diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json index 5d1beb9488986d..efc9a0d40df925 100644 --- a/.devcontainer/devcontainer-lock.json +++ b/.devcontainer/devcontainer-lock.json @@ -9,6 +9,11 @@ "version": "1.5.0", "resolved": "ghcr.io/devcontainers/features/rust@sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c", "integrity": "sha256:0c55e65f2e3df736e478f26ee4d5ed41bae6b54dac1318c443e31444c8ed283c" + }, + "ghcr.io/devcontainers/features/sshd:1": { + "version": "1.1.0", + "resolved": "ghcr.io/devcontainers/features/sshd@sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee", + "integrity": "sha256:f5251b8e4325f68f7280973c6cd65daff414449c66f240621502d4e8e74eb7ee" } } } \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 947527eb4c90f1..874a814fbbfa62 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -5,7 +5,8 @@ }, "features": { "ghcr.io/devcontainers/features/desktop-lite:": {}, - "ghcr.io/devcontainers/features/rust:": {} + "ghcr.io/devcontainers/features/rust:": {}, + "ghcr.io/devcontainers/features/sshd:1": {} }, "containerEnv": { "DISPLAY": "" // Allow the Dev Containers extension to set DISPLAY, post-create.sh will add it back in ~/.bashrc and ~/.zshrc if not set. diff --git a/.github/instructions/best-practices.instructions.md b/.github/instructions/best-practices.instructions.md index 1eea0e9aab5d3a..006c8f2d387ebb 100644 --- a/.github/instructions/best-practices.instructions.md +++ b/.github/instructions/best-practices.instructions.md @@ -24,6 +24,11 @@ applyTo: src/vs/** - Don't hardcode URI scheme strings like `'file'`, `'untitled'`, or `'vscode-remote'`. Use the `Schemas` constants from `vs/base/common/network.ts` (e.g. `Schemas.file`, `Schemas.untitled`, `Schemas.vscodeRemote`). - Don't compare URIs with `===` or `uri.toString()`. Use the comparison utilities from `vs/base/common/resources.ts`: `isEqual` for equality, `isEqualOrParent` for containment, and `getComparisonKey` when a URI is used as a map/set key. These handle path-case sensitivity and fragment/authority correctly. When you need explicit control over case sensitivity, use an `ExtUri` instance (`extUri`, `extUriIgnorePathCase`, or `extUriBiasedIgnorePathCase`) instead of the bound helpers. +## Resource Labels + +- Don't set `{ supportIcons: true }` when creating a `ResourceLabel` (`vs/workbench/browser/labels.ts`). This option makes the label parse `$(codicon)` syntax in the name/description and is only needed when you want to render a codicon inline with the resource text. By default (without it), the label computes the proper file-icon CSS classes for the resource, which is what we almost always want. Note that those classes only render as icons when an ancestor DOM element enables file icons (see below); otherwise the label shows text only. +- To actually display file icons for resource labels in a tree/list, an ancestor container must have the `show-file-icons` class and be wired to the active file icon theme. Don't add the class by hand — call `createFileIconThemableTreeContainerScope` (`vs/workbench/contrib/files/browser/views/explorerView.ts`) on the container. It adds the required `show-file-icons` / `file-icon-themable-tree` classes and keeps `align-icons-and-twisties` / `hide-arrows` in sync with the file icon theme. Register the returned `IDisposable`. A common bug is placing a resource-label list/tree outside such a scoped container, which makes file icons silently disappear. + ## Styling - Avoid `getComputedStyle`. If a style value is needed in both CSS and TypeScript, prefer hardcoding the value in TypeScript and setting it directly on the DOM element (e.g. `element.style.width = '100px'`), or set a CSS custom property via `element.style.setProperty('--my-var', value)` when the value is needed across multiple CSS rules. diff --git a/.github/instructions/committing.instructions.md b/.github/instructions/committing.instructions.md new file mode 100644 index 00000000000000..9cfbb677e51ad1 --- /dev/null +++ b/.github/instructions/committing.instructions.md @@ -0,0 +1,11 @@ +--- +description: Guidelines for creating git commits — respecting signing configuration, verification hooks, and referencing GitHub issues. +applyTo: '**' +--- + +# Committing + +Follow these rules when creating git commits: + +- Always respect the user's commit signing configuration. Do not disable, override, or work around signing (for example, do not pass `--no-gpg-sign` when the user has signing enabled). +- Never commit with `--no-verify`. Always let the pre-commit and commit-msg hooks run. diff --git a/.github/instructions/design-tokens.instructions.md b/.github/instructions/design-tokens.instructions.md index 22281bbc890963..b38a5f5ff4d406 100644 --- a/.github/instructions/design-tokens.instructions.md +++ b/.github/instructions/design-tokens.instructions.md @@ -76,16 +76,32 @@ of a shorthand is checked independently (`0 5px → 0 6px`). `auto`, `%`, ## Font size — `font-size` -Generic UI chrome (fixed px): +Generic UI ramp — pair a **size** token with a **weight** token; "Strong" +reuses the matching size token + `fontWeight.semiBold`, **never** a separate +"strong" size: -| px | Variable | -|----|----------| -| 13 | `--vscode-bodyFontSize` (base) | -| 12 | `--vscode-bodyFontSize-small` | -| 11 | `--vscode-bodyFontSize-xSmall` | - -Agents window ramp (`src/vs/sessions/**`) — pair size with a weight token, -**never** add a separate "strong" size: +| px | Size var | Weight | +|----|----------|--------| +| 26 | `--vscode-fontSize-heading1` | semiBold | +| 18 | `--vscode-fontSize-heading2` | semiBold | +| 13 | `--vscode-fontSize-heading3` | semiBold | +| 13 | `--vscode-fontSize-body1` | regular | +| 11 | `--vscode-fontSize-body2` | regular | +| 12 | `--vscode-fontSize-label1` | regular | +| 11 | `--vscode-fontSize-label2` | regular | +| 10 | `--vscode-fontSize-label3` | regular | + +**Deprecated** — the legacy `--vscode-bodyFontSize*` tokens are deprecated. Use +the generic ramp above instead: + +| Deprecated | px | Use instead | +|------------|----|-------------| +| `--vscode-bodyFontSize` | 13 | `--vscode-fontSize-body1` | +| `--vscode-bodyFontSize-small` | 12 | `--vscode-fontSize-label1` | +| `--vscode-bodyFontSize-xSmall` | 11 | `--vscode-fontSize-body2` | + +Agents window ramp (`src/vs/sessions/**`) — identical values, `agents-`-prefixed +(pair size with a weight token, **never** add a separate "strong" size): | px | Size var | Weight | |----|----------|--------| @@ -105,26 +121,26 @@ is no medium (500). "Strong" = same size token + `semiBold`. See ## Font weight — `font-weight` -The agents window uses a **two-weight ramp** — there are no other weights. -Pair every text style with one of these: +Both the generic and agents ramps use a **two-weight ramp** — there are no other +weights. Pair every text style with one of these: -| weight | Variable | Use | -|--------|----------|-----| -| 400 | `--vscode-agents-fontWeight-regular` | body, labels, metadata | -| 600 | `--vscode-agents-fontWeight-semiBold` | headings, "strong" emphasis | +| weight | Generic var | Agents var | Use | +|--------|-------------|------------|-----| +| 400 | `--vscode-fontWeight-regular` | `--vscode-agents-fontWeight-regular` | body, labels, metadata | +| 600 | `--vscode-fontWeight-semiBold` | `--vscode-agents-fontWeight-semiBold` | headings, "strong" emphasis | - **No medium (500).** `font-weight: 500` is **off the ramp** — snap it to `semiBold` (600). The same goes for `700`/`bold` and any other numeric weight: round to the nearer of 400/600. - **"Strong" is not a separate size.** A "Body 1 Strong" / "Label 2 Strong" - style reuses the matching `--vscode-agents-fontSize-*` token paired with - `semiBold`. Never introduce a separate strong *size* token. + style reuses the matching `--vscode-fontSize-*` (or `--vscode-agents-fontSize-*`) + token paired with `semiBold`. Never introduce a separate strong *size* token. - `normal` ≡ 400 → `regular`. **Leave untouched:** `inherit`, `lighter`, `bolder`, and any `var()`/`calc()` expression. Preserve `!important`. ```css /* avoid */ font-weight: 500; /* not on the 400/600 ramp */ -/* prefer */ font-weight: var(--vscode-agents-fontWeight-semiBold); +/* prefer */ font-weight: var(--vscode-fontWeight-semiBold); ``` ## Codicon size — icon `font-size` diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 1b9a1260794026..50486d8ec4be56 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -25,7 +25,7 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |--------|---------------|----------------------| | **OS-level** (Windows registry, macOS plist) | `NativePolicyService` via `@vscode/policy-watcher` | Watches `Software\Policies\Microsoft\{productName}` (Windows) or bundle identifier prefs (macOS) | | **Linux file** | `FilePolicyService` | Reads `/etc/vscode/policy.json` | -| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` selects among in `getPolicyData()` via `selectManagedSettings(server, nativeMdm, file)` (single authoritative source by precedence server > native MDM > file; no merging between layers) | +| **Account/GitHub** | `AccountPolicyService` | Reads `IPolicyData` from `IDefaultAccountService.policyData`, applies `value()` function. Server-delivered managed settings arrive on `policyData.managedSettings`; native MDM (`INativeManagedSettingsService`) and a file on disk (`IFileManagedSettingsService`) are **separate** inputs that `AccountPolicyService` merges in `getPolicyData()` via `pickManagedSettings(nativeMdm, server, file)` (per-key precedence native MDM > server > file; a key locked by a higher channel cannot be overwritten, keys it leaves unset fall through to lower channels) | | **Copilot managed settings (native MDM)** | `NativeManagedSettingsService` via `@vscode/policy-watcher` | Watches `SOFTWARE\Policies\GitHubCopilot` (Windows) / `com.github.copilot` prefs (macOS); feeds the canonical `managedSettings` bag — see [github-managed-settings.md](./github-managed-settings.md) | | **Copilot managed settings (file)** | `FileManagedSettingsService` | Reads + watches `managed-settings.json` from a well-known per-OS path in the main process, exposed to renderers over IPC; lowest-precedence managed-settings channel — see [github-managed-settings.md](./github-managed-settings.md) | | **Multiplex** | `MultiplexPolicyService` | In the main process, combines multiple OS/file policy readers; in desktop and Agents-window renderers, combines the main-process `PolicyChannelClient` with `AccountPolicyService` | @@ -36,12 +36,12 @@ Policies allow enterprise administrators to lock configuration settings via OS-l |------|---------| | `src/vs/base/common/policy.ts` | `PolicyCategory` enum, `IPolicy` interface, `IPolicyReference`, `ManagedSettingsData`, `IManagedSettingsPolicyDefinitions` | | `src/vs/platform/policy/common/policy.ts` | `IPolicyService`, `AbstractPolicyService`, `PolicyDefinition`, `toSerializablePolicyDefinition` (drops the non-cloneable `value()` for IPC), `getRestrictedPolicyValue` | -| `src/vs/platform/policy/common/copilotManagedSettings.ts` | Managed-settings key constants + well-known file paths, `collectManagedSettingsDefinitions`, `projectManagedSettings`, the shared `normalizeManagedSettings` (single normalizer for all channels), `selectManagedSettings` (channel precedence), `INativeManagedSettingsService` / `IFileManagedSettingsService` | +| `src/vs/platform/policy/common/copilotManagedSettings.ts` | Managed-settings key constants + well-known file paths, `collectManagedSettingsDefinitions`, `projectManagedSettings`, the shared `normalizeManagedSettings` (single normalizer for all channels), `pickManagedSettings` (per-key channel precedence), `INativeManagedSettingsService` / `IFileManagedSettingsService` | | `src/vs/platform/policy/node/nativeManagedSettingsService.ts` | Native MDM watcher (`@vscode/policy-watcher`) for Copilot managed settings | | `src/vs/platform/policy/common/fileManagedSettingsService.ts` | File-based channel: reads + watches `managed-settings.json` on a well-known per-OS path, normalizes via `normalizeManagedSettings` | | `src/vs/platform/configuration/common/configurations.ts` | `PolicyConfiguration` — bridges policies to configuration values; parses JSON-string managed settings back to typed values; applies values to `policyReference` settings | | `src/vs/platform/configuration/common/configurationRegistry.ts` | `policy` / `policyReference` registration; `getPolicyReferenceConfigurations()` (name → subordinate settings) | -| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (server over MDM; single authoritative layer) | +| `src/vs/workbench/services/policies/common/accountPolicyService.ts` | Account/GitHub-based policy evaluation; selects + projects managed settings (native MDM over server; single authoritative layer) | | `src/vs/workbench/services/accounts/browser/managedSettings.ts` | `adaptManagedSettings` — normalizes the server `managed_settings` response into the canonical bag | | `src/vs/workbench/services/policies/common/multiplexPolicyService.ts` | Combines multiple policy services | | `src/vs/workbench/contrib/policyExport/electron-browser/policyExport.contribution.ts` | `--export-policy-data` CLI handler | @@ -329,4 +329,4 @@ Search the codebase for `policy:` to find all the examples of different policy c * Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. * **Regenerate `policyData.jsonc` in a clean environment, or the `PolicyExport` integration test will fail in CI.** `referencedSettings` is only captured for references **loaded at export time**. A plain `npm run export-policy-data` loads your **dev-profile extensions** (e.g. the Copilot extension), which injects `referencedSettings` onto core policies that the test's **fixture-based** export (clean profile, no user extensions) won't produce — so the checked-in file ends up with extra `referencedSettings` and CI fails. This is **not reproducible locally** because the test reuses your default extensions dir. Regenerate the way the test exports: `DISTRO_PRODUCT_JSON= ./scripts/code.sh --export-policy-data="$PWD/build/lib/policies/policyData.jsonc" --user-data-dir="$(mktemp -d)" --extensions-dir="$(mktemp -d)"` (with `VSCODE_SKIP_PRELAUNCH=1`). -* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "server-delivered managed settings win over native MDM; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. +* Document **behavior and business-logic expectations**, not copy-pasted implementation. Reproducing internal code (e.g. the `getPolicyData()` merge body) in the skill rots the moment the source changes and adds no information beyond the source itself. State the contract in prose (e.g. "native MDM managed settings win over the server-delivered channel; the two layers are never merged") and point to the source for the implementation. Reserve code blocks for the **author-facing API contract** a contributor must follow — how to *declare* a `policy` / `managedSettings` / `value` callback — not for restating runtime plumbing. diff --git a/.github/skills/add-policy/github-managed-settings.md b/.github/skills/add-policy/github-managed-settings.md index 2dd4735795d127..8f6bb410397611 100644 --- a/.github/skills/add-policy/github-managed-settings.md +++ b/.github/skills/add-policy/github-managed-settings.md @@ -52,14 +52,15 @@ described delivery slots (native MDM, server-managed, and file-based). All three VS Code channels converge in `AccountPolicyService.getPolicyData()`. -**Precedence: server-delivered managed settings win over native MDM, which in turn win -over the file-based channel** (`selectManagedSettings` in `copilotManagedSettings.ts`). -There is a single authoritative source at any point in time — the channels are **not** -merged. The highest-precedence non-empty channel wins outright and the rest are ignored. -Rationale: the server is harder to bypass than local MDM, and a local file is the most -easily tampered with, so admins need one authoritative source to reason about. The -winning channel is then projected onto the declared schema (see below). Client-side -merging still happens *within* the winning channel (e.g. `enabledPlugins`, +**Precedence: native MDM managed settings win over the server-delivered channel, which in +turn wins over the file-based channel** (`pickManagedSettings` in `copilotManagedSettings.ts`). +Precedence is resolved **per key**: for each key the highest-precedence channel that supplies +it wins, but a key that the higher channels leave unset is still filled in by a lower channel. +A value an admin locks via native MDM therefore cannot be overwritten by the server or a file, +while keys the higher channels never set remain available to lower ones. Rationale for the +order: the server is harder to bypass than local MDM, and a local file is the most easily +tampered with. The merged bag is then projected onto the declared schema (see below). +Client-side merging still happens *within* a channel's value (e.g. `enabledPlugins`, `extraKnownMarketplaces`). ## Schema source of truth @@ -201,7 +202,7 @@ delivery slot for the managed value. | `collectManagedSettingsDefinitions(policyDefinitions)` | Aggregates every policy's `managedSettings` into one `key → { type }` map. **Single source of truth** for which keys (and types) are honored; drives both the MDM watcher and the server projection. | | `hasManagedSettingsDefinitions(policyDefinitions)` | Cheap short-circuiting existence check (does *any* policy declare a managed key?) — used to decide whether the native MDM watcher is needed at all, without building the full aggregate. | | `projectManagedSettings(values, definitions, onWarn?)` | Keeps only declared keys whose runtime value **matches the declared type**. Undeclared keys and type mismatches are **dropped (validated, never coerced)**, with an optional warning. | -| `selectManagedSettings(server, nativeMdm, file)` | Picks the single authoritative channel by precedence (server → native MDM → file); never merges. **The extension point when adding a new channel** — extend the `ManagedSettingsSource` union and this function together. | +| `pickManagedSettings(nativeMdm, server, file)` | Merges the channels **per key** by precedence (native MDM → server → file): the highest-precedence channel that sets a key wins, lower channels fill in keys the higher ones leave unset, and every contribution is recorded for provenance. **The extension point when adding a new channel** — extend the `ManagedSettingsChannel` union, the `MANAGED_SETTINGS_CHANNELS` order, and this function together. | | `managedSettingValue(key)` | Builds the standard pass-through `value` callback `policyData => policyData.managedSettings?.[key]`. Use for the common "lock to the managed value, else fall through" case (see [Declaring a managed setting](#declaring-a-managed-setting-on-a-policy)). | ### Normalization: the structured-key descriptor table diff --git a/.github/skills/chat-perf/SKILL.md b/.github/skills/chat-perf/SKILL.md index 7769d511cf41c0..41c5e22524a4bd 100644 --- a/.github/skills/chat-perf/SKILL.md +++ b/.github/skills/chat-perf/SKILL.md @@ -42,6 +42,8 @@ npm run perf:chat-leak -- --messages 20 --verbose 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. +> **You don't always need a baseline.** A baseline exists only for *comparison* (regression detection). If you just want the current build's numbers — profiling a single change, capturing traces/heap snapshots, or iterating on a scenario — pass `--no-baseline` to skip downloading and benchmarking the baseline entirely (roughly halves runtime). Baseline comparison is what turns raw measurements into a pass/fail verdict; without it you still get all the metrics, just no verdict. + ### Key flags | Flag | Default | Description | @@ -51,7 +53,7 @@ Launches VS Code via Playwright Electron, opens the chat panel, sends a message | `--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. | +| `--no-baseline` | — | Skip the baseline entirely — just measure the test build (no download, no comparison, ~2× faster). Use when you only need raw numbers, not a regression verdict. | | `--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). | @@ -60,6 +62,7 @@ Launches VS Code via Playwright Electron, opens the chat panel, sends a message | `--force` | — | Skip build mode mismatch confirmation prompt. | | `--ci` | — | CI mode: write Markdown summary to `ci-summary.md` (implies `--no-cache`, `--heap-snapshots`, `--cleanup-diagnostics`). | | `--heap-snapshots` | — | Take heap snapshots after each run (slow; auto-enabled in `--ci` mode). | +| `--gc-object-stats` | — | **GC deep-dives only.** Enables V8 `gc_stats` tracing (per-type heap object dump on every GC). ⚠️ Corrupts all timing metrics — a major GC landing mid-request adds ~550ms — so never use it for benchmarking. Off by default; prefer heap snapshots for memory analysis. | | `--cleanup-diagnostics` | — | Delete heap snapshots, CPU profiles, and traces to save disk. During runs, only the latest run's files are kept; after comparison, files for non-regressed scenarios are deleted. Auto-enabled in `--ci` mode. | | `--setting ` | — | Set a VS Code setting override for all builds (repeatable). | | `--test-setting ` | — | Set a VS Code setting override for the test build only. | @@ -186,13 +189,13 @@ Run `npm run perf:chat -- --help` to see the full list of registered scenario ID Only these metrics trigger a regression failure (when they exceed the threshold with statistical significance): - `timeToFirstToken`, `timeToComplete` — user-perceived latency +- `layoutDurationMs` — total layout time from the trace (the *real* layout cost) - `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) +- `layoutCount` — number of layout ops; inflated by CSS animations (compositor-driven, cheap). A build can do *more but cheaper* layouts, so gate on `layoutDurationMs`, not this count. +- `recalcStyleCount` — number of style recalcs; inflated by CSS animations (compositor-driven, cheap) - `timeToRenderComplete` — includes typewriter animation tail - Memory/heap metrics — too noisy for single-request benchmarks @@ -229,6 +232,54 @@ Launches one VS Code session, sends N messages sequentially, forces GC between e - DOM nodes stable after first message — normal (chat list virtualization working) - DOM nodes growing linearly — rendering leak, check disposable cleanup +## CI runs & pinpointing regressions + +The perf + leak checks run in CI via the **`.github/workflows/chat-perf.yml`** workflow (a scheduled daily `workflow_dispatch`, plus manual dispatch). Each run benchmarks the current `main` as the **test** build against a fixed release **baseline** (from `config.jsonc`, e.g. `1.122.0`). Because the baseline is fixed, the **test** median for a metric across successive daily runs traces `main`'s trajectory over time — that's what lets you bisect *when* something regressed or went flaky. + +### Finding and reading historic runs + +```bash +# List recent runs (most recent first) — note the run IDs and dates +gh run list --workflow chat-perf.yml -R microsoft/vscode --limit 30 \ + --json databaseId,status,conclusion,createdAt,headBranch \ + --jq '.[] | [.databaseId, (.conclusion//.status), .createdAt, .headBranch] | @tsv' + +# See a run's per-job results +gh run view -R microsoft/vscode --json jobs \ + --jq '.jobs[] | [.name, (.conclusion//.status)] | @tsv' + +# Trigger a run manually against any ref/version: +gh workflow run chat-perf.yml -R microsoft/vscode --ref main \ + -f test_build= -f baseline_build= +``` + +### Artifacts per run (and retention — this matters for old runs) + +| Artifact | Contents | Retention | +|---|---|---| +| `chat-perf-summary` | Unified `ci-summary.md`: verdicts, per-metric medians ±stddev, **per-run raw tables** | 30 days | +| `perf-results-` | Everything below **plus traces, CPU profiles, heap snapshots** (large) | 30 days | +| `leak-results` | Leak log + `chat-simulation-leak-results.json` | 30 days | +| `perf-summary-` | `results.json` (full per-run metrics incl. `rawRuns`) + `baseline-*.json` | **1 day** | + +```bash +# List a run's artifacts + whether they've expired +gh api repos/microsoft/vscode/actions/runs//artifacts \ + --jq '.artifacts[] | [.name, .expired] | @tsv' + +# Download the human-readable summary (best first stop; survives 30 days) +gh run download -R microsoft/vscode -n chat-perf-summary +``` + +### Pinpointing where a metric regressed / went flaky + +1. **Bisect across dates.** Pull `chat-perf-summary/ci-summary.md` from several runs spanning the window. Compare the **test** median (and ±stddev) for the suspect metric+scenario run-to-run — the day it jumps is when `main` changed. A metric that goes *bimodal* (e.g. a raw-run column showing two clusters like `~250 / ~900`) is the flaky signature; a stable shift is a genuine regression. +2. **Confirm it's real vs. measurement noise.** Check the per-run raw tables (in `ci-summary.md`) — high ±stddev / bimodal values mean the *median* is being pulled around by a few outlier runs, not a uniform slowdown. +3. **Deep-dive the cause.** Download `perf-results-` (has the `trace.json` per run) for a slow run and inspect what dominates the slow window. Trick that found the `gc_stats` artifact: sum main-thread `RunTask` durations between two `code/chat/*` marks (e.g. `willCollectInstructions` → `didCollectInstructions`) — if the window is ~0% busy, it's an async wait or a GC pause, not real work; then look at the largest `X`-phase events in that window (`MajorGC`, `Layout`, etc.). +4. **Reproduce a suspect commit locally** to bisect precisely: `npm run perf:chat -- --build --baseline-build --runs 7` (or two commits directly via `--build --baseline-build `). + +> Tip: `perf-summary-*` (the machine-readable `results.json` with `rawRuns`) is deleted after **1 day**, so for older runs rely on `chat-perf-summary` (raw tables, 30 days) or extract `results.json` from `perf-results-*` (also 30 days). + ## Architecture ``` diff --git a/.github/skills/integrated-browser/SKILL.md b/.github/skills/integrated-browser/SKILL.md new file mode 100644 index 00000000000000..c4ace6680938b4 --- /dev/null +++ b/.github/skills/integrated-browser/SKILL.md @@ -0,0 +1,87 @@ +--- +name: integrated-browser +description: Use this when working on the VS Code integrated browser ("browserView") to understand its architecture and mental model. Covers the embedded Chromium browser, its editor tab, navigation, overlay/layout, sessions, and agent browser tools under `src/vs/platform/browserView` and `src/vs/workbench/contrib/browserView`. +--- + +# Integrated Browser Architecture + +The integrated browser ("browserView") embeds a **real Chromium browser** in VS Code, backed by an Electron `WebContentsView`. It renders live pages, presents each as an editor tab, and lets agents drive those pages through tools. It powers the in-product browser tab and the agent "browser" tools. It is **not** the old `extensions/simple-browser` (an iframe-in-a-webview), which now delegates to this on desktop. + +It's a heavyweight, security-sensitive, multi-process primitive, and **almost every design decision follows from that.** This file describes the load-bearing ideas that rarely change. It deliberately does **not** enumerate current features/tools/commands/settings — those churn; the live `features/` and `tools/` folders are the source of truth. Build the mental model here, then go read the specific code you're changing. + +## The one idea everything follows from + +**A page is a native `WebContentsView` that only the main process may create, own, and position. Nothing else can touch it directly.** It's owned by the main process and painted by the OS compositor *on top of* the workbench DOM — not inside it. Everything else works around this: + +- The **renderer** (editor UI + agent tools) can't hold the page; it holds a model/proxy and talks to main over IPC. +- **Playwright** is heavy and long-lived, so it runs in the **shared process**, reaching the page over IPC too. +- The page **paints over the DOM**, so the workbench choreographs alignment, z-order, focus, and screenshots by hand. + +## Three processes, and why + +| Process | Location | What lives here | +|---------|----------|-----------------| +| **Main** | `platform/browserView/electron-main` | The `WebContentsView`, sessions, trust, permissions, history, CDP, screenshots — authoritative page state. Only main can create native views. | +| **Shared** | `platform/browserView/node` | Playwright + remote/group automation services. Keeps a heavy dependency out of main (stability) and renderer (lifecycle). | +| **Renderer** | `workbench/contrib/browserView` + `platform/browserView/electron-browser` | Editor pane, UI feature contributions, agent tools, page preload script. Holds only lightweight proxies. | + +**Layer rule:** `platform` must not import `workbench`; the agent host can't import `workbench`; shared types belong in `platform/common`. The renderer reaches main/shared only through `ProxyChannel` IPC, never by importing implementations. Channels are registered in `electron-main/app.ts` and `electron-utility/sharedProcess/sharedProcessMain.ts`. Split: **page ops/state → main; agent automation → shared; CDP plumbing is its own channel both renderer and shared use to reach main.** + +## The renderer holds a mirror, not the truth + +The renderer model is a **read replica** of state owned by the main-process view. Flow is one-directional: + +``` +renderer feature ──command──▶ model ──IPC──▶ main BrowserView ──▶ Chromium + ▲ │ + └──────────── event (state changed) ◀──────────┘ +``` + +To *do* something, call a method (round-trips to main); to *react*, listen to a model event. Never **compute** page state in the renderer — it has no access to the web contents. + +- **New state** (url/title/loading/zoom/…): make it authoritative in the main `BrowserView`, emit a change event, then mirror the field + event on the renderer model and forward it over the channel. +- **New operation** (navigate/reload/focus/…): define it in main, expose a thin proxy method on the renderer model that round-trips the channel. + +Wanting the renderer to "just read" something off the page is the signal you need new mirrored state + an event from main. + +## The native view floats above the DOM (the overlay problem) + +The single most error-prone area — the cause of nearly every "won't move / misaligned / shows through a menu / won't focus" bug. The page is painted by main in **screen coordinates on top of** the renderer, so the workbench fakes a normal DOM element. Treat the page's rectangle, visibility, focus, and imagery as things the workbench **coordinates**, never DOM facts: + +- **Alignment.** The editor renders an empty DOM stub, measures its screen rect, and ships bounds to main. CSS zoom and screen pixels disagree, so bounds are **pixel-snapped**. New layout that moves the page must feed this bounds computation, not just move a DOM box. +- **Z-order.** The native view paints *above* all workbench UI, so menus/popups/hovers/dialogs that should sit over the page would be hidden. The workbench detects overlap and **hides the native view**, swapping in a placeholder. New floating UI over the page must be detectable by that machinery — a high CSS z-index won't do it. +- **Flicker masking.** While hidden/repositioning, a periodic screenshot stands in. Screenshots are also the only legit way page imagery reaches the renderer/agents — nobody reads native pixels. +- **Focus & keyboard.** Focus is bridged explicitly. A **preload script** (injected into every page in an isolated world) decides which keystrokes the page keeps vs forwards to VS Code keybindings. Treat it as a **trust boundary**: assume a hostile page, keep it minimal and side-effect-free. + +## A browser tab is a real editor, extended by contributions + +A page is a normal editor — a read-only, serializable `EditorInput` + `EditorPane` on the standard registry, resolving lazily to a view-model (unloadable without closing the tab). This inherits tabs, splitting, persistence, focus, and keybinding scoping for free. + +The pane is thin. Behavior is added via a **local contribution model (separate from workbench contributions)**: small classes that attach to the editor, get DI, and hook a fixed lifecycle (model attach/detach, layout overrides, resize/visibility, focus, UI insertion). Each gets a lifetime **scoped to the attached model**, so per-page disposables clean up on navigate/close. Even native-view rendering is just one contribution. + +**To add behavior, write a new contribution modeled on a sibling — don't grow the editor or the main view.** Contributions affecting the page rect compose through **prioritized layout overrides** (lower runs first; e.g. emulation sizes the viewport, pixel-snap runs last), so **order matters** — never hard-code pixels. + +## Identity and isolation: sessions, groups, CDP + +Keep these three distinct: + +- **Session = storage identity.** Each Electron session maps 1:1 to a `BrowserSession` (cookies, cache, storage), and its id **doubles as the CDP browser-context id**. Scoped **global**, **per-workspace**, or **ephemeral**; multiple tabs can share one. Security is enforced here: `file://`, certificate trust, and permissions are all gated at the session (e.g. local files need workspace trust). New capabilities that expand a page's reach belong here, not on a feature. +- **Group = automation visibility.** A group assembles a dynamic set of views and exposes them as one logical CDP "browser." Groups *reference* views without owning them; a view can be in several. This is how different clients (chat session, DevTools, an extension) each see only their subset. +- **CDP is proxied, never raw.** A protocol-aware proxy implements browser/target-level domains (discovery, auto-attach, flattened sessions, contexts) and forwards the rest per-target. This lets one logical browser be stitched from views that come and go, and lets Playwright connect without touching Chromium directly. + +Cookies/login/storage → **sessions**. "Which pages can this client see" → **groups**. The protocol itself → **the proxy**. + +## Agents share the user's page — under a privacy gate + +- **Same page, shared cooperatively.** Playwright drives the *same* `WebContentsView` the user sees, via CDP, one connection per chat session. Human and agent actions can collide; conflicts resolve in the user's favor (a human prompt/dialog can interrupt automation). The workbench (not Playwright) owns device emulation, so Playwright's auto-emulation is suppressed except during an agent action. Assume a human may interact with the same page concurrently. +- **Pages are private until shared.** Content isn't visible to agents by default. A page's *sharing state* gates content; a separate **availability gate** (chat enabled, agent mode, settings) decides whether the full tool set is registered — when it isn't, only a reduced "open a URL without content access" capability exists. URLs are screened by the network-filter and masked when blocked. Treat page content as **untrusted model input** (prompt injection). Any new agent surface must honor these gates. (Tool *names* live in `platform` so the agent host, which can't depend on `workbench`, can reference them.) + +## Remote pages + +When a page must load as if from a remote machine (forwarded `localhost` in a remote workspace, container, or Codespace), a **tunnel proxy is applied to the page's session**; credentials come from the extension host, and navigation can defer until the proxy is live. "Open localhost" isn't always local — remote URLs are rewritten to their forwarded form, and the proxy lives on the **session**, not an individual call. + +## Practical guidance + +- **Desktop-only.** Nothing runs in web; add to `electron-*` / `node` and let the `browser/` stubs throw "not available in web". +- **Match existing patterns.** New capability → a new feature contribution and/or tool, modeled on a sibling. Cross-process state → a model method + event from main, not local renderer state. Layout change → a prioritized override, not pixels. +- **Mind the trust boundaries:** the preload (hostile page), the session (storage / permissions / file access), agent gating (sharing + availability + network filter), and chat attachment (prompt injection). diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index ff1bdfea16ee80..8d2cea64e3107e 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -27,12 +27,15 @@ Then read the relevant spec for the area you are changing (see table below). If ## Common Pitfalls - **Wrong menu IDs**: Never use `MenuId.*` from `vs/platform/actions` for Agents window UI. Always use `Menus.*` from `browser/menus.ts`. +- **Sessions menu ids must live in the shared menu registry**: Do not declare sessions-owned `new MenuId(...)` constants ad hoc inside individual parts. Add them to `browser/menus.ts` under `Menus` with discoverable `SessionsEditor...` names so ownership and reuse stay obvious. - **Events instead of observables**: Session state must flow through `IObservable`, not `Event`. Use `autorun`/`derived` for reactive UI, not `onDid*` event listeners. - **Importing from providers**: Non-provider `contrib/*` code must never import from `contrib/providers/*`. Extract shared interfaces to `services/` or `common/`. - **`IAgentSessionsService` in shared code**: `IAgentSessionsService` (`vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService`) is a Copilot-provider internal and may be imported **only** by the Copilot chat sessions provider (`contrib/providers/copilotChatSessions/`). Shared sessions code (core/services/non-provider contribs, e.g. the sessions list or visible-sessions grid) must stay provider-agnostic and go through `ISession`/`ISessionsManagementService` — never reach into `model.observeSession(...)` etc. for lazy loading. This is enforced by an ESLint `no-restricted-imports` ban scoped to `src/vs/sessions/**` (Copilot provider exempted). - **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`). - **Modifying workbench code**: Prefer extending/wrapping workbench classes in the sessions layer over modifying shared workbench components. - **Timeouts as fixes**: Never use `setTimeout`/`disposableTimeout`/arbitrary delays to fix bugs or implement behaviour. They are race-prone guesses that mask the real ordering/state problem. Drive logic off deterministic signals instead — observables (`autorun`/`derived`), explicit events (`onDidChange*`), lifecycle phases, or awaiting the actual async operation. +- **Grid `onDidChange` is not a sash-drag signal**: the workbench `SerializableGrid`/`GridView` `onDidChange` fires for size changes and view add/remove, but not internal splitview sash drags. If logic must react to a part node being resized by a sash, route it through that part's `layout(width, ...)` callback, which receives the in-progress node width. +- **Docked detail collapse must use the raw sash width before clamping**: the docked auxiliary bar keeps a minimum visible width, so checking the clamped width can never detect a drag-to-zero collapse. Decide collapse from the raw requested sash width, then route the hide through `setPartHidden(AUXILIARYBAR_PART)` so context keys and per-session capture stay in sync. - **Stashed state read back later (side-channels)**: Never stash a value on a service during one method call and read it back from a separate query later, assuming it is still valid (e.g. a `Set`/flag set in `openSession` and consumed by a `shouldX()` pull-API). This is fragile temporal coupling. Instead, make it reactive state that is set **atomically together with its source of truth** and consumed reactively. Example: per-activation intent like "open in background / preserve focus" is exposed as an `IObservable` set in the **same transaction** as `activeSession` (via a single internal setter so it can never go stale), and read with `.read(reader)` in the consumer's `autorun` — never via a consume-once getter. - **Provider-owned model/mode selection belongs in the loaded chat model, with draft persistence driven by debounce**: For AHP-backed chats, `setModel` / `setAgent` must push the selection into the loaded `IChatModel.inputModel` (like `_updateChatSessionState`) and let the draft-sync debounce emit `chat/draftChanged`. Do not immediately dispatch a model/agent-only draft from the provider, because it can overwrite unsaved typed text before the debounced full input-state draft is persisted. - **Blocking on a "pending/waiting" state instead of creating + upgrading**: When an entity (e.g. a draft session) depends on something that registers asynchronously, don't withhold creation behind a pending/waiting state. Prefer creating immediately with the best available data, then **replace/upgrade** it once the awaited dependency arrives (driven by an `onDidChange*`/observable signal), cancelling the upgrade if the user changes the inputs meanwhile. Do **not** bound the upgrade with a timeout or even a lifecycle milestone like `LifecyclePhase.Eventually` — an agent host connects lazily and can surface its session types arbitrarily late, which would lock in the wrong fallback. Let the upgrade listener live for the consumer's lifetime instead. @@ -50,17 +53,55 @@ Then read the relevant spec for the area you are changing (see table below). If - **Peer chats have no server `summary`, so dedup side-channel dispatch against the last value sent for that chat**: equality guards before dispatching `SessionModelChanged`/`SessionAgentChanged` compare against `summary.model`/`summary.agent`, which only exist for the session's default chat. For peer chats, track the last-dispatched model/agent on the `AgentHostChatSession` instance (auto-cleaned on dispose) and diff against that — otherwise every peer-chat turn redundantly re-dispatches (and re-resolves the agent), and an intentional "clear selection" (`undefined`) can't be detected. - **Scrollable transcript surfaces must use workbench scrollbars**: Don't make Agents/voice transcript regions scrollable with native `overflow-y: auto` on the content node. Wrap transcript content in `DomScrollableElement`/list widgets so scrollbars match VS Code theming and remain usable in narrow auxiliary-window layouts. - **Background-sending a multi-chat composer must reset the composer *before* dispatching the send, not concurrently**: in `NewChatInSessionWidget._send`, creating the replacement untitled chat (`openNewChatInSession({ forceNew: true })` → `provider.createNewChat`) and the fire-and-forget background `sendRequest` both reach into shared chat-session state (`acquireOrLoadSession` / `getOrCreateChatSession`) for chats in the **same group**. Running them concurrently (send first, reset second) raced and left the sent chat stuck spinning with its message never dispatched, plus a second empty "New Chat" tab. Fully `await` the composer reset first, then fire the background send so it runs on its own. -- **Chat tab order belongs in the renderer, not the providers**: providers report chats in unstable orders — the agent host re-sorts its `state.chats` catalog when a chat finishes a turn (moving the just-completed one last). Don't try to fix a "tab jumps to the end on completion" bug inside one provider (it won't cover the others). Instead keep the provider's order in the renderer's rebuild autorun (`chatCompositeBar.ts`) and only move in-composer `Untitled` chats to the end. +- **Chat tab order is the provider's stable creation order; don't reorder in the renderer**: the agent host delivers `state.chats` in stable creation order (append on add, replace-in-place on update — see `agentHostStateManager`/the session reducer), and a genuinely new chat is appended last. The renderer's rebuild autorun (`chatCompositeBar.ts`) must render that order as-is. Do **not** partition/move in-composer `Untitled` chats to the end: a draft is already last, and reordering by status makes a tab jump when a draft commits out of creation order (e.g. sending the 3rd of three drafts first moved it to the front). A chat's `Untitled` presentation (via `AdditionalChat._isNew`, needed so `sessionView.ts` shows the composer) is independent of tab order and must not drive it. Also note `_restorePeerChats` (`agentService.ts`) must seed restored chats in `getChats()` order, not in `Promise.all` resolution order, or the catalog scrambles on reload. - **A new chat must report `SessionStatus.Untitled` until its first request is sent, regardless of how the provider creates it**: `sessionView.ts` only shows the new-chat composer (which owns the Alt+Enter background-send handler) when `activeChat.status === Untitled`. The agent host commits a new peer chat *eagerly*, so its host status is `Completed` — surfacing the standard chat widget and breaking background send. Gate the chat's presented status on a provider-side `isNew` flag (`AdditionalChat.markNew`/`markSent`, set in `createNewChat` and cleared in `sendRequest`'s committed-chat branch), not on the host-reported status. - **Service operations should return a result or throw, not `undefined` for unsupported cases**: capability-gated operations like `forkChatInSession` must throw when the provider/session cannot perform them. Keep fallback decisions in the caller before invoking the service instead of encoding fallback as an `undefined` service result. - **Drop a fork when its turn point is unknown, don't forward it empty**: in `AgentService.createChat`/`createSession`, if the requested fork `turnId`/`turnIndex` resolves to no source turns, set `fork: undefined` and fall through to a fresh create. Forwarding the fork with an empty turn slice makes the Copilot provider call `sessions.fork` with no `toEventId`, inheriting the entire backend conversation while the new chat UI is seeded with zero turns — an inconsistent hidden-history chat. - **A responsive-layout autorun must re-baseline (not react) to controller-driven restores, holding the flag across the async reveal**: the desktop [D7] responsive sidebar hides the sessions sidebar when small + editor + aux-bar are all open. Switching sessions restores layout via two async paths — the desktop aux-bar restore (`openView`/`openViewContainer`) **and** the base controller's editor working-set apply (`_applyWorkingSet`, which reveals the editor part *after* an `await` and runs on a `Sequencer` microtask). Both reveal parts in a *later* autorun run, so an inline "same-run session changed" check only absorbs the synchronous transition and the async reveal still auto-hid the sidebar on navigation. Fix: a shared base-controller `_withSessionLayoutRestore(work)` epoch wraps **both** restore paths (the working-set wrap is the critical one for non-modal editors); the D7 autorun re-baselines `_previousSpaceConstrained` while `_isRestoringSessionLayout` is true. Also gate the constrained derivation on `!multipleSessionsVisibleObs` so the feature is disabled with multiple sessions visible. Never use a `setTimeout` to bridge the async reveal — tie the flag to the actual promise. - **A promise-tied "epoch" helper must decrement synchronously for void/sync work, only deferring for real Promises**: `_withSessionLayoutRestore` increments a depth counter, runs `work()`, and decrements when done. If it *always* schedules the decrement on a microtask (`Promise.resolve(result).finally(...)`) — even when `work()` returns `undefined` (the common no-op restore, e.g. a session with no workspace) — the depth stays elevated for the entire synchronous caller/test body, so `_isRestoringSessionLayout` reads `true` forever and the consumer (D7) silently stops acting. Only defer the decrement when `work()` returns a thenable; for void/sync (or throwing) work, decrement in the `finally`. +- **A quick-chat's workspace-less kind is fixed at adapter construction — every construction path must carry `_meta`**: `AgentHostSessionAdapter` resolves its session-kind (`QuickChatSessionKind` vs `WorkspaceSessionKind`) **once**, from `readSessionWorkspaceless(metadata._meta)` in the constructor; `_computeWorkspace()` and `isQuickChat` delegate to that fixed kind and **cannot be flipped by a later `update`/`setMeta`**. So the `_meta.workspaceless` tag must be present in the metadata passed to **every** adapter-construction path: `_refreshSessions()`/`listSessions` **and** the live `_handleSessionAdded(summary)` notification (carry `summary._meta`). Dropping `_meta` on either locks a committed quick chat into `WorkspaceSessionKind` and leaks its `/.copilot/chats/` scratch dir as a `workspace` (breaking the archive-on-delete fallback, list badges, changes/files). On the host, `CopilotAgent.listSessions()` must re-emit `_meta.workspaceless` from persisted `copilot.workspaceless` metadata (mirroring `getSessionMetadata`) so restored sessions carry the tag once the state-manager live summary is gone. The host still keeps the tag on both the summary `_meta` and `SessionState._meta` (`createSessionState(summary)` copies it) so the channels stay consistent. +- **Don't infer "quick chat" from `workspace === undefined`**: a session's workspace observable is `undefined` for genuine quick chats but also transiently/edge-case for workspace-bound sessions, so keying quick-chat UI (context keys, list grouping) on it is imprecise. Expose the intent explicitly via the optional `ISession.isQuickChat: IObservable` (only quick-chat-capable providers set it; absent ⇒ `false`). The agent-host adapter derives it from `readSessionWorkspaceless(_metaObs)`; non-quick-chat providers omit it. Consume it through `isQuickChatSession(session)` / `session.isQuickChat?.read(reader) ?? false`. +- **Workspace-less is inferred from absent `workingDirectory` — exclude forks from that inference**: in `CopilotAgent.createSession`, `isWorkspaceless` must be `!sessionConfig.fork && !sessionConfig.workingDirectory`. A fork that arrives without an explicit `workingDirectory` should inherit the source session's context, not be tagged `copilot.workspaceless` and dropped into a scratch dir + quick-chat system prompt. +- **On a failed quick-chat create, don't activate an unrelated draft**: `SessionsService.openQuickChat` must, on `createQuickChat` throwing, log and return `undefined` without falling back to `_activate(newSession.get())` — that observable can hold a workspace-bound draft from a different call site, so activating it is surprising. Return the activated `IActiveSession` from `_activate`/`openQuickChat` (setActive is synchronous and yields the wrapper) and have the caller focus that exact value, rather than re-reading `activeSession.get()` afterwards. +- **Hide an aux-bar view CONTAINER via `hideIfEmpty: true` + a view `when`, not a container `when`**: `IViewContainerDescriptor` has **no `when`** property (only `IViewDescriptor` does). To conditionally hide a whole container (its tab/title) — e.g. the Agents-window Changes/Files containers for workspace-less sessions — put the context-key `when` on the inner view(s) and set `hideIfEmpty: true` on the container. Container visibility is `hideIfEmpty && activeViewDescriptors.length === 0` (`viewsService.updateViewContainerEnablementContextKey`), and `activeViewDescriptors` already respects each view's `when`, so the container hides reactively when all its views' `when` go false. +- **Don't gate "is this a quick chat?" routing on `isCreated && isQuickChat`**: a quick-chat **draft** is `Untitled`, so `isCreated` (`= status !== Untitled`, `visibleSessions.ts`) is `false` — yet it is still a quick chat. Gating quick-chat routing on `isCreated && isQuickChat` (e.g. the "New" action) makes a draft fall through to the workspace path (scratch-dir composer, no session-type picker, "No models available"). Route on **`isQuickChat` alone** so drafts and committed quick chats behave identically; use `isCreated` only when you genuinely need to distinguish a committed session from an in-composer draft. +- **Cmd+N in the Agents window is a new-**session** gesture only — don't fold quick-chat/peer-chat creation into it**: session creation (**Cmd+N**, `NewChatInSessionsWindowAction`/`workbench.action.sessions.newChat` → always `openNewSession`), quick-chat creation (Chats-section **"+"**, **Cmd+K Cmd+N**, `NewQuickChatAction` → `openQuickChat`), and peer-chat creation (chat **"+"**, **Cmd+T**, `AddChatToSessionAction`) are three **distinct keybindable actions**. Keep them separate — Cmd+N must not become context-aware/mirror-route to a quick chat based on the active session's kind. +- **The New action must never inherit a quick chat's folder into the workspace composer**: `openNewSessionFromActive` seeds the new-session composer with `activeSession.workspace.get()?.uri`. A quick chat is workspace-less by intent, but if its `workspace` observable is ever non-undefined (e.g. a stale-build/coupling leak where `_kind` resolved to `WorkspaceSessionKind` because `_meta.workspaceless` was dropped, exposing the host's scratch `~/.copilot/chats/` cwd as a workspace), Cmd+N would carry that scratch dir into `createNewSession` → "New session in ``", single/no session type, no picker, "No models available". Gate the folder inheritance on `activeSession.isQuickChat` so a quick chat **always** falls to the clean folder-picker composer regardless of any leaked workspace value. +- **A reused new-session composer must re-seed its workspace draft when it swaps out of quick-chat mode**: the session-type picker hides itself when it has no folder types (`sessionTypePicker` `_folderSessionTypes.length === 0`), which is the case whenever the composer has **no active session** (`refresh(undefined)` clears the types). A *freshly opened* new-session composer avoids this by seeding a workspace draft from the restored folder in its constructor — but the same `NewChatWidget` instance is **reused** across the quick-chat→new-session transition (`sessionView.ts` keeps `kind==='newSession'`), and Cmd+N's `openNewSession` discard branch only `_activate(undefined)`, leaving the reused composer session-less → picker hidden. Fix by re-running the constructor's seed (`_seedWorkspaceDraft()`) from an autorun when `_isQuickChatComposer` flips **true→false with no active session**, so the reused composer matches a fresh one (folder + visible picker). Don't assume the constructor-time restore covers a reused composer. +- **Every untitled-session-title fallback must be quick-chat aware**: an untitled session's title observable is `''`, so a hardcoded `localize(…, "New Session")` fallback shows "New Session" even for a quick chat (whose composer says "New Chat"). Route **all** such fallbacks through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`, boolean param so each caller controls reader-tracked `.read(reader)` vs `.get()`). There are ≥5 sites — titlebar (`sessionsTitleBarWidget`), session header (×2: title + rename placeholder), list-row hover (`sessionHoverContent`), sessions picker (`sessionsActions`) — keep them on the helper; never hardcode "New Session". (The Cmd+N *action* title stays "New Session" — that action creates a session, unrelated to a session's own title.) ## Capturing Feedback (meta-rule) Whenever the user flags a wrong pattern, rejects an approach, or gives design/rules feedback, **automatically add it** as a concise pitfall/learning to this `Common Pitfalls` section (or the most relevant spec doc) in the same change — without being asked again. Keep each entry 1–3 sentences: the anti-pattern, why it is wrong, and the preferred pattern. +- **Default/main chat title persistence differs per provider — don't unify them**: For the **agent host**, the default (main) chat title is independent of the session title (`AgentHostSessionAdapter._defaultChatTitleOverride`, persisted on the host as `customChatTitle:`); it must be seeded back on restore via `restoreSession`/`_ensureDefaultChat` (mirroring `_restorePeerChats`), or it reverts to the session title after a process restart / idle eviction. For the **local chat sessions provider** (`localChatSessionsProvider`), the primary chat and the session intentionally **share one `_title` observable**, so renaming the first/main chat updates the session title live — this is by design; do not "fix" it to be independent. Only additional (non-primary) local chats have their own title. + +- **`ISession.capabilities` must be observable, not a live plain getter**: capabilities can hydrate/change after a session first surfaces (e.g. an agent host whose root state arrives after the session's first `SessionState`). A plain getter cannot be tracked by the context-key autorun (`setActiveSessionContextKeys` reads it inside an `autorun`), so `supportsMultipleChats`/`sessionSupportsFork` would stay stale, and a multi-chat catalog processed while `supportsMultipleChats` was still `false` would stay collapsed to `[defaultChat]`. Expose `capabilities` as `IObservable` (agent host derives it from `connection.rootState` via `observableFromEvent` + `derivedOpts` with `structuralEquals`; static providers use `constObservable`), have consumers read `.read(reader)`/`.get()`, and re-apply the chat catalog from the last `SessionState` in an `autorun` on capability change. Do **not** fix this by firing `_onDidChangeSessions` — the active-session context autorun tracks the session's own observables, not the provider's session-list event. + +- **A managed/default editor tab must be re-ensured every sync, not opened once**: the per-session editor working-set restore (`baseSessionLayoutController` [B2] `_applyWorkingSet`) runs on session activation and is **not** docked-gated, so it reinstates the session's *saved* editor set and will close any tab not in it (e.g. a set persisted before a new tab existed). A controller that opens a default tab **once** (guarded by a `Set` of initialized sessions) therefore loses it permanently after the restore. Ensure managed tabs (the pinned Changes tab, the default File tab) **idempotently on every sync** (`group.editors.some(e => e instanceof X)` → open if absent), exactly like the Changes tab — never with an open-once `Set`. Also: `IEditorService.openEditor(input, group)` on a typed `EditorInput` binds `group` to the `options` param (overload is `(editor, options?, group?)`); pass `openEditor(input, undefined, group)`. + +- **A "keep the editor closed" rule must not react to the editor's visibility transition**: the single-pane new-session rule (R1, `_registerSinglePaneNewSessionRules`) must drive its hide off the **session + active editor**, never off `onDidChangePartVisibility`/an `editorVisibleObs`. `onWillOpenEditor` reveals the editor *before* the opened file becomes the active editor, and toggling the detail panel off reveals the empty editor via `setAuxiliaryBarHidden` — both fire the visibility event synchronously with the active editor still stale (managed empty tab). A rule that re-runs on that transition re-hides the editor the user just asked to show (file never appears; the whole side pane vanishes on detail-toggle). Hide only when the active editor is non-real content, read the current visibility **untracked** when deciding to hide, and block spurious width-based reveals at the source with `setSuppressDockedEditorRevealSync(true)` rather than a visibility backstop. **Refinement:** dropping the visibility trigger entirely loses the backstop for *automatic* post-activation reveals (working-set restore, an editor left visible across a session switch), so the editor can appear in a fresh new-session view. Keep the visibility trigger but gate the hide on an explicit-reveal flag: the workbench records `_editorRevealedExplicitly` (set true only on `onWillOpenEditor` and the detail-toggle reveal, cleared on any hide and on the suppress false->true transition so a stale cross-session flag can't leak) and exposes `isEditorRevealedExplicitly()`; R1 re-hides only when the editor is visible **and** the reveal was not explicit. + +- **When the docked editor is hidden while the detail stays visible, clear the sidebar-grow snapshots**: hiding the editor resizes the docked node down to the detail width (`_dockedAuxiliaryBarWidth`). Any `_editorSizeGrownForSidebarHide`/`_detailWidthGrownForSidebarHide` snapshot captured earlier (while the editor was visible and the sessions list was hidden) is now stale — restoring it when the sessions list is later shown re-inflates the node so the detail fills the whole side pane instead of the chat reclaiming the freed editor width. `setEditorHidden(true)` must drop both snapshots in the detail-still-visible branch. + +- **Overriding a workbench toolbar hover needs matching specificity**: the core rule `.monaco-workbench .monaco-action-bar:not(.vertical) .action-label:not(.disabled):hover` (and the `:hover` outline rule) sets the toolbar hover background/outline at ~6-7 class specificity. An action-item label that needs to override that hover (either to suppress it for a non-interactive label, or to re-skin it) must use an equal-or-higher-specificity selector (prefix `.monaco-workbench ... .monaco-action-bar:not(.vertical) .action-item. .action-label:hover`), not a short `. .action-label:hover` that loses the cascade. (The single-pane diff-stats pill was once suppressed this way while static; it is now a clickable action that opens the multi-file diff, so it keeps the standard `--vscode-toolbar-hoverBackground` hover.) + +- **A managed tab must never reveal the docked editor content — but the core workbench must not hardcode which, and the excluded editor's deliberate open must reveal explicitly**: the empty Files placeholder (`EmptyFileEditorInput`) and the Changes multi-diff (`SessionChangesEditorInput`) surface their content in the detail panel, so activating either (e.g. the placeholder activating as a side effect of closing the Changes tab, or clicking the Changes tab) must not reveal the editor area. The core workbench `_handleWillOpenEditor` reveal handler skips revealing for editors matched by a **contrib-provided predicate** (`IAgentWorkbenchLayoutService.setEditorRevealOnOpenExclusion`), which the single-pane layout controller sets to its `_isManagedEditor` check — so the editor-type policy lives in contrib (which can `instanceof` the real inputs), not as hardcoded type-id literals in `src/vs/sessions/browser/*` (core). **Caveat:** because tab activation and opening a file diff both fire `onWillOpenEditor` for the *same* Changes editor (and the event carries no options to tell them apart), excluding it from the auto-reveal also blocks the deliberate file-open reveal — so the Changes view's `_openMultiFileDiffEditor` (clicking a file) must **explicitly** `setPartHidden(false, EDITOR_PART)` before opening (revealing before the open also avoids the multi-diff hanging while laid out in a hidden 0-size editor part). Do NOT use a broad "editor already in group -> skip" rule. Keep `_handleWillOpenEditor` a named method so it is unit-testable via `Reflect.get(Workbench.prototype, ...)`. + +- **Gate single-pane editor-title actions on `MainEditorAreaVisibleContext`**: every single-pane (`config.`-gated) editor-title menu item (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal) must include `MainEditorAreaVisibleContext` in its `when` so the whole action set disappears when the editor content is closed — the docked tab bar stays but its right-hand actions do not. The docked reveal-sync (`_syncDockedEditorVisibility`) must be *symmetric*: it reveals when the node widens past the detail width and **hides** (sets `partVisibility.editor=false`, flips `MainEditorAreaVisibleContext`) when a sash drag squeezes the editor content back down to the detail width — same guards (`_syncingDockedEditorVisibility`, `_suppressDockedEditorRevealSync`, `_dockDetailPanel`, and only while the detail is visible). + +- **The managed Files placeholder tab is conditional, not always-on**: `ChangesTabController` shows the empty Files tab only when the editor area is closed OR no real (non-managed) editor is open; once a real file/diff is open in a visible editor area it removes the placeholder (and re-adds it when the area closes). Drive this off an `autorun` that also reads the editor-area visibility (`observableFromEvent(onDidChangePartVisibility, () => isVisible(EDITOR_PART, mainWindow))`) and an editor-change signal (`observableSignalFromEvent(this, Event.any(onDidActiveEditorChange, onDidEditorsChange))`), and keep the ensure/remove idempotent so it settles instead of looping. Close/open the placeholder under `suppressEditorPartAutoVisibility()`. + +- **A per-session aux-bar (detail) capture must be skipped during a session-switch restore**: the D2 `onDidChangePartVisibility` listener that records a created session's detail visibility must bail while `_isRestoringSessionLayout` is true. During restore an external component (e.g. `DetailPanelController`) can transiently reveal the aux bar for the incoming active editor; capturing that overwrites the session's saved detail-hidden state, so switching back shows the detail even though the user had closed it. Keep the aux-bar sync work synchronous (fire-and-forget the view-open calls) so the restore epoch ends promptly and legitimate post-restore user toggles are still captured. + +- **The detail-panel forced reveal must be gated on the editor content being visible**: `DetailPanelController._syncForcedTarget` reveals the docked detail (aux bar) when a Changes/File editor becomes active while the detail is hidden. That reveal must additionally require `isVisible(EDITOR_PART, mainWindow)` — otherwise, on a window reload where the user had closed the **whole** side pane (editor + detail both hidden, persisted), the restored managed tab becoming active re-reveals the detail and the closed state is lost. Reveal the detail only to accompany a *visible* editor; when the whole pane is intentionally closed, leave it closed. + +- **Auto-managed tabs must still be user-closable — remember user dismissals instead of blindly re-ensuring**: `ChangesTabController` re-ensures the managed Changes/Files tabs on many signals (session state, editor visibility, editor changes). Without care this re-creates a tab the instant the user closes it, so the tab feels un-closable (the managed tabs are non-preview `pinEditor`, NOT sticky — they *do* have close buttons; the blocker is the re-ensure, not a missing button). Track user-initiated closes in a `_dismissedManagedTabs` set (via `onDidCloseEditor`, ignoring the controller's own closes tracked in an `_internallyClosingEditors` set) and skip ensuring a dismissed kind. Clear the set only on a **session change** or when the **side pane is reopened from fully closed** (edge-detected via a stored `_sidePaneWasVisible`), so closes stick within the session while reopening/switching re-populates (Scenario B). + +- **D10 (empty aux-bar cleanup) must gate on quick-chat, not the racy container-active check, or it flickers the side pane closed on reload**: the Agents-window Changes/Files aux-bar views gate on `SessionHasWorkspaceContext` + `WorkspaceFolderCountContext`, which are set **asynchronously** (via the `setActiveSessionContextKeys` autorun reading the session's async `workspace`) after a session activates/reloads. So right after D3b/DetailPanelController/a manual toggle reveals the aux bar, `isViewContainerActive(Files/Changes)` is transiently `false` (context keys not settled) even for a real workspace session. The D10 reconcile (`_syncAuxiliaryBarPartVisibility`, which runs synchronously on the `onDidChangePartVisibility(visible)` signal and only ever hides) then closes the just-opened side pane, and since it never re-reveals, it stays closed — the reload "side pane opens then closes" flicker, "Files not shown when opening the side pane", and "new-session side-pane state not remembered". Fix: D10 hides only when the aux is **genuinely** empty for the active session's lifetime — no active session, or a **workspace-less quick chat** (`activeSession.isQuickChat?.get() === true`, its Changes+Files permanently gated off) — never for a workspace-backed session whose gating context keys are merely still settling. Do NOT use the transient `_hasActiveAuxViewContainers()` result to hide a workspace session's aux. + +- **`DetailPanelController` must not hide the detail on an empty editor group in the new-session view**: `_computeTarget` returns `Hidden` when the main editor part is empty (a created session's all-tabs-closed → whole side pane closed). But the new-session (uncreated) view's editor group is *transiently* empty while its Files tab is (re)ensured, and its Files detail is open by default and owned by the layout controller's D3b. Gating the empty-group `Hidden` on `activeSession.isCreated` avoids a transient hide that the D2 visibility listener would otherwise capture as the new-session preference — making every subsequent `cmd+n` open with the side pane hidden. Combined with the editor-visibility reveal gate, the new-session default stays open while a user's explicit hide is still remembered (D3b `_newSessionViewState`). + ## Validating Changes You **must** run these checks before declaring work complete: @@ -68,3 +109,47 @@ You **must** run these checks before declaring work complete: 1. `npm run typecheck-client` — TypeScript compilation check. **Do not run `tsc` directly.** 2. `npm run valid-layers-check` — **MANDATORY.** Catches layering violations. If this fails, fix the imports before proceeding. 3. `scripts/test.sh --grep ` — unit tests for affected areas + +- **Managed-tab reconciliation must run entirely under `suppressEditorPartAutoVisibility()`**: `ChangesTabController._syncChangesEditor` closes stale managed tabs (e.g. a restored Changes tab whose session's workspace hasn't resolved yet on reload) before ensuring the current ones. If any close (esp. `_closeInactiveChangesEditors`) runs **unsuppressed** and empties the group, the workbench `handleDidCloseEditor` docked branch treats it as "user closed all tabs" and closes the whole side pane — the reload flicker where the side pane appears then vanishes. Wrap the full reconciliation body in one suppression window so transient empty states are never mistaken for a user action; don't rely on per-open suppressions alone. Relatedly, the managed Files placeholder tab must NOT be auto-removed based on editor-area visibility (old Scenario 9 auto-remove) — that removal also emptied the group on reload; only remove it on an explicit user close (tracked via `_dismissedManagedTabs`). + +- **Layout-driven editor closes (working-set apply) must not be mistaken for user closes**: On any single-pane session switch (incl. Cmd+N to a new untitled session and reload), the base controller applies the target session's editor working set — an **empty** working set closes the managed Changes/Files tabs *externally*. Two bugs resulted: (1) the workbench `handleDidCloseEditor` docked branch saw an empty group and closed the whole side pane (reload/Cmd+N flicker: pane/Files-tab appears then vanishes); (2) `ChangesTabController._handleEditorClosed` recorded the external close as a user dismissal, poisoning `_dismissedManagedTabs` so the Files tab toggled on/off on alternate Cmd+N presses. Fix: `_withSessionLayoutRestore` (base controller) holds `suppressEditorPartAutoVisibility()` for the whole (async) restore **only when `isSinglePaneLayoutEnabled`** (OFF layout unchanged), so layout-driven closes never reach `handleDidCloseEditor`; and `_handleEditorClosed` ignores closes while `layoutService.isEditorPartAutoVisibilitySuppressed()` is true, so only a genuine user close dismisses a managed tab. Added `isEditorPartAutoVisibilitySuppressed()` to `IAgentWorkbenchLayoutService` as the shared "this close is layout-driven, not a user action" signal. + +- **DetailPanelController must not force-reveal the detail during a layout-driven restore**: `_syncForcedTarget` reveals the aux-bar detail to accompany the active Changes/Files editor. On a session switch the target session's editor working set is restored, making its Changes/Files editor active — an editor change that is NOT a user open. If the user had hidden the detail for that session, that restore-driven editor change would force-reveal it, losing the per-session detail-hidden state. Gate the reveal (`setPartHidden(false, AUXILIARYBAR)`) on `!isEditorPartAutoVisibilitySuppressed()` (in addition to the existing editor-visible guard): during `_withSessionLayoutRestore` the base controller holds `suppressEditorPartAutoVisibility()` (single-pane), so a restore-driven forced target skips the reveal while a genuine user editor open (unsuppressed) still reveals. Note the existing `[D3c/single-pane]` test passed because it simulated the reveal *synchronously during* apply (so D3 re-hid last); the real bug is the *async* DetailPanelController reveal firing on the editor-change after D3 already hid. + +- **The width-based docked reveal-sync (`_syncEditorVisibility`) must bail while editor-part auto-visibility is suppressed**: `SinglePaneWorkbench._syncEditorVisibility` reveals/hides the docked editor purely from the node width (for user sash drags). A session-switch / reload layout restore holds `suppressEditorPartAutoVisibility` while it applies the working set, which can **widen the docked node** before the controller has set the target editor-part visibility. Because the width-sync ran regardless of suppression, restoring a **Detail-only** session (aux open, editor closed) flickered the editor open on switch (the working-set apply widened the node → reveal → the controller then re-hid it) and could persist it open on reload. Gate `_syncEditorVisibility` on `!this._isEditorPartAutoVisibilitySuppressed` (alongside the existing `_syncingEditorVisibility` reentrancy guard) so only a real user sash drag (unsuppressed) drives width-based visibility. Relatedly, `baseSessionLayoutController._applyWorkingSet`'s `isInitialRestore` branch must, for single-pane, apply `_shouldHideEditorPartOnApply(editorPartHidden)` after the working-set apply (a no-op for the classic layout) — otherwise a Detail-only session's persisted editor-hidden state is not re-applied on reload and the editor is left visible. + +- **Single-pane detail (aux bar) ownership is split cleanly in two — visibility vs content — never three overlapping aux strategies**: in single-pane the auxiliary bar *is* the detail panel, so exactly two strategies touch it, with non-overlapping responsibilities. `SinglePaneDetailVisibilityStrategy` owns **only** per-session *shown/hidden* memory: it captures the user's choice ([D1]/[D2]), restores it on switch ([D3]) by revealing/hiding the aux **part** (`setPartHidden` / `hideAuxiliaryBarForRestore`), and handles the submit transition ([D4]). `SinglePaneDetailPanelStrategy` owns **everything about content**: which container (Changes/Files, mapped from the active editor), the transient browser-tab hide, editor-maximize → Changes, and the "nothing to show" hide (quick chat / no workspace / empty group → `Hidden`). Do NOT reintroduce a separate `EmptyAuxCleanup`/D10 strategy or desktop's saved-container machinery (`auxiliaryBarActiveViewContainerId` restore, `_openDefaultAuxiliaryBarContainer`, `_restoreSavedAuxiliaryBarContainerOnReveal`, pinned-container checks) into the visibility strategy — the container always follows the active editor, so a stored container preference is redundant and races the detail-panel mapping. Because the visibility strategy reveals the part and the detail-panel strategy fills it, the detail-panel strategy registers **immediately** in `_registerViewStateManagement` (not deferred to `Restored` like the managed tabs), so a reveal and its container open happen in the same turn. + +- **Single-pane is a *sibling* of the desktop controller and composes strategy objects — it does not extend `LayoutController`**: `SinglePaneLayoutController` (file `contrib/layout/browser/singlePaneLayoutController.ts`) extends `BaseLayoutController` directly, NOT the classic desktop `LayoutController`, so the desktop controller can be deprecated/deleted without touching single-pane. Its behaviour is composed from strategy objects under `contrib/layout/browser/singlePane/` (each a `Disposable`, created via `createInstance` with a leading `ISinglePaneLayoutContext` arg): `SinglePaneDetailVisibilityStrategy` (per-session detail shown/hidden: D1/D2/D3/D4) and `SinglePaneDetailPanelStrategy` (container + maximize + browser-hide + nothing-to-show hide) — the detail split above; `SinglePaneManagedTabsStrategy` + `SinglePaneEditorAreaCollapseStrategy` (share a `SinglePaneDockedTabsCoordinator` holding the tab `Sequencer`, `internallyClosingEditors`, `collapsedEditors`, and the `isManagedEditor`/`getChangesEditorResource` helpers); `SinglePaneQuickChatEditorHideStrategy`; `SinglePaneResponsiveSidebarStrategy` (owns the Toggle Details action + sidebar auto-hide); `SinglePaneNewSessionRulesStrategy` (R1). Shared controller state (`isRestoringSessionLayout`, `withSessionLayoutRestore`, `togglingSidePane`, the obs, `viewStateBySession`, `hidingAuxiliaryBarForRestore`/`hideAuxiliaryBarForRestore`) is exposed to strategies through `ISinglePaneLayoutContext` (built lazily in the controller because base's constructor calls the `_registerViewStateManagement`/`_registerAuxiliaryControllers` hooks *before* subclass field initializers run). The detail-visibility/detail-panel/responsive/R1 strategies register in `_registerViewStateManagement`; the managed-tab/collapse/quick-chat strategies register in `_registerAuxiliaryControllers` deferred to `LifecyclePhase.Restored`. **Fresh storage**: single-pane persists to `sessions.singlePane.layoutState` + `sessions.singlePane.newSessionViewState` (base `_layoutStateStorageKey`/`_legacyWorkingSetsStorageKey` are overridable; single-pane skips legacy migration), so it never shares state with the classic desktop controller — the test harness seeds both keys. + +- **Single-pane detail/tab behaviour lives ON the layout controller (or its strategies), not in separate contribution controllers or a shared service**: `SinglePaneLayoutController` owns both the managed docked tabs (pinned Changes multi-diff + empty Files placeholder) and the detail-panel mapping (active editor → Changes/Files container, aux-bar reveal/hide). They were previously `ChangesTabController`/`DetailPanelController` (registered by a `SinglePaneModeController` contribution) coordinating via global `IAgentWorkbenchLayoutService` flags, then briefly via an `ISessionLayoutCoordinatorService`. Both were removed: "is a session-switch restore in progress?" is just the base protected getter `this._isRestoringSessionLayout` (set by `_withSessionLayoutRestore`) — surfaced to the strategies via `ISinglePaneLayoutContext.isRestoringSessionLayout` — so a restore-driven editor change never force-reveals the detail or dismisses a managed tab. The base controller has `IChangesViewService` + `IContextKeyService` deps and a protected `_editorGroupsService` (a subclass can't add DI ctor params without redeclaring all base params, so shared services live on the base). Tests: the layout harness got `activeGroupEditors`/`closeSuppressionFlags`, a real `mainPart.activeGroup`, an `activateAux` opt-in that resolves the lifecycle, and a `TestSinglePaneController.runWithRestore(...)` seam to hold `_isRestoringSessionLayout` across an async editor change; `changesTabController.test.ts` was deleted and its scenarios moved into `desktopSessionLayoutController.test.ts`. + +- **Single-pane created-session default is Editor-only (Changes editor, detail closed) — the detail is not force-opened by editor activation**: a Changes/file editor becoming active must NOT auto-reveal the docked detail (aux bar). `SinglePaneLayoutController._syncForcedDetailTarget` reveals a hidden detail ONLY when it was transiently hidden by a browser tab (`_hiddenByBrowser`), never when it is hidden by the per-session default or an explicit user hide; when the detail is visible it still switches the container (Changes/Files) to match the active editor. The reopen default is layout-aware via the base `_defaultReopenSidePaneParts()` hook (base returns both parts; single-pane returns `{editor:true, auxiliaryBar:false}` for a created session and `{editor:false, auxiliaryBar:true}` for a new-session view). The detail is opened only via Toggle Details or restored per-session state. When changing this, update the `[single-pane] ... file tab activation` / `[per-session detail]` / reopen-default tests together — they encode the reveal semantics. +- **Editor-title actions that only make sense with a restorable editor must also gate on `EditorMaximizedContext.negate()`**: the single-pane "Hide Editor" action is meaningless while the editor area is maximized, so its `when` includes `EditorMaximizedContext.negate()` (in addition to `MainEditorAreaVisibleContext` + `SinglePaneDetailChangesOrFilesActiveContext`). + +- **R1 (new-session editor hide) must be transition-triggered, not level-triggered on the active editor**: hiding the editor in the new-session view must fire only when the editor **just became visible** (visibility false→true) or when the view was **just entered** with the editor already visible (inherited-visible editor) — never merely because the active editor changed to a managed placeholder while the editor is already visible. A level-triggered rule ("hide whenever active editor is non-real content and editor visible") wrongly hides the editor when the user switches to the Files tab with a file already open (the reveal-sync suppression re-arm clears `isEditorRevealedExplicitly`, so the level rule then hides). Track `previousEditorVisible` + `previousInNewSessionView` in the autorun and hide only on `(editorJustRevealed || justEnteredNewSessionView) && !isEditorRevealedExplicitly()`. The two workbench methods `setSuppressDockedEditorRevealSync` (blocks width-based reveals at the source, avoiding flicker) and `isEditorRevealedExplicitly` (distinguishes an explicit toggle-details-off/file-open reveal that must stick) are still required by R1 — they are independent of the ChangesTab/DetailPanel controller merge. + +- **Single-pane created sessions need the docked editor part revealed on switch — the `isModal` gate in `_applyWorkingSet` skips it**: `baseSessionLayoutController._applyWorkingSet` only reveals the editor part when `!isModal` (i.e. `workbench.editor.useModal !== 'all'`), because in the classic layout editors open in a modal part. But in single-pane the docked editor lives in the **grid** even when `useModal` is `'all'` (the default), so that gate wrongly skips the reveal and a created session's side pane looks fully closed (worse once the Changes editor no longer force-reveals the detail). Fix: compute `revealEditorPart = !editorPartHidden && !isInitialRestore && (isSinglePaneLayoutEnabled ? isCreatedSession : !isModal)` and also reveal for the `'empty'` working-set case in single-pane (a first-visit created session has no saved editors but still shows its managed Changes editor). This restores the Editor-only default while respecting the per-session `editorPartHidden` (Detail-only / side-pane-closed) state and excluding new-session views (R1 keeps their editor closed). Note the layout test harness leaves `isSinglePaneLayoutEnabled` falsy by default, so base single-pane branches are inert in tests unless a test opts in via the `singlePaneLayoutEnabled` create option. + +- **A draft replaced by a committed session must inherit the draft's side-pane layout before `_applyWorkingSet` runs**: some providers commit a new-session draft by firing `onDidReplaceSession` with a new session resource, not by flipping `isCreated` on the same resource. Without transferring the active draft's `_editorPartHiddenBySession` and aux-bar state, the committed resource has no saved layout, so the delayed B2 working-set apply treats it as a first-visit created session and reveals the editor (Editor-only default) even though the user submitted from the new-session Detail-only view. Handle the replacement event as D4 submit: copy the active draft's editor-hidden state to the committed resource, record Changes as the committed aux container, and open Changes only if the draft detail was visible; switching to an unrelated existing created session still uses the Editor-only default. + +- **Single-pane D3c: a created session with NO saved detail state must be left in its current on-screen state — never force-hidden**: the detail (aux-bar) restore for a created single-pane session (`SinglePaneDetailVisibilityStrategy._syncDetailVisibility` D3c) must **only** act when `viewStateBySession` has a saved entry — hide when it says hidden, reveal when it says visible. When there is **no** saved state (`savedState === undefined`), return without touching the aux bar. Force-hiding on the no-state path re-closes the detail the user had open in the new-session view on submit: the committed session's resource can change again *after* the initial draft→committed transition, so a later restore run lands in D3c with no saved state and `previousIsCreated` already `true` (the intrinsic `!previousIsCreated && isCreated` submit detection ([D4]) no longer matches), and would re-hide. Leaving the current state also covers a first-time-seen created session gracefully; the detail-panel strategy keeps the container in sync, and the visibility is captured on the next switch-away or user toggle. (The intrinsic [D4] submit routing to `_onNewSessionSubmitted` is still kept for the *clean* first transition — it records the state and opens Changes — but D3c-leave-current is the backstop for every follow-up run.) + +- **A replace-based submit must be detected *intrinsically* in the aux/detail restore autorun (`!previousIsCreated && isCreated`), not via `_onSessionReplaced`**: the same ordering trap as the editor reveal, but for the detail (aux-bar) visibility. `sessionsService` listens to `onDidReplaceSession` *first* (it's a core service) and its handler calls `updateSession` → sets `activeSession` in a transaction → the single-pane `SinglePaneDetailVisibilityStrategy` D3 restore autorun fires **synchronously inside that handler**. The layout controller's `_onSessionReplaced` (registered later, at `BlockRestore`) runs *after* — so any aux-state transfer it does is too late: the autorun has already run D3c. The classic same-resource `isSubmit` guard (`!isSessionSwitch && !previousIsCreated && isCreated`) misses this because the agent-host/Copilot provider commits by *replacing* the draft with a **new resource** (`isSessionSwitch` is true). Fix: relax `isSubmit` to `previousSessionResource && !previousIsCreated && isCreated && !viewStateBySession.has(activeSessionResource)` — detect the submit purely from the transition, independent of `_onSessionReplaced` ordering. The `!has(state)` guard keeps a genuine navigation from a draft to an *existing* created session on the normal D3 restore path. `_onSessionReplaced` then only needs to cover the **background** submit (a session committed while a *different* session is active, so the autorun never fires for it). Because the committed resource can still change again after the first transition, this intrinsic detection alone isn't enough — pair it with the D3c-leave-current rule above. General rule: for any "on submit, preserve/transfer layout" logic, detect the submit from the reactive transition the consumer already observes — never from a flag/transfer set by a separately-registered `onDidReplaceSession` listener. + +- **`onDidReplaceSession` always means submit — never re-check `from.status === Untitled`, and never try to preserve visibility via a flag consumed by `runOnChange`**: two related traps when suppressing the docked-editor reveal on new-session submit. (1) By the time `onDidReplaceSession` fires, the draft has *already* transitioned `Untitled→Completed`, so a `_isNewSessionReplacement(from,to)` guard checking `from.status === SessionStatus.Untitled` is **always false** and silently skips the whole editor-hidden/aux transfer. The event is documented to fire only when an untitled draft is atomically replaced by its committed session, so treat *every* `onDidReplaceSession` as a submit — no status guard. (2) The B2 working-set `runOnChange` (on the workspace-gated `activeSessionForWorkingSet` derive) fires **before** the synchronous `onDidReplaceSession` handler, so a boolean flag set in `_onSessionReplaced` and read synchronously in `runOnChange` is captured stale (`false`) and cannot suppress the reveal. The correct, ordering-robust mechanism is to have `_onSessionReplaced` write the draft's live editor-part visibility into `_editorPartHiddenBySession[to]` **synchronously**; because `_applyWorkingSet` reads that map inside its `Sequencer.queue` async microtask body (which runs *after* the sync replace handler), the reveal decision (`_shouldRevealEditorPartOnApply`/`_shouldRevealEditorPartForEmptyWorkingSet`) sees `editorPartHidden=true` and skips. Do NOT add a `preserveEditorPartVisibility` apply option keyed off event ordering — it's impossible to set in time. + +- **R1 can drop `setSuppressDockedEditorRevealSync` — always hide on new-session-view entry instead**: the width-based reveal-sync suppression (`setSuppressDockedEditorRevealSync`/`_suppressDockedEditorRevealSync`) was removed. It did two jobs: (1) block a momentary width-reveal of the editor in the new-session view, and (2) clear `_editorRevealedExplicitly` on entering the view so R1 re-hides an inherited-explicit editor across a session switch (the working-set apply runs under `suppressEditorPartAutoVisibility`, so `handleDidCloseEditor` doesn't clear the flag naturally). Job (1) is now handled by R1 re-hiding any non-explicit reveal (a sash-drag reveal flickers then re-hides — acceptable). Job (2) is handled by making R1's hide condition `justEnteredNewSessionView || (editorJustRevealed && !isEditorRevealedExplicitly())` — i.e. **entering the new-session view always resets to editor-closed** (a stale cross-session explicit flag can't keep the editor open), while the explicit flag is only honored for *in-session* reveals (toggle-details-off revealing the empty editor). `isEditorRevealedExplicitly` is still needed for that in-session case. + +- **Quick chats have no side pane — don't auto-reveal the editor part, and hide it when switching in from a session that had it open**: in single-pane, `SinglePaneLayoutController._shouldRevealEditorPartOnApply` must exclude quick chats (`!editorPartHidden && isCreatedSession && !isQuickChat`); a created quick chat would otherwise reveal the docked editor part on switch (bug: "side pane opened automatically for quick chat"). Excluding the *reveal* is not enough — switching in from a workspace session leaves the editor part visible (the working-set apply is suppressed and never hides it), so a dedicated `_registerQuickChatEditorHide()` autorun hides the editor part while a quick chat's editor group is empty (gated on `_isMainPartEmpty()` so a real editor, e.g. the integrated browser, opened in a quick chat is never hidden). The aux bar is already handled by D10 + the detail-panel `Hidden` target. + +- **An auto-collapsed sessions list must be restored once the side pane is fully hidden**: the single-pane responsive rule auto-collapses the sessions list to free width for a *visible* side pane (Toggle Details, opening a file). It must also restore an auto-hidden list when the side pane becomes fully hidden (both editor and aux bar closed) — e.g. switching to a quick chat (no side pane) — otherwise the list is left collapsed with nothing to make room for (bug: "sessions list closed even though the side pane is hidden"). Implement as an autorun in `_registerResponsiveSidebar` on an `observableFromEvent(onDidChangePartVisibility, () => editorVisible || auxVisible)` (the value-dedup is essential: hiding the *sidebar* itself doesn't change the computed side-pane visibility, so the pre-reveal auto-hide from opening an editor is never undone). Restore only when `_sidebarAutoHidden` is true, so a list the user closed **manually** stays closed. + +- **Single-pane per-session editor-part visibility must be restored *both* ways — `_applyWorkingSet` only ever revealed it**: `baseSessionLayoutController._applyWorkingSet` revealed the editor part when a session wanted it visible but never *hid* it, so returning to a session whose docked editor was closed (Detail-only or whole side pane closed) left the editor visible/inherited from the previously-active session (bug: "side pane opened when returning to a session where it was closed"). The per-session `_editorPartHiddenBySession` state was only consumed to *suppress* the reveal (`!editorPartHidden`), never to actively hide. Fix: add a symmetric Template-Method hook `_shouldHideEditorPartOnApply(editorPartHidden)` (base returns `false` — classic layout doesn't treat editor-part visibility as per-session; single-pane returns `editorPartHidden && isCreated && !isQuickChat`) and, in both the empty and non-empty `_applyWorkingSet` branches, hide the editor part (mutually exclusive with revealing, skipped on `isInitialRestore` which preserves the workbench-restored visibility). The hide runs inside `_withSessionLayoutRestore`'s `suppressEditorPartAutoVisibility` window so it is never mistaken for a user close. Note the aux bar was already restored both ways by the inherited D3 `_syncAuxiliaryBarVisibility`; only the editor part lacked the hide. +- **Explicit managed-editor opens must reveal outside the auto-reveal path — and mark the reveal *explicit***: managed Changes/Files tabs are excluded from `_handleWillOpenEditor` so tab activation and layout-driven restores do not reveal the docked editor. A deliberate user gesture that should show managed editor content (session-header Changes pill `ViewAllChangesAction`, opening a file diff in `_openMultiFileDiffEditor`) must reveal the editor part before opening the managed editor via `IAgentWorkbenchLayoutService.revealEditorPartExplicitly()` — **not** the generic `setPartHidden(false, EDITOR_PART)`. The generic call routes to `setEditorHidden(hidden, explicit=false)`, leaving `_editorRevealedExplicitly = false`, so R1 / the working-set apply (`_shouldHideEditorPartOnApply`) can re-hide it (especially across a session-switch race). `revealEditorPartExplicitly()` sets the explicit flag (and re-asserts it even when already visible, since `setEditorHidden` early-returns when the part is already visible). Do not weaken the managed-editor exclusion or add timing delays. +- **A `MutableDisposable`-backed content slot must not `clearNode` its shared container on cleanup**: `EditorGroupView.setHeaderContent` appends a new content node into the shared `headerContainer`, then assigns the new store to `_headerContent` (a `MutableDisposable`) — which **synchronously disposes the previous store**. If that store's cleanup calls `clearNode(headerContainer)`, it wipes the freshly-appended new content (blank header, height stuck at 0, orphaned `ResizeObserver`). Fix: clear the previous content **before** appending the new one (`this._headerContent.clear()` at the top), and have each store's cleanup remove only **its own** node (`content.remove()`), never the shared container. This bug surfaces on consecutive header→header renders (e.g. `Changes(sessionA)` → `Changes(sessionB)`). + +- **Per-session editor-part (side-pane) hidden state must be captured *eagerly* on the visibility change, not lazily re-read at switch-away**: `baseSessionLayoutController._saveWorkingSet` used to record `_editorPartHiddenBySession[prev] = !isVisible(EDITOR_PART)` at the moment it saved the outgoing session. That races: the working-set derive (`activeSessionForWorkingSet`) lags the raw `activeSession` (it gates on workspace-folder readiness), so other autoruns driven by the raw active session (managed-tab open, D3 aux sync) have already revealed the editor for the *incoming* session by the time `_saveWorkingSet(prev)` runs — so the previous session gets recorded as `editorPartHidden=false` and its closed side pane reopens on return (symptom: only the editor content re-appears, details stay closed, and no `setEditorHidden` fires on the switch because nothing on the switch path toggles it). Fix: capture it in a `[B2]` `onDidChangePartVisibility(EDITOR_PART)` listener (mirroring the existing `[B1]` panel-visibility capture) guarded by `!multipleSessionsVisibleObs && !_isRestoringSessionLayout`, so the value is written the instant the user closes/opens the side pane and layout-driven restore changes are ignored. Remove the lazy read from `_saveWorkingSet` entirely (keeping it would let the racy switch-time value overwrite the good eager one). The unit harness can't reproduce the derive-lag, so add a focused test that fires the EDITOR_PART event to assert eager capture, plus one that fires a reveal inside `_withSessionLayoutRestore` to assert the captured closed state is preserved. +- **Single-pane detail sync must re-read aux-bar visibility when queued work runs**: the detail-panel autorun queues container opens through a sequencer, so a task can be computed while the previous session's detail is visible and run after D3 has restored the incoming session's detail to hidden. Do not trust an `auxBarVisible` value captured before the queue boundary; read `isVisible(AUXILIARYBAR_PART)` inside the queued sync, otherwise `openViewContainer` can re-reveal the detail and overwrite the incoming session's saved hidden state. +- **The managed Changes tab must open non-stealing so the working-set-restored active editor is preserved**: on a single-pane session switch, `baseSessionLayoutController._applyWorkingSet` restores the session's editor working set including *which* editor was active (e.g. `package.json`). `SinglePaneManagedTabsStrategy` then idempotently re-ensures the pinned Changes tab — but if `changesEditorOptions` opens it as active (no `inactive` / `activation`), it steals active state from the just-restored editor, so the wrong tab is active after the switch. Give `changesEditorOptions` `inactive: true` + `activation: EditorActivation.PRESERVE` (matching `fileTabOptions`); the workbench still makes it active when the group is empty (`active: this.count === 0 || !options?.inactive`, `editorGroupView.doOpenEditor`), so the first-visit created-session default (Changes active) is preserved while a restored session keeps its own active editor. +- **Save the outgoing session's working set eagerly on the *raw* active session change, not on the workspace-gated `activeSessionForWorkingSet` derive**: the derive (`baseSessionLayoutController`) holds back while the incoming session's workspace folders resolve, and other autoruns driven by the *raw* `ISessionsService.activeSession` (e.g. the single-pane managed-tabs sync) **async-close the outgoing session's docked editors** (`_closeInactiveChangesEditors`) during that lag. If the working-set save is on the lagged derive it runs *after* those closes, so the outgoing session's Changes tab (or whichever editor was active) is already gone and its active state is lost — on return the working set restores the wrong active editor (symptom: switching back to a session whose Changes tab was active shows a different tab active). Fix: a dedicated `[B2] runOnChange(activeSession, …)` saves the previous session's working set **synchronously** (guarded by resource-inequality, `!Untitled`, `!_isRestoringSessionLayout`) — this runs before the managed-tab sequencer microtask closes anything — and the save is removed from the gated apply `runOnChange` (which now only applies). Save doesn't need workspace readiness; only apply does. Pairs with making the managed Changes tab open non-stealing (`inactive: true` + `EditorActivation.PRESERVE`) so the restored active editor is preserved. The unit harness can't reproduce the derive-lag directly, so assert the decoupling: switching to a session whose workspace isn't in the folders (gated apply holds back) still records a `saveWorkingSet` for the outgoing session. diff --git a/.github/skills/ux-css-layout/SKILL.md b/.github/skills/ux-css-layout/SKILL.md index 537b9d74ccdafd..0e5776524535e3 100644 --- a/.github/skills/ux-css-layout/SKILL.md +++ b/.github/skills/ux-css-layout/SKILL.md @@ -297,11 +297,28 @@ scale value, **ties round up** (`5px → 6px`, `3px → 4px`, `1px → 2px`, ### Font size & weight -Generic UI chrome (fixed px): `13 → --vscode-bodyFontSize` (base), -`12 → --vscode-bodyFontSize-small`, `11 → --vscode-bodyFontSize-xSmall`. +Generic UI ramp — pair a **size** token with a **weight** token (mirrors the +agents ramp; "Strong" = matching size token + `semiBold`, never a separate size): -Agents window (`src/vs/sessions/**`) ramp — pair a **size** token with a -**weight** token: +| px | Size var | Weight | +|----|----------|--------| +| 26 | `--vscode-fontSize-heading1` | semiBold | +| 18 | `--vscode-fontSize-heading2` | semiBold | +| 13 | `--vscode-fontSize-heading3` | semiBold | +| 13 | `--vscode-fontSize-body1` | regular | +| 11 | `--vscode-fontSize-body2` | regular | +| 12 | `--vscode-fontSize-label1` | regular | +| 11 | `--vscode-fontSize-label2` | regular | +| 10 | `--vscode-fontSize-label3` | regular | + +Generic weights: `--vscode-fontWeight-regular` (400), +`--vscode-fontWeight-semiBold` (600). + +**Deprecated** — `--vscode-bodyFontSize` (13) → `--vscode-fontSize-body1`, +`--vscode-bodyFontSize-small` (12) → `--vscode-fontSize-label1`, +`--vscode-bodyFontSize-xSmall` (11) → `--vscode-fontSize-body2`. + +Agents window (`src/vs/sessions/**`) ramp — identical values, `agents-`-prefixed: | px | Size var | Weight | |----|----------|--------| @@ -314,14 +331,15 @@ Agents window (`src/vs/sessions/**`) ramp — pair a **size** token with a | 11 | `--vscode-agents-fontSize-label2` | regular | | 10 | `--vscode-agents-fontSize-label3` | regular | -The agents weight ramp is **two weights only**: -`--vscode-agents-fontWeight-regular` (400) and -`--vscode-agents-fontWeight-semiBold` (600). +Both weight ramps are **two weights only**: `regular` (400) and +`semiBold` (600) — generic `--vscode-fontWeight-*`, agents +`--vscode-agents-fontWeight-*`. - **No medium (500).** `font-weight: 500` is off the ramp — snap to `semiBold`. Likewise `700`/`bold` → round to the nearer of 400/600. - **"Strong" is not a separate size.** "Body 1 Strong" = the matching - `--vscode-agents-fontSize-*` size token + `semiBold`. Never add a strong *size*. + `--vscode-fontSize-*` (or `--vscode-agents-fontSize-*`) size token + `semiBold`. + Never add a strong *size*. - `normal` ≡ 400 → `regular`. Leave `inherit`, `lighter`, `bolder`, `var()`/`calc()` untouched. diff --git a/.github/skills/ux-theming/SKILL.md b/.github/skills/ux-theming/SKILL.md index f674aafb13da6c..274f478966b4a4 100644 --- a/.github/skills/ux-theming/SKILL.md +++ b/.github/skills/ux-theming/SKILL.md @@ -134,8 +134,8 @@ Reviewers will always flag hardcoded colors, shadows, sizes that should use them | `border: 1px solid …` (width) | `var(--vscode-strokeThickness)` for the 1px width | | `border-radius: 6px` | `var(--vscode-cornerRadius-medium)` (radius ramp) | | `padding: 8px 12px` (off-scale) | spacing ramp (`--vscode-spacing-size*`) | -| `font-size: 14px` (arbitrary) | size ramp (`--vscode-bodyFontSize`, agents `--vscode-agents-fontSize-*`) | -| `font-weight: 500` | agents `--vscode-agents-fontWeight-semiBold` (no 500) | +| `font-size: 14px` (arbitrary) | size ramp (`--vscode-fontSize-*`, agents `--vscode-agents-fontSize-*`) | +| `font-weight: 500` | `--vscode-fontWeight-semiBold` (agents `--vscode-agents-fontWeight-semiBold`; no 500) | | codicon `font-size: 14px` | `--vscode-codiconFontSize` (16) / `-compact` (12) | **Rule:** If a value relates to color, shadow, or border — it must come from a CSS variable or registered color token. The only exception is `0` (zero) values and purely structural measurements like `100%`. diff --git a/.github/workflows/chat-perf.yml b/.github/workflows/chat-perf.yml index 8f67c576e02a1c..8713e9a567ec02 100644 --- a/.github/workflows/chat-perf.yml +++ b/.github/workflows/chat-perf.yml @@ -57,13 +57,19 @@ env: SCENARIOS_INPUT: ${{ inputs.scenarios || '' }} TEST_SETTINGS_INPUT: ${{ inputs.test_settings || '' }} BASELINE_SETTINGS_INPUT: ${{ inputs.baseline_settings || '' }} + # Skip binary downloads during `npm ci` (they hang intermittently on hosted + # runners inside the nested postinstall installs). Electron and Playwright are + # fetched explicitly later via `Download Electron` and `Install Playwright + # Chromium`, which ignore these flags. Mirrors pr-node-modules.yml. + ELECTRON_SKIP_BINARY_DOWNLOAD: 1 + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 jobs: # ── Shared setup: build once, cache everything ────────────────────── setup: name: Build & Cache runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 50 outputs: test_is_version: ${{ steps.resolve.outputs.is_version }} test_build_arg: ${{ steps.resolve.outputs.build_arg }} @@ -111,13 +117,33 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + # Retry with a per-attempt timeout to survive transient npm registry + # hangs/failures on hosted runners (mirrors pr-node-modules.yml). + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install build dependencies - run: npm ci working-directory: build + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done - name: Transpile source run: npm run transpile-client @@ -210,7 +236,16 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -383,7 +418,16 @@ jobs: libasound2t64 libxshmfence1 libgtk-3-0 - name: Install dependencies - run: npm ci + run: | + set -e + for i in {1..5}; do + timeout 900 npm ci && break + if [ $i -eq 5 ]; then + echo "npm ci failed/hung too many times" >&2 + exit 1 + fi + echo "npm ci attempt $i failed or timed out, retrying..." + done env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/component-fixtures.yml b/.github/workflows/component-fixtures.yml index e42f20b623d5e2..3aa4054080c712 100644 --- a/.github/workflows/component-fixtures.yml +++ b/.github/workflows/component-fixtures.yml @@ -71,9 +71,6 @@ jobs: - name: Copy codicons run: cp node_modules/@vscode/codicons/dist/codicon.ttf src/vs/base/browser/ui/codicons/codicon/codicon.ttf - - name: Transpile source - run: npm run transpile-client - - name: Install Playwright Chromium run: npx playwright install chromium diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index bce8dcacaeeb05..96ee679f47a293 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -135,6 +135,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + - name: Verify native optional dependency binaries + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules @@ -190,6 +194,10 @@ jobs: # on macOS. GYP_DEFINES: "kerberos_use_rtld=false" + - name: Verify native optional dependency binaries + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.cache-node-modules.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules @@ -245,6 +253,10 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 GITHUB_TOKEN: ${{ secrets.VSCODE_OSS }} + - name: Verify native optional dependency binaries + if: steps.node-modules-cache.outputs.cache-hit != 'true' + run: node build/azure-pipelines/common/checkNativeOptionalDeps.ts + - name: Save node_modules cache if: steps.node-modules-cache.outputs.cache-hit != 'true' uses: ./.github/actions/save-node-modules diff --git a/.npmrc b/.npmrc index 8c21e58ef14d5f..b6b2ee025a57d2 100644 --- a/.npmrc +++ b/.npmrc @@ -1,6 +1,6 @@ disturl="https://electronjs.org/headers" -target="42.3.0" -ms_build_id="14159160" +target="42.5.0" +ms_build_id="14525058" runtime="electron" ignore-scripts=false build_from_source="true" diff --git a/.nvmrc b/.nvmrc index 5bf4400f22922e..1dd37d53743d1a 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -24.15.0 +24.17.0 diff --git a/.vscode/notebooks/endgame.github-issues b/.vscode/notebooks/endgame.github-issues index 0b26098f8edaf6..47975885d1f014 100644 --- a/.vscode/notebooks/endgame.github-issues +++ b/.vscode/notebooks/endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.126.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" + "value": "$MILESTONE=milestone:\"1.128.0\"\n\n$TPI_CREATION=2026-03-23 // Used to find fixes that need to be verified" }, { "kind": 1, diff --git a/.vscode/notebooks/my-endgame.github-issues b/.vscode/notebooks/my-endgame.github-issues index d7a3f66d69a061..7c918297ac47b3 100644 --- a/.vscode/notebooks/my-endgame.github-issues +++ b/.vscode/notebooks/my-endgame.github-issues @@ -7,7 +7,7 @@ { "kind": 2, "language": "github-issues", - "value": "$MILESTONE=milestone:\"1.126.0\"\n\n$MINE=assignee:@me" + "value": "$MILESTONE=milestone:\"1.128.0\"\n\n$MINE=assignee:@me" }, { "kind": 2, diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 807b327404732e..caec8ac6495c24 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -104,7 +104,8 @@ "beginsPattern": "Starting transpilation\\.\\.\\.", "endsPattern": "Finished transpilation with" } - } + }, + "inAgents": true, }, { "type": "npm", diff --git a/build/.cachesalt b/build/.cachesalt index d382d477ac6c74..c57aa9baf4ff95 100644 --- a/build/.cachesalt +++ b/build/.cachesalt @@ -1 +1 @@ -2026-06-22T15:10:10.546Z +2026-07-07T20:00:56.412Z \ No newline at end of file diff --git a/build/.moduleignore b/build/.moduleignore index 7af8defdfcf175..7c8b0068b62822 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -175,6 +175,9 @@ prebuild-install/**/* **/test/** **/tests/** +# Signing artifacts +**/_manifest/** + **/History.md **/CHANGELOG.md **/README.md diff --git a/build/agent-sdk/agents/claude/package-lock.json b/build/agent-sdk/agents/claude/package-lock.json index 1bf16785e0d2b6..d7d9cfd7b29c61 100644 --- a/build/agent-sdk/agents/claude/package-lock.json +++ b/build/agent-sdk/agents/claude/package-lock.json @@ -6,26 +6,26 @@ "": { "name": "agent-sdk-claude", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz", - "integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -34,9 +34,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz", - "integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -47,9 +47,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz", - "integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -60,9 +60,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz", - "integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -76,9 +76,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz", - "integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -92,9 +92,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz", - "integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -108,9 +108,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz", - "integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -124,9 +124,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz", - "integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -137,9 +137,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz", - "integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], diff --git a/build/agent-sdk/agents/claude/package.json b/build/agent-sdk/agents/claude/package.json index d3ea8dc1acb525..bcdae1739cfbe9 100644 --- a/build/agent-sdk/agents/claude/package.json +++ b/build/agent-sdk/agents/claude/package.json @@ -3,6 +3,6 @@ "private": true, "comment": "Pinned dependency set for the build/agent-sdk claude tarball. The package-lock.json alongside is the source of truth for transitive deps — produce.ts runs `npm ci` against this directory to get byte-deterministic output across pipeline runs.", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187" + "@anthropic-ai/claude-agent-sdk": "0.3.198" } } diff --git a/build/agent-sdk/package.ts b/build/agent-sdk/package.ts index e4c9c8af586b18..28680b2bfe88bb 100644 --- a/build/agent-sdk/package.ts +++ b/build/agent-sdk/package.ts @@ -36,6 +36,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import * as tar from 'tar'; +import { findMissingNativeOptionalDep } from '../azure-pipelines/common/checkNativeOptionalDeps.ts'; import { getAgentDir, getAgentMeta, parseFlags, type Sdk, sha256OfFile } from './common.ts'; const SCRIPT = 'package.ts'; @@ -85,6 +86,20 @@ export async function buildOne(args: IBuildArgs): Promise { npmCi(stagingDir, npmEnv); const nodeModulesDir = path.join(stagingDir, 'node_modules'); + + // The SDK ships its native binary in a per-platform package declared as + // an *optional* dependency (e.g. `@openai/codex-linux-x64`). npm does not + // fail when an optional dependency can't be installed, so a transient + // registry hiccup can leave the base package present but the native + // package missing — which would silently produce a binary-less tarball + // and upload it to the (immutable, content-addressed) CDN path, failing + // only at runtime for end users. Fail loud here instead. + // See https://github.com/microsoft/vscode/pull/323881. + const missingNativeDep = findMissingNativeOptionalDep(nodeModulesDir, packageName, args.sdkTarget); + if (missingNativeDep) { + throw new Error(`[${SCRIPT}] npm ci left ${packageName}@${sdkVersion} without its native package '${missingNativeDep}' for target ${args.sdkTarget} — the optional dependency was silently skipped. Refusing to build a binary-less tarball; re-run to re-fetch it.`); + } + chmodPlatformBinaries(nodeModulesDir, args.sdk); fs.mkdirSync(args.outDir, { recursive: true }); diff --git a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml index 7b560b328677b9..f819c45e99fcf8 100644 --- a/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml +++ b/build/azure-pipelines/alpine/product-build-alpine-node-modules.yml @@ -33,6 +33,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts alpine $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key diff --git a/build/azure-pipelines/common/apply-sdk-canary-override.ts b/build/azure-pipelines/common/apply-sdk-canary-override.ts new file mode 100644 index 00000000000000..7c1523fba01972 --- /dev/null +++ b/build/azure-pipelines/common/apply-sdk-canary-override.ts @@ -0,0 +1,240 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'fs'; +import path from 'path'; +import { execFileSync } from 'child_process'; + +/** + * Stage 3 of the Copilot SDK -> VS Code integration pipeline. + * See microsoft/vscode-engineering specs/sdk-vscode-integration.spec.md. + * + * Overrides the `@github/copilot-sdk` and/or `@github/copilot` dependency in the + * root and `remote` manifests to a canary version published to the private npm + * feed, then refreshes the lockfiles so `npm ci` stays consistent (and the + * node_modules cache key, derived from these manifests + lockfiles, naturally + * misses). + * + * Driven by environment variables so it is a no-op in normal builds: + * VSCODE_SDK_CANARY_VERSION - version to pin `@github/copilot-sdk` to (empty = + * no override / normal build) + * VSCODE_CLI_CANARY_VERSION - version to pin `@github/copilot` to. When empty + * (and an SDK version is set) the CLI version is + * inferred from the SDK's own `@github/copilot` + * dependency so the two stay compatible. When set + * explicitly, it is validated against that same + * dependency range and the build fails fast on a + * confirmed incompatible SDK/CLI pair. + * + * npm registry + auth must already be configured in the ambient environment + * (the orchestrator authenticates to the private feed before invoking this). + */ + +const ROOT = path.join(import.meta.dirname, '../../../'); + +/** + * On Windows `npm` is a `.cmd` shim. Two things matter: + * 1. The explicit `.cmd` suffix — Node won't resolve it via PATHEXT for `execFile`. + * 2. `shell: true` — since Node 20 (CVE-2024-27980) `child_process` refuses to + * spawn a `.cmd`/`.bat` without it. + */ +const IS_WINDOWS = process.platform === 'win32'; +const NPM = IS_WINDOWS ? 'npm.cmd' : 'npm'; + +/** + * Allowlist for npm version / range specifiers before they are interpolated + * into `npm view @` argument strings. These specs come from + * queue-time pipeline parameters and from registry responses, and on Windows + * the npm calls run with `shell: true` — so restrict to the characters that + * appear in valid semver versions, ranges and dist-tags and reject anything a + * shell could otherwise interpret. + */ +const SAFE_SPEC = /^[\w.+~^><=|* -]+$/; + +function assertSafeSpec(label: string, value: string): void { + if (!SAFE_SPEC.test(value)) { + throw new Error(`[canary-override] Refusing unsafe ${label} "${value}": only semver versions, ranges and dist-tags are allowed.`); + } +} + +/** Manifests that declare the Copilot dependencies. */ +const TARGET_DIRS = ['', 'remote']; + +interface Override { + readonly name: string; + readonly version: string; +} + +/** + * Infers the `@github/copilot` version to use from the SDK canary's own + * `@github/copilot` dependency range, resolved to a concrete published version. + * Returns undefined (leaving VS Code's pinned CLI) if the SDK declares no such + * dependency or resolution fails — inference is best-effort, never fatal. + */ +function inferCliVersion(sdkVersion: string): string | undefined { + try { + const depsRaw = execFileSync(NPM, ['view', `@github/copilot-sdk@${sdkVersion}`, 'dependencies', '--json'], { encoding: 'utf8', shell: IS_WINDOWS }); + const deps = JSON.parse(depsRaw || '{}'); + const range = deps['@github/copilot']; + if (!range) { + console.log(`[canary-override] SDK ${sdkVersion} declares no @github/copilot dependency — leaving VS Code's pinned CLI.`); + return undefined; + } + assertSafeSpec('inferred @github/copilot range', range); + const versionRaw = execFileSync(NPM, ['view', `@github/copilot@${range}`, 'version', '--json'], { encoding: 'utf8', shell: IS_WINDOWS }); + const parsed = JSON.parse(versionRaw); + const resolved = Array.isArray(parsed) ? parsed[parsed.length - 1] : parsed; + if (typeof resolved !== 'string') { + console.warn(`[canary-override] Could not resolve @github/copilot@${range} to a concrete version — leaving VS Code's pinned CLI.`); + return undefined; + } + console.log(`[canary-override] Inferred @github/copilot ${resolved} from @github/copilot-sdk@${sdkVersion} (range ${range}).`); + return resolved; + } catch (err) { + console.warn(`[canary-override] Failed to infer @github/copilot from SDK ${sdkVersion}: ${err instanceof Error ? err.message : err}. Leaving VS Code's pinned CLI.`); + return undefined; + } +} + +/** + * When the CLI version is pinned explicitly (`VSCODE_CLI_CANARY_VERSION`), + * verify it satisfies the `@github/copilot` range the SDK canary declares so an + * incompatible SDK/CLI pair fails here with a clear message rather than + * surfacing as a confusing runtime error in the shipped build. Best-effort: if + * the SDK declares no such range, or the range cannot be resolved from the feed, + * we log and continue rather than block on a transient registry hiccup — only a + * *confirmed* mismatch is fatal. + */ +function assertCliSatisfiesSdk(sdkVersion: string, cliVersion: string): void { + let range: string | undefined; + try { + const depsRaw = execFileSync(NPM, ['view', `@github/copilot-sdk@${sdkVersion}`, 'dependencies', '--json'], { encoding: 'utf8', shell: IS_WINDOWS }); + range = JSON.parse(depsRaw || '{}')['@github/copilot']; + } catch (err) { + console.warn(`[canary-override] Could not read @github/copilot-sdk@${sdkVersion} dependencies to check CLI compatibility: ${err instanceof Error ? err.message : err}. Skipping check.`); + return; + } + if (!range) { + console.log(`[canary-override] SDK ${sdkVersion} declares no @github/copilot dependency — skipping CLI compatibility check for pinned @github/copilot@${cliVersion}.`); + return; + } + assertSafeSpec('@github/copilot range', range); + let satisfying: string[]; + try { + const versionsRaw = execFileSync(NPM, ['view', `@github/copilot@${range}`, 'version', '--json'], { encoding: 'utf8', shell: IS_WINDOWS }); + const parsed = JSON.parse(versionsRaw || 'null'); + satisfying = Array.isArray(parsed) ? parsed : parsed ? [parsed] : []; + } catch (err) { + console.warn(`[canary-override] Could not resolve @github/copilot@${range} to check CLI compatibility: ${err instanceof Error ? err.message : err}. Skipping check.`); + return; + } + if (!satisfying.includes(cliVersion)) { + throw new Error( + `[canary-override] Incompatible pinned versions: @github/copilot@${cliVersion} does not satisfy the range "${range}" required by @github/copilot-sdk@${sdkVersion} ` + + `(versions satisfying the range: ${satisfying.length ? satisfying.join(', ') : ''}). ` + + `Set VSCODE_CLI_CANARY_VERSION to a compatible version, or leave it as 'auto' to infer a compatible CLI from the SDK.` + ); + } + console.log(`[canary-override] Verified @github/copilot@${cliVersion} satisfies "${range}" required by @github/copilot-sdk@${sdkVersion}.`); +} + +function collectOverrides(): Override[] { + const sdkVersion = (process.env['VSCODE_SDK_CANARY_VERSION'] ?? '').trim(); + if (!sdkVersion) { + return []; + } + assertSafeSpec('SDK canary version', sdkVersion); + const overrides: Override[] = [{ name: '@github/copilot-sdk', version: sdkVersion }]; + + // Explicit CLI version wins (but must be compatible with the SDK); empty + // means "infer a compatible CLI from the SDK". + const explicitCli = (process.env['VSCODE_CLI_CANARY_VERSION'] ?? '').trim(); + let cliVersion: string | undefined; + if (explicitCli) { + assertSafeSpec('CLI canary version', explicitCli); + assertCliSatisfiesSdk(sdkVersion, explicitCli); + cliVersion = explicitCli; + } else { + cliVersion = inferCliVersion(sdkVersion); + } + if (cliVersion) { + overrides.push({ name: '@github/copilot', version: cliVersion }); + } + return overrides; +} + +function applyOverrides(dir: string, overrides: Override[]): Override[] { + const packageJsonPath = path.join(ROOT, dir, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const dependencies = packageJson.dependencies ?? {}; + + const applied: Override[] = []; + for (const override of overrides) { + const { name, version } = override; + if (Object.prototype.hasOwnProperty.call(dependencies, name) && dependencies[name] !== version) { + dependencies[name] = version; + applied.push(override); + console.log(`[canary-override] ${path.join(dir, 'package.json')}: ${name} -> ${version}`); + } + } + + if (applied.length > 0) { + packageJson.dependencies = dependencies; + fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); + } + return applied; +} + +function refreshLockfile(dir: string): void { + // Refresh only the lockfile (no node_modules writes, no lifecycle scripts) + // so `npm ci` in the product build resolves the overridden versions. This + // contacts the configured registry, so npm auth for the private feed must + // already be established in the ambient environment. + execFileSync(NPM, ['install', '--package-lock-only', '--ignore-scripts'], { + cwd: path.join(ROOT, dir), + stdio: 'inherit', + shell: IS_WINDOWS + }); +} + +/** + * Confirms the refreshed lockfile actually resolved each override to the + * requested version. Fails loudly if a version is missing (e.g. not published + * to the feed, or a registry/auth misconfiguration) so a bad canary version is + * caught here rather than surfacing as a confusing downstream build error. + */ +function verifyResolved(dir: string, overrides: Override[]): void { + const lockPath = path.join(ROOT, dir, 'package-lock.json'); + const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + const packages = lock.packages ?? {}; + for (const { name, version } of overrides) { + const entry = packages[`node_modules/${name}`]; + if (!entry) { + throw new Error(`[canary-override] ${path.join(dir, 'package-lock.json')}: ${name} not found after lockfile refresh — is ${name}@${version} published to the feed and is npm auth configured?`); + } + if (entry.version !== version) { + throw new Error(`[canary-override] ${path.join(dir, 'package-lock.json')}: ${name} resolved to ${entry.version}, expected ${version}`); + } + console.log(`[canary-override] verified ${path.join(dir, 'package-lock.json')}: ${name}@${entry.version} (resolved ${entry.resolved ?? ''})`); + } +} + +function main(): void { + const overrides = collectOverrides(); + if (overrides.length === 0) { + console.log('[canary-override] No canary versions set — nothing to do.'); + return; + } + + for (const dir of TARGET_DIRS) { + const applied = applyOverrides(dir, overrides); + if (applied.length > 0) { + refreshLockfile(dir); + verifyResolved(dir, applied); + } + } +} + +main(); diff --git a/build/azure-pipelines/common/apply-sdk-canary.yml b/build/azure-pipelines/common/apply-sdk-canary.yml new file mode 100644 index 00000000000000..36bf0bc8540ba1 --- /dev/null +++ b/build/azure-pipelines/common/apply-sdk-canary.yml @@ -0,0 +1,48 @@ +# SDK canary integration (no-op unless VSCODE_SDK_CANARY_VERSION is set). +# Overrides @github/copilot-sdk / @github/copilot to a version published to the +# private feed and refreshes the lockfile so the node_modules cache key below +# misses and `npm ci` installs the canary. Authenticates to the feed first so +# the override's lockfile refresh can resolve the canary — the standard +# npmAuthenticate runs only after the cache-key step. Mirrors common/agent-sdk-produce.yml. +# See microsoft/vscode-engineering specs/sdk-vscode-integration.spec.md. + +parameters: + - name: windows + type: boolean + default: false + +steps: + - ${{ if eq(parameters.windows, true) }}: + - powershell: | + npm config set registry $env:NPM_REGISTRY + Write-Host "##vso[task.setvariable variable=SDK_CANARY_NPMRC_PATH]$(npm config get userconfig)" + condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + displayName: SDK canary — set registry for auth + - ${{ else }}: + - script: | + set -e + npm config set registry "$NPM_REGISTRY" + echo "##vso[task.setvariable variable=SDK_CANARY_NPMRC_PATH]$(npm config get userconfig)" + condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + displayName: SDK canary — set registry for auth + + - task: npmAuthenticate@0 + inputs: + workingFile: $(SDK_CANARY_NPMRC_PATH) + condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + displayName: SDK canary — authenticate feed + + - ${{ if eq(parameters.windows, true) }}: + - powershell: node build/azure-pipelines/common/apply-sdk-canary-override.ts + condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + env: + VSCODE_SDK_CANARY_VERSION: $(VSCODE_SDK_CANARY_VERSION) + VSCODE_CLI_CANARY_VERSION: $(VSCODE_CLI_CANARY_VERSION) + displayName: Apply SDK canary override + - ${{ else }}: + - script: node build/azure-pipelines/common/apply-sdk-canary-override.ts + condition: and(succeeded(), ne(variables['VSCODE_SDK_CANARY_VERSION'], '')) + env: + VSCODE_SDK_CANARY_VERSION: $(VSCODE_SDK_CANARY_VERSION) + VSCODE_CLI_CANARY_VERSION: $(VSCODE_CLI_CANARY_VERSION) + displayName: Apply SDK canary override diff --git a/build/azure-pipelines/common/checkNativeOptionalDeps.ts b/build/azure-pipelines/common/checkNativeOptionalDeps.ts new file mode 100644 index 00000000000000..b85b5742632c2e --- /dev/null +++ b/build/azure-pipelines/common/checkNativeOptionalDeps.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import fs from 'fs'; +import path from 'path'; + +// Some dependencies ship their native binary in a per-platform package that is +// declared as an *optional* dependency of a small base package — e.g. +// `@openai/codex` and `@anthropic-ai/claude-agent-sdk` are thin launchers and +// the real binaries live in `@openai/codex--` / +// `@anthropic-ai/claude-agent-sdk--`. `npm install` / `npm ci` +// do NOT fail when an optional dependency cannot be installed, so a transient +// hiccup can leave the base package present while the per-platform package is +// missing (see https://github.com/microsoft/vscode/pull/323881). +// +// `findMissingNativeOptionalDep` is the reusable primitive that detects this. +// It is used from two places: +// - The CLI entry point below runs after `npm ci` in the node_modules +// cache-build jobs (.github/workflows/pr-node-modules.yml) and fails the +// job so a poisoned cache is never saved. +// - The agent-SDK producer (build/agent-sdk/package.ts) runs it after its +// scratch `npm ci` so a binary-less tarball is never built and uploaded to +// the CDN. + +/** + * Returns the name of the required per-platform package that is missing from + * `nodeModulesDir`, or `undefined` when nothing is wrong. + * + * A base package (e.g. `@openai/codex`) ships its native binary in a + * per-platform optional dependency named `-` (e.g. + * `@openai/codex-linux-x64`, `@anthropic-ai/claude-agent-sdk-linux-x64-musl`). + * npm does not fail when an optional dependency cannot be installed, so this + * detects a base package that ended up installed without its matching native + * package. + * + * `target` is the `-[-]` suffix — e.g. `darwin-arm64`, + * `linux-x64`, `linux-x64-musl`. + * + * Only enforced when the base package itself is installed; if it is not, the + * dependency simply was not requested here and there is nothing to verify. + */ +export function findMissingNativeOptionalDep(nodeModulesDir: string, basePackage: string, target: string): string | undefined { + if (!fs.existsSync(path.join(nodeModulesDir, basePackage))) { + return undefined; + } + const platformPackage = `${basePackage}-${target}`; + if (!fs.existsSync(path.join(nodeModulesDir, platformPackage))) { + return platformPackage; + } + return undefined; +} + +// #region CLI entry point +// +// Runs after the root `npm ci` in the node_modules cache-build jobs (see +// .github/workflows/pr-node-modules.yml), before the cache is saved. Verifies +// the repo-root node_modules has the per-platform package for the current host +// so a poisoned cache (base package present, native package silently skipped) +// is never persisted. + +// Base packages whose per-platform package (`--`) is +// required whenever the base package itself is installed. +const NATIVE_OPTIONAL_DEP_BASE_PACKAGES = [ + '@openai/codex', + '@anthropic-ai/claude-agent-sdk', +]; + +// Platform/arch combinations these packages publish a per-platform package for. +const SUPPORTED_PLATFORMS = new Set(['linux', 'darwin', 'win32']); +const SUPPORTED_ARCHS = new Set(['x64', 'arm64']); + +function isCliInvocation(): boolean { + // `import.meta.filename` is already a real filesystem path; comparing it + // directly to `process.argv[1]` works on Windows too. Matches the pattern + // in `build/agent-sdk/package.ts` and `build/npm/installStateHash.ts`. + return import.meta.filename === process.argv[1]; +} + +function main(): void { + const { platform, arch } = process; + if (!SUPPORTED_PLATFORMS.has(platform) || !SUPPORTED_ARCHS.has(arch)) { + console.log(`Skipping native optional-dependency check on unsupported ${platform}-${arch}.`); + return; + } + + const nodeModulesDir = path.join(import.meta.dirname, '../../../', 'node_modules'); + const target = `${platform}-${arch}`; + const errors: string[] = []; + for (const basePackage of NATIVE_OPTIONAL_DEP_BASE_PACKAGES) { + const missing = findMissingNativeOptionalDep(nodeModulesDir, basePackage, target); + if (missing) { + errors.push(`${basePackage}: required per-platform package '${missing}' is missing from node_modules — the optional dependency was silently skipped during install`); + } + } + + if (errors.length > 0) { + console.error('\x1b[1;31m*** Missing native optional-dependency packages — refusing to save a poisoned node_modules cache ***\x1b[0m'); + for (const err of errors) { + console.error(` - ${err}`); + } + console.error('\nnpm does not fail when an optional dependency cannot be installed, so this tree would poison the shared node_modules cache. Re-run a fresh `npm ci` (e.g. after bumping build/.cachesalt) to restore the package before the cache is saved.'); + process.exit(1); + } + + console.log(`Verified native optional-dependency packages for ${platform}-${arch}.`); +} + +if (isCliInvocation()) { + main(); +} + +// #endregion diff --git a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml index e811c7397a72cd..fd47c266bf1e44 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-node-modules.yml @@ -41,6 +41,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key diff --git a/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml new file mode 100644 index 00000000000000..9450451b0129ad --- /dev/null +++ b/build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml @@ -0,0 +1,67 @@ +parameters: + - name: iterations + type: object + +jobs: + - job: macOSSmokeFlaky + displayName: macOS (Electron) + timeoutInMinutes: 300 + variables: + VSCODE_ARCH: arm64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-macos-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-darwin-compile.yml@self + parameters: + VSCODE_ARCH: arm64 + VSCODE_CIBUILD: true + + - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - script: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - script: mkdir -p .build/crashes .build/logs + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) + APP_NAME="`ls $APP_ROOT | head -n 1`" + npm run smoketest-no-compile -- --tracing --build "$APP_ROOT/$APP_NAME" + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index b5437077cd3a6b..18e2a9c51d10b2 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -41,6 +41,8 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts darwin $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key @@ -121,11 +123,13 @@ steps: # Start pulling the CG-generated NOTICE from the parallel Quality stage # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). - - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Pull CG NOTICE (background) + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) - template: ../../common/install-builtin-extensions.yml@self @@ -169,11 +173,14 @@ steps: # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt # before gulp packages it (and before any later signing/notarization). # Non-fatal: falls back to legacy on any problem. - - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Apply CG NOTICE + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true - script: | set -e diff --git a/build/azure-pipelines/linux/product-build-linux-node-modules.yml b/build/azure-pipelines/linux/product-build-linux-node-modules.yml index 9be0c3f1daf40d..026aadfb772573 100644 --- a/build/azure-pipelines/linux/product-build-linux-node-modules.yml +++ b/build/azure-pipelines/linux/product-build-linux-node-modules.yml @@ -54,6 +54,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key diff --git a/build/azure-pipelines/linux/product-smoke-flaky-linux.yml b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml new file mode 100644 index 00000000000000..b3a7e11ded72a5 --- /dev/null +++ b/build/azure-pipelines/linux/product-smoke-flaky-linux.yml @@ -0,0 +1,82 @@ +parameters: + - name: VSCODE_QUALITY + type: string + - name: iterations + type: object + +jobs: + - job: LinuxSmokeFlaky + displayName: Linux (Electron) + timeoutInMinutes: 300 + variables: + DISPLAY: ":10" + NPM_ARCH: x64 + VSCODE_ARCH: x64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-linux-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-linux-compile.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CIBUILD: true + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + + - script: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + - script: | + set -e + APP_ROOT=$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH) + ELECTRON_ROOT=.build/electron + sudo chown root $APP_ROOT/chrome-sandbox + sudo chown root $ELECTRON_ROOT/chrome-sandbox + sudo chmod 4755 $APP_ROOT/chrome-sandbox + sudo chmod 4755 $ELECTRON_ROOT/chrome-sandbox + displayName: Change setuid helper binary permission + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - script: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - script: mkdir -p .build/crashes .build/logs + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - script: | + set -e + npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)/VSCode-linux-$(VSCODE_ARCH)" + env: + TMPDIR: $(Agent.TempDirectory) + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 diff --git a/build/azure-pipelines/linux/setup-env.sh b/build/azure-pipelines/linux/setup-env.sh index 2f275d1597581b..b9dc1d80b995e1 100755 --- a/build/azure-pipelines/linux/setup-env.sh +++ b/build/azure-pipelines/linux/setup-env.sh @@ -39,7 +39,7 @@ EOF if [ "$npm_config_arch" == "x64" ]; then # Download clang based on chromium revision used by vscode - curl -s https://raw.githubusercontent.com/chromium/chromium/148.0.7778.97/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux + curl -s https://raw.githubusercontent.com/chromium/chromium/148.0.7778.271/tools/clang/scripts/update.py | python - --output-dir=$PWD/.build/CR_Clang --host-os=linux # Download libcxx headers and objects from upstream electron releases DEBUG=libcxx-fetcher \ @@ -51,9 +51,9 @@ if [ "$npm_config_arch" == "x64" ]; then # Set compiler toolchain # Flags for the client build are based on - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/arm.gni - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/compiler/BUILD.gn - # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:build/config/c++/BUILD.gn + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/arm.gni + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/compiler/BUILD.gn + # https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:build/config/c++/BUILD.gn export CC="$PWD/.build/CR_Clang/bin/clang --gcc-toolchain=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu" export CXX="$PWD/.build/CR_Clang/bin/clang++ --gcc-toolchain=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu" export CXXFLAGS="-nostdinc++ -D__NO_INLINE__ -DSPDLOG_USE_STD_FORMAT -I$PWD/.build/libcxx_headers -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit -D_LIBCPP_ABI_NAMESPACE=Cr -D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE --sysroot=$VSCODE_CLIENT_SYSROOT_DIR/x86_64-linux-gnu/x86_64-linux-gnu/sysroot" diff --git a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml index 3972a9927fddcb..159be74b1dcae1 100644 --- a/build/azure-pipelines/linux/steps/product-build-linux-compile.yml +++ b/build/azure-pipelines/linux/steps/product-build-linux-compile.yml @@ -61,6 +61,8 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts linux $VSCODE_ARCH $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key @@ -172,11 +174,13 @@ steps: # Start pulling the CG-generated NOTICE from the parallel Quality stage # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). - - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Pull CG NOTICE (background) + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) - template: ../../common/install-builtin-extensions.yml@self @@ -221,11 +225,14 @@ steps: # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt # right before gulp packages it. Non-fatal: falls back to legacy on any problem. - - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Apply CG NOTICE + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - script: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true - script: | set -e diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 7212e74e881b6d..701be83e17ec56 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -115,12 +115,36 @@ parameters: displayName: "Use legacy OSS Notice" type: boolean default: false + # SDK -> VS Code integration: override the Copilot dependencies to a version + # already published to the private feed. 'none'/'auto' = normal build. + # See microsoft/vscode-engineering specs/sdk-vscode-integration.spec.md. + - name: VSCODE_SDK_CANARY_VERSION + displayName: "SDK canary: @github/copilot-sdk version ('none' = normal build)" + type: string + default: none + - name: VSCODE_CLI_CANARY_VERSION + displayName: "SDK canary: @github/copilot version ('auto' = infer from the SDK)" + type: string + default: auto variables: - name: VSCODE_PRIVATE_BUILD value: ${{ ne(variables['Build.Repository.Uri'], 'https://github.com/microsoft/vscode.git') }} - name: NPM_REGISTRY value: ${{ parameters.NPM_REGISTRY }} + # Normalize the canary sentinels ('none'/'auto') back to empty so the gated + # node-modules steps (condition: ne(..., '')) stay a no-op for normal builds + # and the override script infers the CLI when it is empty. + - name: VSCODE_SDK_CANARY_VERSION + ${{ if eq(parameters.VSCODE_SDK_CANARY_VERSION, 'none') }}: + value: "" + ${{ else }}: + value: ${{ parameters.VSCODE_SDK_CANARY_VERSION }} + - name: VSCODE_CLI_CANARY_VERSION + ${{ if eq(parameters.VSCODE_CLI_CANARY_VERSION, 'auto') }}: + value: "" + ${{ else }}: + value: ${{ parameters.VSCODE_CLI_CANARY_VERSION }} - name: CARGO_REGISTRY value: ${{ parameters.CARGO_REGISTRY }} - name: VSCODE_QUALITY diff --git a/build/azure-pipelines/product-copilot.yml b/build/azure-pipelines/product-copilot.yml index cb3210e41cc618..d47a23a6d24dca 100644 --- a/build/azure-pipelines/product-copilot.yml +++ b/build/azure-pipelines/product-copilot.yml @@ -5,6 +5,9 @@ parameters: - name: VSCODE_RELEASE type: boolean default: false + - name: VSCODE_UPLOAD_SOURCEMAPS + type: boolean + default: true jobs: - job: Copilot @@ -94,86 +97,88 @@ jobs: parameters: runIntegrationTests: false - - task: AzureCLI@2 - displayName: Upload source maps to CDN - inputs: - azureSubscription: vscode-cdn - scriptType: bash - addSpnToEnvironment: true - scriptLocation: inlineScript - inlineScript: | - set -e - - WORKING_DIR="$(Build.SourcesDirectory)/extensions/copilot" - SOURCE_MAP_DIR="$WORKING_DIR/dist-sourcemaps" - STORAGE_ACCOUNT="vscodeweb" - - if [ ! -d "$SOURCE_MAP_DIR" ]; then - echo "Source maps directory not found: $SOURCE_MAP_DIR" - echo "Skipping upload." - exit 0 - fi - - PUBLISHER=$(node -p "require('$WORKING_DIR/package.json').publisher") - NAME=$(node -p "require('$WORKING_DIR/package.json').name") - VERSION=$(node -p "require('$WORKING_DIR/package.json').version") - EXTENSION_ID=$(echo "${PUBLISHER}.${NAME}" | tr '[:upper:]' '[:lower:]') - - echo "Extension: $EXTENSION_ID" - echo "Version: $VERSION" - - MAP_FILES=$(find "$SOURCE_MAP_DIR" -name "*.map" -type f) - FILE_COUNT=$(echo "$MAP_FILES" | grep -c . || true) - - if [ "$FILE_COUNT" -eq 0 ]; then - echo "No source map files found in $SOURCE_MAP_DIR" - exit 0 - fi - - echo "Found $FILE_COUNT source map files" - - BLOB_URL="https://${STORAGE_ACCOUNT}.blob.core.windows.net" - PREFIX="sourcemaps/${EXTENSION_ID}/${VERSION}" - - echo "Uploading to: $BLOB_URL/\$web/$PREFIX/" - - UPLOADED=0 - for FILE in $MAP_FILES; do - FILENAME=$(basename "$FILE") - BLOB_NAME="$PREFIX/$FILENAME" - + # Source map upload is only needed for builds that ship the extension. + - ${{ if eq(parameters.VSCODE_UPLOAD_SOURCEMAPS, true) }}: + - task: AzureCLI@2 + displayName: Upload source maps to CDN + inputs: + azureSubscription: vscode-cdn + scriptType: bash + addSpnToEnvironment: true + scriptLocation: inlineScript + inlineScript: | + set -e + + WORKING_DIR="$(Build.SourcesDirectory)/extensions/copilot" + SOURCE_MAP_DIR="$WORKING_DIR/dist-sourcemaps" + STORAGE_ACCOUNT="vscodeweb" + + if [ ! -d "$SOURCE_MAP_DIR" ]; then + echo "Source maps directory not found: $SOURCE_MAP_DIR" + echo "Skipping upload." + exit 0 + fi + + PUBLISHER=$(node -p "require('$WORKING_DIR/package.json').publisher") + NAME=$(node -p "require('$WORKING_DIR/package.json').name") + VERSION=$(node -p "require('$WORKING_DIR/package.json').version") + EXTENSION_ID=$(echo "${PUBLISHER}.${NAME}" | tr '[:upper:]' '[:lower:]') + + echo "Extension: $EXTENSION_ID" + echo "Version: $VERSION" + + MAP_FILES=$(find "$SOURCE_MAP_DIR" -name "*.map" -type f) + FILE_COUNT=$(echo "$MAP_FILES" | grep -c . || true) + + if [ "$FILE_COUNT" -eq 0 ]; then + echo "No source map files found in $SOURCE_MAP_DIR" + exit 0 + fi + + echo "Found $FILE_COUNT source map files" + + BLOB_URL="https://${STORAGE_ACCOUNT}.blob.core.windows.net" + PREFIX="sourcemaps/${EXTENSION_ID}/${VERSION}" + + echo "Uploading to: $BLOB_URL/\$web/$PREFIX/" + + UPLOADED=0 + for FILE in $MAP_FILES; do + FILENAME=$(basename "$FILE") + BLOB_NAME="$PREFIX/$FILENAME" + + az storage blob upload \ + --account-name "$STORAGE_ACCOUNT" \ + --container-name '$web' \ + --name "$BLOB_NAME" \ + --file "$FILE" \ + --content-type "application/json" \ + --content-cache-control "max-age=31536000, public" \ + --auth-mode login \ + --overwrite \ + --only-show-errors + + UPLOADED=$((UPLOADED + 1)) + 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 "$BLOB_NAME" \ - --file "$FILE" \ + --name "$MAPPING_BLOB" \ + --file "/tmp/commit-version.json" \ --content-type "application/json" \ - --content-cache-control "max-age=31536000, public" \ + --content-cache-control "no-cache, no-store, must-revalidate" \ --auth-mode login \ --overwrite \ --only-show-errors - UPLOADED=$((UPLOADED + 1)) - 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" + echo "Commit mapping uploaded: $MAPPING_BLOB" diff --git a/build/azure-pipelines/product-smoke-flaky.yml b/build/azure-pipelines/product-smoke-flaky.yml new file mode 100644 index 00000000000000..de729e6d139442 --- /dev/null +++ b/build/azure-pipelines/product-smoke-flaky.yml @@ -0,0 +1,209 @@ +# Daily smoke-test flaky-detection pipeline. +# +# Builds VS Code from source on Linux, Windows and macOS and runs ONLY the +# Electron smoke tests, repeated 20 times per platform. Each repetition is its +# own step (continueOnError) so a single flaky failure does not abort the run and +# every iteration produces its own timeline record. The pass/fail tally is read +# back from the build timeline by the vscode-probot `SmokeFlaky` Azure Function +# (wired up via an ADO service hook on build completion), which posts a summary +# to the Slack #build channel. +# +# We build from source rather than testing a published build on purpose: product +# builds are not always released, so depending on a published artifact would make +# us miss the daily schedule. Building here keeps the run self-contained. + +pr: none +trigger: none + +schedules: + # Once per day at 04:00 UTC (= 5am CET / 6am CEST) — a quiet window for the ADO + # org. `always: true` forces the run even when there are no new commits, since + # flaky behavior is environmental and worth sampling every day regardless of + # source changes. + - cron: "0 4 * * *" + displayName: Daily at 04:00 UTC / 5am CET (smoke flaky detection) + branches: + include: + - main + always: true + +parameters: + - name: VSCODE_QUALITY + displayName: Quality + type: string + default: insider + values: + - exploration + - insider + - stable + - name: NPM_REGISTRY + displayName: "Custom NPM Registry" + type: string + default: "https://pkgs.dev.azure.com/monacotools/Monaco/_packaging/vscode/npm/registry/" + # The smoke test is run once per entry in this list. 20 entries => 20 runs per + # platform (the flaky-detection sample size). Exposed as an object so it can be + # shortened for manual debugging runs from the ADO UI. + - name: iterations + displayName: "Smoke test iterations (one run per entry)" + type: object + default: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20] + - name: VSCODE_BUILD_MACOS + displayName: "🎯 macOS arm64" + type: boolean + default: true + - name: VSCODE_BUILD_LINUX + displayName: "🎯 Linux x64" + type: boolean + default: true + - name: VSCODE_BUILD_WIN32 + displayName: "🎯 Windows x64" + type: boolean + default: true + +variables: + - name: VSCODE_QUALITY + value: ${{ parameters.VSCODE_QUALITY }} + - name: NPM_REGISTRY + value: ${{ parameters.NPM_REGISTRY }} + # Compile templates skip publish/codesign/artifact-staging when VSCODE_CIBUILD + # is true; it's passed directly as a job parameter. VSCODE_STEP_ON_IT must be + # 'false' to keep the build steps enabled. + - name: VSCODE_STEP_ON_IT + value: false + - name: VSCODE_MIXIN_REPO + value: microsoft/vscode-distro + - name: VSCODE_OVERWRITE_TPN + value: "true" + # The Copilot extension build patches its package.json version for pre-release + # (non-stable) quality and requires a per-build counter (0-99, reset daily) to + # keep versions unique. Mirrors product-build.yml. The counter resets each day + # per quality+branch, so this daily pipeline stays at a low value. + - name: VSCODE_PUBLISH_COUNTER_PREFIX + value: $[format('{0:yyyyMMdd}-{1}-{2}', pipeline.startTime, variables['VSCODE_QUALITY'], variables['Build.SourceBranch'])] + - name: VSCODE_PUBLISH_COUNTER + value: $[counter(variables['VSCODE_PUBLISH_COUNTER_PREFIX'], 1)] + - name: skipComponentGovernanceDetection + value: true + - name: Codeql.SkipTaskAutoInjection + value: true + +name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }} smoke flaky)" + +resources: + repositories: + - repository: 1esPipelines + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: distro + type: github + name: microsoft/vscode-distro + endpoint: Monaco + ref: main + - repository: capi + type: github + name: microsoft/vscode-capi + endpoint: Monaco + ref: main + - repository: encrypt + type: github + name: microsoft/vscode-encrypt + endpoint: Monaco + ref: main + - repository: vsda + type: github + name: microsoft/vsda + endpoint: Monaco + ref: main + - repository: regexp + type: github + name: microsoft/vscode-regexp-languagedetection + endpoint: Monaco + ref: main + - repository: vscode_loc + type: github + name: microsoft/vscode-extensions-loc + endpoint: Monaco + ref: main + +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + sdl: + sourceRepositoriesToScan: + exclude: + - repository: distro + - repository: capi + - repository: encrypt + - repository: vsda + - repository: regexp + - repository: vscode_loc + tsa: + enabled: false + codeql: + compiled: + enabled: false + justificationForDisabling: Smoke test repetition only, not a shipping build + credscan: + suppressionsFile: $(Build.SourcesDirectory)/build/azure-pipelines/config/CredScanSuppressions.json + eslint: + enabled: false + binskim: + enabled: false + sourceAnalysisPool: 1es-windows-2022-x64 + createAdoIssuesForJustificationsForDisablement: false + stages: + # The per-OS compile template unconditionally waits (up to 30 min) for a + # `Copilot` job to publish the `copilot_vsix` artifact, which is mixed into + # the build. This stage produces it — mirroring product-build.yml. It runs + # in parallel with the build stages (dependsOn: []); the download step polls + # the whole-run timeline for the artifact. Without it every build stage + # fails at "Download Copilot VSIX". + - stage: Copilot + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/product-copilot.yml@self + parameters: + VSCODE_PUBLISH: false + VSCODE_RELEASE: false + # This is a test-only run; don't push the Copilot extension's source + # maps to the CDN. + VSCODE_UPLOAD_SOURCEMAPS: false + + - ${{ if eq(parameters.VSCODE_BUILD_LINUX, true) }}: + - stage: Linux + dependsOn: [] + pool: + name: 1es-ubuntu-22.04-x64 + os: linux + jobs: + - template: build/azure-pipelines/linux/product-smoke-flaky-linux.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + iterations: ${{ parameters.iterations }} + + - ${{ if eq(parameters.VSCODE_BUILD_WIN32, true) }}: + - stage: Windows + dependsOn: [] + pool: + name: 1es-windows-2022-x64 + os: windows + jobs: + - template: build/azure-pipelines/win32/product-smoke-flaky-win32.yml@self + parameters: + VSCODE_QUALITY: ${{ variables.VSCODE_QUALITY }} + iterations: ${{ parameters.iterations }} + + - ${{ if eq(parameters.VSCODE_BUILD_MACOS, true) }}: + - stage: macOS + dependsOn: [] + pool: + name: AcesShared + os: macOS + jobs: + - template: build/azure-pipelines/darwin/product-smoke-flaky-darwin.yml@self + parameters: + iterations: ${{ parameters.iterations }} diff --git a/build/azure-pipelines/web/product-build-web-node-modules.yml b/build/azure-pipelines/web/product-build-web-node-modules.yml index fdd15d54f1a102..e757bc918eb9f8 100644 --- a/build/azure-pipelines/web/product-build-web-node-modules.yml +++ b/build/azure-pipelines/web/product-build-web-node-modules.yml @@ -26,6 +26,8 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../common/apply-sdk-canary.yml@self + - script: mkdir -p .build && node build/azure-pipelines/common/computeNodeModulesCacheKey.ts web $(node -p process.arch) > .build/packagelockhash displayName: Prepare node_modules cache key diff --git a/build/azure-pipelines/win32/product-build-win32-node-modules.yml b/build/azure-pipelines/win32/product-build-win32-node-modules.yml index 9167e5b6a004b8..2ff7fc1158b7da 100644 --- a/build/azure-pipelines/win32/product-build-win32-node-modules.yml +++ b/build/azure-pipelines/win32/product-build-win32-node-modules.yml @@ -37,6 +37,10 @@ jobs: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../common/apply-sdk-canary.yml@self + parameters: + windows: true + - pwsh: | mkdir .build -ea 0 node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 $(VSCODE_ARCH) $(node -p process.arch) > .build/packagelockhash diff --git a/build/azure-pipelines/win32/product-smoke-flaky-win32.yml b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml new file mode 100644 index 00000000000000..e1241ae3c70b31 --- /dev/null +++ b/build/azure-pipelines/win32/product-smoke-flaky-win32.yml @@ -0,0 +1,66 @@ +parameters: + - name: VSCODE_QUALITY + type: string + - name: iterations + type: object + +jobs: + - job: WindowsSmokeFlaky + displayName: Windows (Electron) + timeoutInMinutes: 300 + variables: + VSCODE_ARCH: x64 + BUILDS_API_URL: $(System.CollectionUri)$(System.TeamProject)/_apis/build/builds/$(Build.BuildId)/ + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/crashes + artifactName: crash-dump-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Crash Reports + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory)/.build/logs + artifactName: logs-windows-$(VSCODE_ARCH)-smoke-$(System.JobAttempt) + displayName: Publish Log Files + sbomEnabled: false + isProduction: false + condition: succeededOrFailed() + steps: + # Build VS Code from source. No VSCODE_RUN_*_TESTS flags are passed, so the + # compile template builds the client/server but skips compiling the test + # suites and skips the unit/integration/smoke test template entirely — we + # run only the smoke tests below, nothing else. + - template: ./steps/product-build-win32-compile.yml@self + parameters: + VSCODE_ARCH: x64 + VSCODE_CIBUILD: true + VSCODE_QUALITY: ${{ parameters.VSCODE_QUALITY }} + + - powershell: npm exec -- npm-run-all2 -lp "electron $(VSCODE_ARCH)" "playwright-install" + env: + GITHUB_TOKEN: "$(github-token-code-oss)" + AZURE_DEVOPS_EXT_PAT: $(System.AccessToken) + displayName: Download Electron and Playwright + retryCountOnTaskFailure: 3 + + # The compile template only compiles the smoke suite when a test flag is + # set; since we pass none, compile it explicitly here. + - powershell: npm run compile --prefix test/smoke + displayName: Compile smoke tests + + # Guarantee the artifact directories exist so the 1ES output publishing + # never fails on a missing path. The smoke runner writes logs under + # .build/logs and crash dumps under .build/crashes (only on a crash). + - powershell: New-Item -ItemType Directory -Force -Path .build\crashes, .build\logs | Out-Null + displayName: Ensure artifact directories exist + + # Run the Electron smoke test once per iteration. continueOnError lets every + # iteration run even if some fail, and gives each run its own timeline record + # so the SmokeFlaky function can tally passes vs. failures. + - ${{ each i in parameters.iterations }}: + - powershell: npm run smoketest-no-compile -- --tracing --build "$(agent.builddirectory)\VSCode-win32-$(VSCODE_ARCH)" + displayName: "🧪 Smoke test iteration ${{ i }}/${{ length(parameters.iterations) }} (Electron)" + continueOnError: true + timeoutInMinutes: 20 diff --git a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml index c14cbcc6de28a2..1ca604668658db 100644 --- a/build/azure-pipelines/win32/steps/product-build-win32-compile.yml +++ b/build/azure-pipelines/win32/steps/product-build-win32-compile.yml @@ -41,6 +41,10 @@ steps: condition: and(succeeded(), ne(variables['NPM_REGISTRY'], 'none')) displayName: Setup NPM Registry + - template: ../../common/apply-sdk-canary.yml@self + parameters: + windows: true + - pwsh: | mkdir .build -ea 0 node build/azure-pipelines/common/computeNodeModulesCacheKey.ts win32 $(VSCODE_ARCH) $(node -p process.arch) > .build/packagelockhash @@ -113,11 +117,13 @@ steps: # Start pulling the CG-generated NOTICE from the parallel Quality stage # in the background (overwrites repo-root ThirdPartyNotices.txt before packaging). - - pwsh: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Pull CG NOTICE (background) + # Publish builds only (CIBUILD=false) — CI test jobs never ship a notice. + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --detach --wait -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Pull CG NOTICE (background) - template: ../../common/install-builtin-extensions.yml@self @@ -167,11 +173,14 @@ steps: # Block on the CG NOTICE download and overwrite ThirdPartyNotices.txt # right before gulp packages it. Non-fatal: falls back to legacy on any problem. - - pwsh: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts - env: - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) - displayName: Apply CG NOTICE + - ${{ if ne(parameters.VSCODE_CIBUILD, true) }}: + - pwsh: npx deemon --attach -- node build/azure-pipelines/common/downloadNotice.ts + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + VSCODE_OVERWRITE_TPN: $(VSCODE_OVERWRITE_TPN) + displayName: Apply CG NOTICE + # deemon --attach can exit non-zero if the background daemon died; never fail for that. + continueOnError: true - powershell: | . build/azure-pipelines/win32/exec.ps1 diff --git a/build/checksums/electron.txt b/build/checksums/electron.txt index 7db96c8acfc3ba..37aeada5f98f60 100644 --- a/build/checksums/electron.txt +++ b/build/checksums/electron.txt @@ -1,75 +1,75 @@ -6c25b7496a0f3f4e325965ac3d934f9460e6b39fc2aa05b2aefecb1e212e7535 *chromedriver-v42.2.0-darwin-arm64.zip -7ea0a5378a615b816c6652c1e8f62b25fee751f34c669eb3eff122dcde98dffc *chromedriver-v42.2.0-darwin-x64.zip -8f6c2462e05491ab7a6ea6492fe7f62c8de1b01900978dd3dcbc878fde6f5e3e *chromedriver-v42.2.0-linux-arm64.zip -72cdb458b48306ec4939021198696926651a6a6dd47b8acf4486d77689ffe88f *chromedriver-v42.2.0-linux-armv7l.zip -ebe0fb1e5eb8a83a20612d660191d8292826d46d93701fe6390a9a5ab69aede0 *chromedriver-v42.2.0-linux-x64.zip -f19d5491a6c5d2970b05a42e1e2a5bcab75206423cea9dfe8ef5c2b4a0ceb38b *chromedriver-v42.2.0-mas-arm64.zip -30c88509d9640b4bf0066da7b6df7c4bb61d894df20941daaeeead598da40c28 *chromedriver-v42.2.0-mas-x64.zip -a6a21d19b84938d32ea75ad19013370c8937536c049d4d5c44175fbcf3703c5b *chromedriver-v42.2.0-win32-arm64.zip -da076702e72317843754e72cc1895c4d223e423f177061342ae5aec78a7281a9 *chromedriver-v42.2.0-win32-ia32.zip -97074022e6b5048b0bd9932c011e8377148c04b0e5455ea2e24bfc048d888297 *chromedriver-v42.2.0-win32-x64.zip -4012c6a738d83799544cabd04e41c8eba2467e4de573664326eaea174f2f8b47 *electron-api.json -bffe18be718c641a0fdfa8bf2ab82dbce462b8809be97898a9c4dfd136be071d *electron-v42.2.0-darwin-arm64-dsym-snapshot.zip -5a341581905b84ae62b4f7664c0fd950ec78fef172042dd8b55dc2a8d9aea66a *electron-v42.2.0-darwin-arm64-dsym.tar.xz -110b25eadfd680ad44ba5b43bf59dcf31297976ee128735e3ef9bbc2a35758cf *electron-v42.2.0-darwin-arm64-symbols.zip -f45f80da0a2d005530b70f6f6b00756dbf875947a21e533041a05b3c4d629f79 *electron-v42.2.0-darwin-arm64.zip -aefc2008c08cd3795cf997a5c5d88772497f460c5cc6f5008b01c412d7534417 *electron-v42.2.0-darwin-x64-dsym-snapshot.zip -a6b70b51de1f05541e42138d02768ef72940c125379b0d7a929fc3e5f2e1eb75 *electron-v42.2.0-darwin-x64-dsym.tar.xz -2d64a5bf8af6b9dccecde9ac09f20cbae36a76be537c4b3e5c3a238add5a4a01 *electron-v42.2.0-darwin-x64-symbols.zip -cd6a2d4feca84b7e7b2c4a5a13a443fc3c1173e77b05f798deaab2f0f41002a1 *electron-v42.2.0-darwin-x64.zip -2c0e81bc0e82c9e1966d4042b565dbf4924bd6365c08e6cc715001b3842ff9cd *electron-v42.2.0-linux-arm64-debug.zip -30983431149fc5a813c2145843744292e2553577f19d75aa09cbf8f7100d9319 *electron-v42.2.0-linux-arm64-symbols.zip -1f2037dbdcb8b1327b855ec15fbe3fb8a7f27786b331d17866e88377a0606ad8 *electron-v42.2.0-linux-arm64.zip -948553edfe3fa5272327d867b68aceb6e272cdeb68df4a40a6ffa045976531d2 *electron-v42.2.0-linux-armv7l-debug.zip -fed24d4f0c4fa40c3dfe38fdea423d6f36dc09630a614fb9060373fe30f310c4 *electron-v42.2.0-linux-armv7l-symbols.zip -00de1cc51859a4e064a2178cb29c899feadfabf24d926d8f684c30e6ab9b72a7 *electron-v42.2.0-linux-armv7l.zip -e0a0c2f97e97321ca194bf2ed7275ad6b31c0c0f4f7e4989838c4fd74728f306 *electron-v42.2.0-linux-x64-debug.zip -cf65567a4c5ef25c24cbdc8a6059a419773756f79313a1e682272c42f5716f2a *electron-v42.2.0-linux-x64-symbols.zip -9caeeb15dada37cb3a2d80bf0f5899d175db026a4def11560890bd2f19684909 *electron-v42.2.0-linux-x64.zip -69ad3951065eb33bb6533f872267828e4ea728ca7d539c649cebc966c0ea1bab *electron-v42.2.0-mas-arm64-dsym-snapshot.zip -38c4beeb71171ab97f55e9a213aa44a45e20e62cc73207532f29ff70a2698539 *electron-v42.2.0-mas-arm64-dsym.tar.xz -359be04c4da1b1979f0e1275b3abd5bd2cbab43c2290d71bfdeb174c1f42beba *electron-v42.2.0-mas-arm64-symbols.zip -535aca88dbe2977d22dc63e47887d09c7e41e8ccf4c0f23d67b7ce593e2341dd *electron-v42.2.0-mas-arm64.zip -e621151d901eeab917e225c97e102fa33ba5225c5cd5b8c152948d8e4a60d104 *electron-v42.2.0-mas-x64-dsym-snapshot.zip -0d33ddacaa69b923fe0117f6ed7d92c8918de6e98daa5f0e9462acf1ea758c5e *electron-v42.2.0-mas-x64-dsym.tar.xz -1527430d26a58505c0e3e52f27afc5aa5feffa5dd17d83d1b89ebcb16dfb4c0c *electron-v42.2.0-mas-x64-symbols.zip -576abe887a65587910ffc481fcaac39bb42e2791c54ae3a2c000431a44299cd2 *electron-v42.2.0-mas-x64.zip -d4e06302625806acf6a39b15a22835de6d4c38ce0cedfe1716d4c0df8416fc2b *electron-v42.2.0-win32-arm64-pdb.zip -09792a89e357258afae9d3abe51f42592062bafb1b98e80d77daa1cc554e26f2 *electron-v42.2.0-win32-arm64-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-arm64-toolchain-profile.zip -1e6b5639cbbb0134f41e5cb62a283ebc34bc580a8fb21349d711f275d60f9705 *electron-v42.2.0-win32-arm64.zip -5b5803e32a2ddfb51bbf4a0097a6fb48f964b2844150cec9c6fb48d3eef16865 *electron-v42.2.0-win32-ia32-pdb.zip -218ce2ebacc35de9bc9ac83a1b65551fd53c9622b71516a484bcfed0aec2c4a6 *electron-v42.2.0-win32-ia32-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-ia32-toolchain-profile.zip -1a866e0634ff95f83a043c7c6738e148f38362ed0afa60aa5eb8dcab16def7b5 *electron-v42.2.0-win32-ia32.zip -82329a1c558392b1d2a80cfc24d1463cf88fd0bb7b542eee24305ae099935bf6 *electron-v42.2.0-win32-x64-pdb.zip -00b8c0437bb75b0db1dfa2bf23e6a6d2436c0665e2a627bd2732ccab0fd1e648 *electron-v42.2.0-win32-x64-symbols.zip -615b0145f304e0eea0ffd20c0fcf0e8e7e5255e1c3f3ae0577725a16ebb91994 *electron-v42.2.0-win32-x64-toolchain-profile.zip -6e034b748ad5ed9445bd3da4b7d0792ed49556774a541217b507c156b00dd69a *electron-v42.2.0-win32-x64.zip -acb2fdd6e99a2056ff88fe26d94b141922e30c5a6a2b11868aa5e708ba30266d *electron.d.ts -3099226c4eb0c13134bbdd970c0fac8a372547b401c54552a89fa1689470dbd7 *ffmpeg-v42.2.0-darwin-arm64.zip -1f4fac2e8f4f8b136bc29ce6be50598f217300e10c1a203b6b893f623ba516ba *ffmpeg-v42.2.0-darwin-x64.zip -1fd892c2195d89a7d4f926167c122c34ab7ce15a61b75c8e5ba0cbfc47ebdb4f *ffmpeg-v42.2.0-linux-arm64.zip -6b6a200705ded7211b02d7ee56396d99c2003aa6b0b83bdd8c89f95702ea459d *ffmpeg-v42.2.0-linux-armv7l.zip -ffb78ccf0b2cbf1a1c0da2a69d8021337ae5352642a2d84adae951f95c771696 *ffmpeg-v42.2.0-linux-x64.zip -3099226c4eb0c13134bbdd970c0fac8a372547b401c54552a89fa1689470dbd7 *ffmpeg-v42.2.0-mas-arm64.zip -1f4fac2e8f4f8b136bc29ce6be50598f217300e10c1a203b6b893f623ba516ba *ffmpeg-v42.2.0-mas-x64.zip -87ad1b3868751b2af34cfebd62f9874ced837fc4389adad3e7659a437afcffb1 *ffmpeg-v42.2.0-win32-arm64.zip -57af529a0cc217bb5f20c67554eb526926b853a1f1594a06e8423662cead9c09 *ffmpeg-v42.2.0-win32-ia32.zip -62dc51e9290b444c6640048a715d8d7b60fe3e546a98f0faff945e4a522250f8 *ffmpeg-v42.2.0-win32-x64.zip -72ea4fdeabf3a49dd86bdeb136c916f7b54952b439edd527321963a5dbb0460d *hunspell_dictionaries.zip -98e8f208b8fd697e2a035d03e83de5731ba92b23f13f826abc60c0b7eab0d952 *libcxx-objects-v42.2.0-linux-arm64.zip -7187c3f0406a04b8e19af6ca374e6d7ca7833e47ea2382f7fba1ed7061f7d8be *libcxx-objects-v42.2.0-linux-armv7l.zip -7254b02f7220aa47210c382f45668016710d07cd7c7c61da5c4c556cc2334446 *libcxx-objects-v42.2.0-linux-x64.zip -37ade3096c7362b9df8945311f55abb8d039f93aabacec086e449cee4a31dcab *libcxx_headers.zip -c623bfe37d755eb9020fc080d4ef0df799acf3439b7b588f9a62d9b6e3908b75 *libcxxabi_headers.zip -44b16dd0e2bade1253e6f36235a43f005ed6472c1017195ee1ea1e629b8ab504 *mksnapshot-v42.2.0-darwin-arm64.zip -9d59364581e6001d655cd6c659e76ab80799d794e22b0c990b181268faa0492c *mksnapshot-v42.2.0-darwin-x64.zip -6035f7f148470352c04f0591d360685724a7784780f6d74c4fdaa99c71b8be6f *mksnapshot-v42.2.0-linux-arm64-x64.zip -27d4c39086c376744bd606f55c7b6c1420d0267f94cd8974031ce4b1f88320e6 *mksnapshot-v42.2.0-linux-armv7l-x64.zip -ca549469050d6a360e6b95ea202cb61ea704db51f7d67aa67c457e5dfa98b459 *mksnapshot-v42.2.0-linux-x64.zip -8d2271bd6cb9a16d34e02dfc92efb1fd1a2908038165efc9aa161cf5e62dc7ae *mksnapshot-v42.2.0-mas-arm64.zip -ae8453efb615242dd27de97cf1f9c6b8f4c6ad9cfab5fc476671d8751c5ac319 *mksnapshot-v42.2.0-mas-x64.zip -f21cdde8044078ce14acd0c65af1e7cf999d78e6d40d467cc73f854c9e9ef481 *mksnapshot-v42.2.0-win32-arm64-x64.zip -4b5b7d68067f55a0fc4dd4c295ca115070f288684e7942e0e80badc2b64754a6 *mksnapshot-v42.2.0-win32-ia32.zip -25d850c5674e0a821bb5897197b6e38c0d55a5cf0505d4d79a39b608b54c4b5c *mksnapshot-v42.2.0-win32-x64.zip +f46a52e829e4e8d294887346e2cd65a43cda3cf0c4988f9e8c40bff083b21ca0 *chromedriver-v42.5.0-darwin-arm64.zip +95ea8ac2949531f92a69e935345fb5f2c49dabb4d03244e90431b54e4177d66c *chromedriver-v42.5.0-darwin-x64.zip +217eff942b1fb63767a2fccdf309b8af67bc85dcbe5b6a626159706f8b0f6549 *chromedriver-v42.5.0-linux-arm64.zip +af76c74bebd454a43e6ed463eba64b2d635661203c31bca8efbe829169f1df2a *chromedriver-v42.5.0-linux-armv7l.zip +ada1322977b76a42c97baa12a9388fb3d33e6043a9d4380ded3ccd003c9f3481 *chromedriver-v42.5.0-linux-x64.zip +9a1fc333f04178abc4985596ff5e6843e857d63bc05be8ae9b8d39adc184890c *chromedriver-v42.5.0-mas-arm64.zip +7c1357049a4f4d87e86f80f002b948dbd77f2987002c05d09f67495ae38d6235 *chromedriver-v42.5.0-mas-x64.zip +619bb88746f6708d9f291847ec8f9a360b9dd799cdda96c2125e6e64f6ba5dc6 *chromedriver-v42.5.0-win32-arm64.zip +d3cf1b135f8fd9c27d5736f64b4afd1044b851ba3122c7a0985188aba94f2df0 *chromedriver-v42.5.0-win32-ia32.zip +a22ffa5f552adf98609ce15511133f8d3d2a0dee3f7be21cbf252bac98364ef6 *chromedriver-v42.5.0-win32-x64.zip +9600be1460e73162fa0bc04b0d27f6af9f87af9c9e8c1979feeb81777a984fce *electron-api.json +d32ee862313327486bfe33c2d91d404d4ee20146773c8a5f87bd418f8a8e9a91 *electron-v42.5.0-darwin-arm64-dsym-snapshot.zip +606a47375b0e48343d3a96d3bada5add9e26768036edecbdb6e6f1aea754e55e *electron-v42.5.0-darwin-arm64-dsym.tar.xz +155e135264dfe1b4109140acc23e321e00495516089937ee0909ff3e70a9447f *electron-v42.5.0-darwin-arm64-symbols.zip +5e392f66b3f6d13caa6d50bbe10fce3d848ca16c5680529357aa26c5da58a1e6 *electron-v42.5.0-darwin-arm64.zip +7a4691f6648a10dfb3315a45e1ce4c297a606d1c475b65fbb5c9d80688cbb0bb *electron-v42.5.0-darwin-x64-dsym-snapshot.zip +fc620d7787958dbe74dc9acaf42fe35fcdbba7f0c727b376e979b1b86e0d99c9 *electron-v42.5.0-darwin-x64-dsym.tar.xz +9fca997d9f6187b398730b69819e3643dc182b869e1ad0b80a7b5e1cc55a9ad1 *electron-v42.5.0-darwin-x64-symbols.zip +ca4792d47e74a6afce1d39a019f201d1a47fa81fcf4e8bcaac6fe5756f172940 *electron-v42.5.0-darwin-x64.zip +f144b1025a7b8c5bf34cfbc6f17f47e594db186de4b5647286d40618c8efd5c4 *electron-v42.5.0-linux-arm64-debug.zip +54878afd06169b12e1e2908319883d2e3ecd4a3fc39187152a4110f13a20db46 *electron-v42.5.0-linux-arm64-symbols.zip +0188f1d86efe785e5721cfaa1ae51d6ef68260705c30037d0d271aaaf78af315 *electron-v42.5.0-linux-arm64.zip +d58cf9b92c9e74885d6fd0ea0740602fa368d87967ed35b8286aaf4170e053e1 *electron-v42.5.0-linux-armv7l-debug.zip +45cfe194be6c3af8c8f4f04e83d26cea2f0d01d822d6d0d39cf24ef11298b6f3 *electron-v42.5.0-linux-armv7l-symbols.zip +360ff6d128be77be83fb3eaf458380fdca226be06f247551049fe3b6ef8ac382 *electron-v42.5.0-linux-armv7l.zip +a0fad46643b7bf6bfce7b2d0a3bd26166079e81e0581d9f3420484984e135c4b *electron-v42.5.0-linux-x64-debug.zip +3c1a4dc6c79bad3d9bc7d3af17b22bc3c97543c27a49b18d852b9eeb3ced2c0a *electron-v42.5.0-linux-x64-symbols.zip +6705a9d0cc5c8f225d705d6e1c2607b2b5be8667d2befb18cdafe8b7b29b8008 *electron-v42.5.0-linux-x64.zip +11b3c08d04f8e89983bff6ee1fc872f8d94f0a92c07311c81da6ac493cbedcc9 *electron-v42.5.0-mas-arm64-dsym-snapshot.zip +1ed2d9702069c699e143366d86e24398349072209459d23883c8bda74f62a161 *electron-v42.5.0-mas-arm64-dsym.tar.xz +7a474a3498973aeb9344c193a383e3590fde4ce083b0f0d51a0eced4f6d31ac6 *electron-v42.5.0-mas-arm64-symbols.zip +33166a8b868cd6624cb0d70847b42067219a2f532050d1cdd128d8d7eb9f2b74 *electron-v42.5.0-mas-arm64.zip +b6eaf6f40e20f196974f675c936a2d25c97a3f660faecba8978fd59b838a7b40 *electron-v42.5.0-mas-x64-dsym-snapshot.zip +30ca0601ba38e804152d16805eb01b05a60452359ce3d6b9cebb1e21d024f4dc *electron-v42.5.0-mas-x64-dsym.tar.xz +5da55d91f5b4159f4bc6fa4831b995b621dcab6ef60d20d64aaf074dcba3943c *electron-v42.5.0-mas-x64-symbols.zip +b2c23925f3d6561dadc8f563576b7a222ea20606a8002986e5937bd066710870 *electron-v42.5.0-mas-x64.zip +d7cea2c82487a4af809c181c99cec529d0a00c86fcfc70684d9a2c5c0d295212 *electron-v42.5.0-win32-arm64-pdb.zip +2799cffe27425316d27336fc03d8c77581aa8e65e27964104da68d7780b1a040 *electron-v42.5.0-win32-arm64-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-arm64-toolchain-profile.zip +765370134fdd2dd851bb019d7b7d9379ed7087f288eb8bee4043b42cfd9c33d8 *electron-v42.5.0-win32-arm64.zip +d4b34eb9e4e1ac77f11a0be51d39e94548362d1df63f6b7675016e6cb7b9b904 *electron-v42.5.0-win32-ia32-pdb.zip +eaa1fa1bc6bb14f8877e8ec930b90d9cdbc9dc41765cf530f2c5e6aca1da1fe8 *electron-v42.5.0-win32-ia32-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-ia32-toolchain-profile.zip +aa040c36074046a723fca9173aac1c8bf70c19d8a156215e8dbeb17ea37f588b *electron-v42.5.0-win32-ia32.zip +615726602fd5d697c12fc9b66de28343919631b24ab1179d83da46508e8a87d4 *electron-v42.5.0-win32-x64-pdb.zip +a913f632a979b7b7fb964b4d43ad45976638a5673cec1b6a69fd6572f57adc0d *electron-v42.5.0-win32-x64-symbols.zip +8e245a0179165d88b82f382dda3f68d0851a2f4b21b14b833b9b7c4374d7da3a *electron-v42.5.0-win32-x64-toolchain-profile.zip +127bbf7a755b438612c076b22baee258a87cd3d07168cc82ea46ffc015936114 *electron-v42.5.0-win32-x64.zip +21ead4c9c384849887ca8cac0a2a7a683d0cf90a3b62a2a2ba8519224e16e249 *electron.d.ts +45d8022f3bd67995d90242c5176c832fdf3abbdaac575b2469ca0537d0eb58db *ffmpeg-v42.5.0-darwin-arm64.zip +3a5e8f19fd4aa38b3d31eeb7da40f0434e87703050912fc0ca9dbca2de06f0e2 *ffmpeg-v42.5.0-darwin-x64.zip +9efdc919ffd357f00f080a8b1e1baeea19dcfc3164ea02d5cd12b93f85e67b73 *ffmpeg-v42.5.0-linux-arm64.zip +2e24581796500f7d6ed0c16076d74b7fe0b7cf625844623535077df03886bc26 *ffmpeg-v42.5.0-linux-armv7l.zip +78e8d6fd7432e6cbf4ef024728b46aa0b7fa292a598fbbadcc42474f87380d42 *ffmpeg-v42.5.0-linux-x64.zip +28e44748d48b6168aefde8601225c5f3dfb9404d616de2e6035c634354e0a6ac *ffmpeg-v42.5.0-mas-arm64.zip +0bdb9e4050c4159935e8640d0facd6e868583d921386e9c2a5bf3c550c5baf3c *ffmpeg-v42.5.0-mas-x64.zip +81a1c0273c172f140fbf261c194d7ae7369a1e7abe27cae413c8590e2bca4cad *ffmpeg-v42.5.0-win32-arm64.zip +87cffd235b85029c21a1e962132b608209e9929d2a22611985fda317cfe7e95f *ffmpeg-v42.5.0-win32-ia32.zip +497790a714fffc090d217b66332dfe6896609aa24293dc3fb4effce57fe29c82 *ffmpeg-v42.5.0-win32-x64.zip +6db69261ec30c95e30cc3bcd526e8fcec59cd1ad785a7985e520f77bc974a03c *hunspell_dictionaries.zip +441e5c5d7ac9407438d1f2a9974bbc0ef1c3a28e9408051f577e790cd6c6d171 *libcxx-objects-v42.5.0-linux-arm64.zip +99a3a1f895debddf023f01631c2c2b81911992bbbc514718d2ad24599040e4da *libcxx-objects-v42.5.0-linux-armv7l.zip +c7e4c5c5ac5505f97a555cc21bd09014a2e740e5d25e999f2bdc7542a86256e0 *libcxx-objects-v42.5.0-linux-x64.zip +92cdadcc04957db6e7b65421790f77f56bc0c8e6dc1407ad1adbfbdf83dbbc06 *libcxx_headers.zip +a169c9558951055efc054d31c3b4c8a04aa588ef1533b4456b6b27950d121994 *libcxxabi_headers.zip +9611bdee31d4464a8b06e9d44e99adf290a898969a502cfc9ac5616ae1026a0c *mksnapshot-v42.5.0-darwin-arm64.zip +89f7e234802958438a115c96b2f6207153f776a13b07247947fd2e5ca9b1ce3d *mksnapshot-v42.5.0-darwin-x64.zip +2a6d92eb9eb89419dc3b2ab1287329acc16955a62833d32bda2bd90ded45a5b0 *mksnapshot-v42.5.0-linux-arm64-x64.zip +391263da6782b2536067113ce7dab8ef19d9b3d59a74430fef59a4d8a7364502 *mksnapshot-v42.5.0-linux-armv7l-x64.zip +f7752a92ad45041e0c00a74bdc10a0d13574849ad944ba2b3424dc255460465f *mksnapshot-v42.5.0-linux-x64.zip +b9425403b15e273be565d7a937c7d28fc09f421df9fa4d376d6932bfecc91ff5 *mksnapshot-v42.5.0-mas-arm64.zip +a7b7230be427b849f31467298c2c4a934c7273e731ea17513c5790c6d66c8205 *mksnapshot-v42.5.0-mas-x64.zip +4a32b6558fb9aaae703cdc2e6a87ce05e95184e6d0e49e350a956c0908724ef7 *mksnapshot-v42.5.0-win32-arm64-x64.zip +ca73fb8ef0d6e4cf92f569a0dee9770f13166e67be68b12688a7ecaa630a2163 *mksnapshot-v42.5.0-win32-ia32.zip +d8a484f4f3347d7fd73bc4af59ce743e90e14fe7743a1be5ea09452e9c3bbd5b *mksnapshot-v42.5.0-win32-x64.zip diff --git a/build/checksums/nodejs.txt b/build/checksums/nodejs.txt index 16e83546f49a19..a787f4a19a9f94 100644 --- a/build/checksums/nodejs.txt +++ b/build/checksums/nodejs.txt @@ -1,6 +1,6 @@ -372331b969779ab5d15b949884fc6eaf88d5afe87bde8ba881d6400b9100ffc4 node-v24.15.0-darwin-arm64.tar.gz -ffd5ee293467927f3ee731a553eb88fd1f48cf74eebc2d74a6babe4af228673b node-v24.15.0-darwin-x64.tar.gz -73afc234d558c24919875f51c2d1ea002a2ada4ea6f83601a383869fefa64eed node-v24.15.0-linux-arm64.tar.gz -44836872d9aec49f1e6b52a9a922872db9a2b02d235a616a5681b6a85fec8d89 node-v24.15.0-linux-x64.tar.gz -49a54c103f4919ce64199a043ef5cd309507de491d718085edee089cd8e87543 win-arm64/node.exe -3331e1ffe19874215472217c5e94f5a0c6d8e18c4ac7111d3937aa0ad5e9b4a5 win-x64/node.exe +4fc3266a3702eebc39cc37661cf4eeceeade307e242ab64e4d7ce7949197e11f node-v24.17.0-darwin-arm64.tar.gz +80da552fe037290cb130e9dea590f5eeeb7aa450636f0c89ab41415511c1ec27 node-v24.17.0-darwin-x64.tar.gz +faa0d59ba7fe7045c950ed09b190578fb8eee73e4358686d38fcc99ca58c1480 node-v24.17.0-linux-arm64.tar.gz +e0472427aa791ad80bdc426ff7cc73cdd28ed0f616d1ff9689a23a7f47f1265f node-v24.17.0-linux-x64.tar.gz +44999f9ec6486d01202d8961f343eac8c9f2847b234a8637c3fd0f1e2bb3288a win-arm64/node.exe +c6335d08331c23d68b9f2b18adb102002d76ef150b47248e954c507e0d033664 win-x64/node.exe diff --git a/build/darwin/create-universal-app.ts b/build/darwin/create-universal-app.ts index 7b6910a05e0e2c..7a301e19d89b1c 100644 --- a/build/darwin/create-universal-app.ts +++ b/build/darwin/create-universal-app.ts @@ -80,6 +80,12 @@ async function main(buildDir?: string) { crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(copilotExtensionNodeModules, '@github', 'copilot', 'tgrep', 'bin', plat)); } + for (const base of nodeModulesBases) { + for (const mxcArch of ['x64', 'arm64']) { + crossCopyPlatformDir(x64AppPath, arm64AppPath, path.join(base, '@microsoft', 'mxc-sdk', 'bin', mxcArch)); + } + } + const filesToSkip = [ '**/CodeResources', '**/Credits.rtf', @@ -121,6 +127,13 @@ async function main(buildDir?: string) { outAppPath, force: true, mergeASARs: true, + // Files that are unique to a single arch *inside* the merged `node_modules.asar`. + // Their on-disk (unpacked) copies are cross-copied between builds above, but the + // ASAR header still only references the target arch's package, so the merger sees + // them as arch-unique. Paths here are ASAR-internal (top level, no `node_modules` + // prefix). Over-covering is harmless: the allowlist is only consulted for files + // that are actually unique to one arch. + singleArchFiles: '{**/@github/copilot-darwin-*,**/@github/copilot-darwin-*/**,**/@github/copilot/prebuilds/darwin-*,**/@github/copilot/prebuilds/darwin-*/**,**/@github/copilot/tgrep/bin/darwin-*,**/@github/copilot/tgrep/bin/darwin-*/**,**/@github/copilot/sdk/tgrep/bin/darwin-*,**/@github/copilot/sdk/tgrep/bin/darwin-*/**,**/@github/copilot/sdk/prebuilds/darwin-*,**/@github/copilot/sdk/prebuilds/darwin-*/**,**/@github/copilot/sdk/ripgrep/bin/darwin-*,**/@github/copilot/sdk/ripgrep/bin/darwin-*/**,**/@vscode/ripgrep-universal/bin/darwin-*,**/@vscode/ripgrep-universal/bin/darwin-*/**,**/@microsoft/mxc-sdk/bin/*,**/@microsoft/mxc-sdk/bin/*/**}', x64ArchFiles: '{*/kerberos.node,**/extensions/microsoft-authentication/dist/libmsalruntime.dylib,**/extensions/microsoft-authentication/dist/msal-node-runtime.node,**/node_modules/@github/copilot-darwin-*/**,**/node_modules/@github/copilot/prebuilds/darwin-*/*,**/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot-darwin-*/**,**/node_modules.asar.unpacked/@github/copilot/prebuilds/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules.asar.unpacked/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/prebuilds/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/ripgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/sdk/tgrep/bin/darwin-*/*,**/extensions/copilot/node_modules/@github/copilot/tgrep/bin/darwin-*/*,**/node_modules/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules.asar.unpacked/@vscode/ripgrep-universal/bin/darwin-*/*,**/node_modules/@microsoft/mxc-sdk/bin/**,**/node_modules.asar.unpacked/@microsoft/mxc-sdk/bin/**}', filesToSkipComparison: (file: string) => { for (const expected of filesToSkip) { diff --git a/build/gulpfile.reh.ts b/build/gulpfile.reh.ts index caf85b4d72c3ba..a40ff6f6db1eb1 100644 --- a/build/gulpfile.reh.ts +++ b/build/gulpfile.reh.ts @@ -31,7 +31,7 @@ import log from 'fancy-log'; import buildfile from './buildfile.ts'; import { fetchUrls } from './lib/fetch.ts'; import { downloadFeedPackage } from './lib/azureFeed.ts'; -import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; @@ -448,6 +448,7 @@ function packageTask(type: string, platform: string, arch: string, sourceFolderN .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) .pipe(filter(getRipgrepExcludeFilter(platform, arch))) + .pipe(filter(getMxcExcludeFilter(arch))) .pipe(jsFilter) .pipe(util.stripSourceMappingURL()) .pipe(jsFilter.restore); diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 02750e9a856107..f3669ff6c17ce8 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -28,7 +28,7 @@ import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask, compileCopilotExtensionBuildTask } from './gulpfile.extensions.ts'; import { copyCodiconsTask } from './lib/compilation.ts'; -import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; +import { ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getCopilotTgrepExcludeFilter, getMxcExcludeFilter, getRipgrepExcludeFilter, prepareBuiltInCopilotRipgrepShim } from './lib/copilot.ts'; import { readAgentSdkResults } from './agent-sdk/common.ts'; import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; @@ -346,24 +346,47 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d .pipe(filter(getCopilotExcludeFilter(platform, arch))) .pipe(filter(getCopilotTgrepExcludeFilter(platform, arch))) .pipe(filter(getRipgrepExcludeFilter(platform, arch))) + .pipe(filter(getMxcExcludeFilter(arch))) .pipe(jsFilter) .pipe(util.rewriteSourceMappingURL(sourceMappingURLBase)) .pipe(jsFilter.restore) .pipe(createAsar(path.join(process.cwd(), 'node_modules'), [ '**/*.node', '**/@vscode/ripgrep-universal/bin/**', - '**/@github/copilot-*/**', + // Only the platform-specific Copilot CLI packages (`@github/copilot--`) + // need to be unpacked: the CLI is spawned as a subprocess and is a + // self-locating bundle that memory-maps files and resolves its native + // addons / sub-binaries relative to its own on-disk location, so it cannot + // run from inside the archive. `@github/copilot-sdk` is intentionally NOT + // matched here — it is pure JavaScript that the agent host loads via + // `import` (ASAR-aware), so it stays in the archive. + '**/@github/copilot-{darwin,linux,linuxmusl,win32}-*/**', + '**/@microsoft/mxc-sdk/bin/**', '**/node-pty/build/Release/*', '**/node-pty/build/Release/conpty/*', '**/node-pty/lib/worker/conoutSocketWorker.js', '**/node-pty/lib/shared/conout.js', + // node-pty spawns `conoutSocketWorker.js` as a Worker from the unpacked + // tree (Windows only). Unpack node-pty's `package.json` alongside it so + // Node finds a `package.json` without `"type": "module"` when walking up + // from the worker file. Otherwise the lookup reaches the app's own + // `package.json` (`"type": "module"`), the CommonJS worker is loaded as + // ESM and throws `exports is not defined`, the worker never signals ready, + // and node-pty blocks the pty host on `ConnectNamedPipe`. + '**/node-pty/package.json', '**/*.wasm', '**/@vscode/vsce-sign/bin/*', ], [ '**/*.mk', - '!node_modules/vsda/**' // stay compatible with extensions that depend on us shipping `vsda` into ASAR ], [ - 'node_modules/vsda/**' // retain copy of `vsda` in node_modules for internal use + 'node_modules/vsda/**', // retain copy of `vsda` in node_modules for internal use + // The sandbox runtime is spawned as a standalone Node subprocess (no ASAR + // resolution hook), so it and its transitive JS dependencies must remain as + // real files under `node_modules`. Keep them duplicated out of the archive. + 'node_modules/@vscode/sandbox-runtime/**', // includes its nested `commander` + 'node_modules/@pondwader/socks5-server/**', + 'node_modules/shell-quote/**', + 'node_modules/zod/**' ], 'node_modules.asar')); const mergeStreams = [ @@ -612,7 +635,7 @@ function prepareCopilotRipgrepShimTask(platform: string, arch: string, destinati const appBase = platform === 'darwin' ? path.join(outputDir, `${product.nameLong}.app`, 'Contents', 'Resources', 'app') : path.join(outputDir, versionedResourcesFolder, 'resources', 'app'); - const appNodeModulesDir = path.join(appBase, 'node_modules'); + const appNodeModulesDir = path.join(appBase, 'node_modules.asar.unpacked'); const builtInCopilotExtensionDir = path.join(appBase, 'extensions', 'copilot'); prepareBuiltInCopilotRipgrepShim(platform, arch, builtInCopilotExtensionDir, appNodeModulesDir); diff --git a/build/lib/copilot.ts b/build/lib/copilot.ts index 37faf2b0666312..645ac0487dde21 100644 --- a/build/lib/copilot.ts +++ b/build/lib/copilot.ts @@ -66,6 +66,8 @@ const copilotTgrepPlatforms = [ 'win32-arm64', 'win32-x64', ]; +const mxcArchitectures = ['x64', 'arm64']; + function toCopilotTgrepPlatformArch(platform: string, arch: string): string { if (platform === 'alpine') { return `linuxmusl-${arch}`; @@ -114,6 +116,23 @@ function getCopilotOptionalNativePayloadFiles(platform: string): string[] { return files; } +/** + * Returns a glob filter that strips @microsoft/mxc-sdk `bin/` payload for + * architectures other than the build target. `@microsoft/mxc-sdk` ships a full + * set of sandbox binaries for every architecture under `bin//`; only the + * build target's architecture is needed. Architectures that mxc-sdk does not + * ship (e.g. armhf) strip every `bin/` directory. + */ +export function getMxcExcludeFilter(arch: string): string[] { + const target = mxcArchitectures.includes(arch) ? arch : undefined; + const nonTargetArchitectures = mxcArchitectures.filter(a => a !== target); + + return [ + '**', + ...nonTargetArchitectures.map(a => `!**/node_modules/@microsoft/mxc-sdk/bin/${a}/**`), + ]; +} + /** * Returns a glob filter that strips @vscode/ripgrep-universal bin directories * for architectures other than the build target. diff --git a/build/lib/electron.ts b/build/lib/electron.ts index e229b875f552c7..f9236eaf47235e 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -102,8 +102,7 @@ function darwinBundleDocumentTypes(types: { [name: string]: string | string[] }, }); } -const { msBuildId } = util.getElectronVersion(); -export const electronVersion = '42.2.0'; +const { electronVersion, msBuildId } = util.getElectronVersion(); // In product builds, `@vscode/gulp-electron` is given an asset resolver (via the // `repo` option) that fetches the prebuilt Electron archives on demand from the diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index 3e37d158ec709d..b1dbfbbdc8ac9f 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -644,6 +644,10 @@ "name": "vs/sessions", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/automations", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/accountMenu", "project": "vscode-sessions" @@ -676,6 +680,10 @@ "name": "vs/sessions/contrib/chat", "project": "vscode-sessions" }, + { + "name": "vs/sessions/contrib/promptTimeline", + "project": "vscode-sessions" + }, { "name": "vs/sessions/contrib/providers/copilotChatSessions", "project": "vscode-sessions" diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 2c545a0a122d41..f82aff27a42d7d 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -708,7 +708,7 @@ "localization": { "description": { "key": "updateMode", - "value": "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service." + "value": "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service." }, "enumDescriptions": [ { diff --git a/build/lib/preLaunch.ts b/build/lib/preLaunch.ts index df9ef7738c6e84..0aa037e6969065 100644 --- a/build/lib/preLaunch.ts +++ b/build/lib/preLaunch.ts @@ -48,7 +48,8 @@ async function getElectron() { async function isExpectedElectronInstalled(): Promise { try { - const { electronVersion } = await import('./electron.ts'); + const { getElectronVersion } = await import('./util.ts'); + const { electronVersion } = getElectronVersion(); const installedVersion = (await fs.readFile(path.join(rootDir, '.build', 'electron', 'version'), 'utf8')).trim().replace(/^v/, ''); return installedVersion === electronVersion; } catch { diff --git a/build/lib/stylelint/validateDesignTokens.ts b/build/lib/stylelint/validateDesignTokens.ts index 91fd26d361edfe..01c1a51ff0ddd2 100644 --- a/build/lib/stylelint/validateDesignTokens.ts +++ b/build/lib/stylelint/validateDesignTokens.ts @@ -121,12 +121,12 @@ export function validateCodiconFontSizes(text: string): IDesignTokenViolation[] /** Exact px value -> suggested token var(s). Ambiguous values list alternatives. */ const FONT_SIZE_RAMP: ReadonlyMap = new Map([ - [26, 'var(--vscode-agents-fontSize-heading1)'], - [18, 'var(--vscode-agents-fontSize-heading2)'], - [13, 'var(--vscode-bodyFontSize) or var(--vscode-agents-fontSize-heading3) or var(--vscode-agents-fontSize-body1)'], - [12, 'var(--vscode-bodyFontSize-small) or var(--vscode-agents-fontSize-label1)'], - [11, 'var(--vscode-bodyFontSize-xSmall) or var(--vscode-agents-fontSize-body2) or var(--vscode-agents-fontSize-label2)'], - [10, 'var(--vscode-agents-fontSize-label3)'], + [26, 'var(--vscode-fontSize-heading1)'], + [18, 'var(--vscode-fontSize-heading2)'], + [13, 'var(--vscode-fontSize-body1) or var(--vscode-fontSize-heading3)'], + [12, 'var(--vscode-fontSize-label1)'], + [11, 'var(--vscode-fontSize-body2) or var(--vscode-fontSize-label2)'], + [10, 'var(--vscode-fontSize-label3)'], ]); /** @@ -255,7 +255,7 @@ export function validateCornerRadiusTokens(text: string): IDesignTokenViolation[ // Font-weight token suggestions (sessions design-system area only) // --------------------------------------------------------------------------- // -// The agents font ramp defines exactly two weights: regular (400) and +// The font ramp defines exactly two weights: regular (400) and // semiBold (600). Any other numeric weight (e.g. 500, 700) is off the ramp and // snaps to the nearer of the two. The CSS keywords `normal` (400) and `bold` // (700) are normalised before snapping. `inherit`, `lighter`, `bolder` and @@ -268,7 +268,7 @@ interface IFontWeightToken { readonly name: string; } -/** The only two weights in the agents ramp. */ +/** The only two weights in the font ramp. */ const FONT_WEIGHT_TOKENS: readonly IFontWeightToken[] = [ { weight: 400, name: 'regular' }, { weight: 600, name: 'semiBold' }, @@ -303,7 +303,7 @@ function snapFontWeight(weight: number): IFontWeightToken { } /** - * Finds hardcoded `font-weight` values and suggests the agents weight-ramp var. + * Finds hardcoded `font-weight` values and suggests the weight-ramp var. * Exact ramp values (400 / 600, plus `normal` = 400) are flagged as drop-in * replacements; off-ramp values (e.g. 500, 700, `bold`) snap to the nearer * token and call out that the value is off the two-weight ramp. `inherit`, @@ -332,7 +332,7 @@ export function validateFontWeightTokens(text: string): IDesignTokenViolation[] const note = exact ? '' : ' (off-ramp, 400/600 only)'; violations.push({ line, - message: `${shown} -> var(--vscode-agents-fontWeight-${token.name})${note}` + message: `${shown} -> var(--vscode-fontWeight-${token.name})${note}` }); }); @@ -475,3 +475,55 @@ export function validateStrokeTokens(text: string): IDesignTokenViolation[] { return violations; } + +// --------------------------------------------------------------------------- +// Deprecated token usage +// --------------------------------------------------------------------------- +// +// Some `--vscode-*` size tokens have been superseded by more generic ones and +// are marked `@deprecated` in the size registry. Any remaining usage of a +// deprecated token var is reported with its drop-in replacement, regardless of +// which property it appears in. + +interface IDeprecatedToken { + readonly deprecated: string; + readonly replacement: string; +} + +/** Deprecated token var -> its replacement. */ +const DEPRECATED_TOKENS: readonly IDeprecatedToken[] = [ + { deprecated: '--vscode-bodyFontSize', replacement: '--vscode-fontSize-body1' }, + { deprecated: '--vscode-bodyFontSize-small', replacement: '--vscode-fontSize-label1' }, + { deprecated: '--vscode-bodyFontSize-xSmall', replacement: '--vscode-fontSize-body2' }, +]; + +// Longest-first so a suffixed token (e.g. `-small`) matches before the base +// token that is its prefix (`--vscode-bodyFontSize`). +const DEPRECATED_TOKENS_SORTED = [...DEPRECATED_TOKENS].sort((a, b) => b.deprecated.length - a.deprecated.length); + +/** + * Finds usages of deprecated token vars and suggests the replacement token. + * A match is only reported when the token is not the prefix of a longer token + * (the next character does not continue the identifier). Returns one finding + * per occurrence so the terminal can linkify each `file(line,col)`. + */ +export function validateDeprecatedTokens(text: string): IDesignTokenViolation[] { + const violations: IDesignTokenViolation[] = []; + + forEachDeclaration(text, (line, _selector, declaration) => { + for (const { deprecated, replacement } of DEPRECATED_TOKENS_SORTED) { + for (let idx = declaration.indexOf(deprecated); idx !== -1; idx = declaration.indexOf(deprecated, idx + deprecated.length)) { + const nextChar = declaration[idx + deprecated.length] ?? ''; + if (/[\w-]/.test(nextChar)) { + continue; // prefix of a longer token + } + violations.push({ + line, + message: `${deprecated} -> ${replacement} (deprecated)` + }); + } + } + }); + + return violations; +} diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index 7d6bef2b41b942..4346e1d8d47fed 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -955,6 +955,7 @@ "--session-view-background", "--session-view-foreground", "--session-view-centered-content-max-width", + "--prompt-timeline-bottom", "--chat-editing-last-edit-shift", "--chat-voice-icon-glow-color", "--chat-current-response-min-height", @@ -1067,6 +1068,9 @@ "--chat-input-anim-angle", "--chat-input-anim-duration", "--chat-input-working-border-color1", + "--session-input-banner-anim-angle", + "--session-input-banner-anim-duration", + "--session-input-banner-working-border-color", "--chat-send-button-anim-angle", "--chat-setup-dialog-glow-angle", "--vscode-chat-font-family", @@ -1110,6 +1114,16 @@ "--vscode-cornerRadius-small", "--vscode-cornerRadius-xLarge", "--vscode-cornerRadius-xSmall", + "--vscode-fontSize-body1", + "--vscode-fontSize-body2", + "--vscode-fontSize-heading1", + "--vscode-fontSize-heading2", + "--vscode-fontSize-heading3", + "--vscode-fontSize-label1", + "--vscode-fontSize-label2", + "--vscode-fontSize-label3", + "--vscode-fontWeight-regular", + "--vscode-fontWeight-semiBold", "--vscode-keyboard-height", "--vscode-spacing-sizeNone", "--vscode-spacing-size20", diff --git a/build/lib/test/copilot.test.ts b/build/lib/test/copilot.test.ts index b0f6b4f18a46ae..71bc408f71a87f 100644 --- a/build/lib/test/copilot.test.ts +++ b/build/lib/test/copilot.test.ts @@ -9,7 +9,7 @@ import * as os from 'os'; import * as path from 'path'; import { suite, test } from 'node:test'; import { create } from 'tar'; -import { copilotPlatforms, ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, prepareBuiltInCopilotRipgrepShim } from '../copilot.ts'; +import { copilotPlatforms, ensureCopilotPlatformPackage, getCopilotExcludeFilter, getCopilotRuntimePrebuildFiles, getMxcExcludeFilter, prepareBuiltInCopilotRipgrepShim } from '../copilot.ts'; suite('copilot', () => { test('keeps the public copilot platform package include list scoped to the selected package', () => { @@ -215,6 +215,34 @@ suite('copilot', () => { ] ); }); + + test('keeps only the target architecture of @microsoft/mxc-sdk', () => { + assert.deepStrictEqual( + getMxcExcludeFilter('x64'), + [ + '**', + '!**/node_modules/@microsoft/mxc-sdk/bin/arm64/**', + ] + ); + assert.deepStrictEqual( + getMxcExcludeFilter('arm64'), + [ + '**', + '!**/node_modules/@microsoft/mxc-sdk/bin/x64/**', + ] + ); + }); + + test('strips every @microsoft/mxc-sdk architecture for unsupported armhf builds', () => { + assert.deepStrictEqual( + getMxcExcludeFilter('armhf'), + [ + '**', + '!**/node_modules/@microsoft/mxc-sdk/bin/x64/**', + '!**/node_modules/@microsoft/mxc-sdk/bin/arm64/**', + ] + ); + }); }); function assertCopilotPlatformPackageIncludes(patterns: string[], packageDir: string, relativeFiles: string[]): void { diff --git a/build/linux/debian/dep-lists.ts b/build/linux/debian/dep-lists.ts index 96e394f86bb207..d7bd81ccd104d9 100644 --- a/build/linux/debian/dep-lists.ts +++ b/build/linux/debian/dep-lists.ts @@ -40,6 +40,7 @@ export const referenceGeneratedDepsByArch = { 'libdbus-1-3 (>= 1.9.14)', 'libexpat1 (>= 2.1~beta3)', 'libgbm1 (>= 17.1.0~rc2)', + 'libglib2.0-0 (>= 2.12.0)', 'libglib2.0-0 (>= 2.39.4)', 'libgtk-3-0 (>= 3.9.10)', 'libgtk-3-0 (>= 3.9.10) | libgtk-4-1', diff --git a/build/linux/dependencies-generator.ts b/build/linux/dependencies-generator.ts index eb1d73d011ac9a..e79a3b5ad99cde 100644 --- a/build/linux/dependencies-generator.ts +++ b/build/linux/dependencies-generator.ts @@ -22,7 +22,7 @@ import product from '../../product.json' with { type: 'json' }; // are valid, are in dep-lists.ts const FAIL_BUILD_FOR_NEW_DEPENDENCIES: boolean = true; -// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.97:chrome/installer/linux/BUILD.gn;l=64-80 +// Based on https://source.chromium.org/chromium/chromium/src/+/refs/tags/148.0.7778.271:chrome/installer/linux/BUILD.gn;l=64-80 // and the Linux Archive build // Shared library dependencies that we already bundle. const bundledDeps = [ @@ -44,8 +44,8 @@ export async function getDependencies(packageType: 'deb' | 'rpm', buildDir: stri } // Get the files for which we want to find dependencies. - const canAsar = false; // TODO@esm ASAR disabled in ESM - const nativeModulesPath = path.join(buildDir, 'resources', 'app', canAsar ? 'node_modules.asar.unpacked' : 'node_modules'); + // Native modules are unpacked next to the ASAR archive in `node_modules.asar.unpacked`. + const nativeModulesPath = path.join(buildDir, 'resources', 'app', 'node_modules.asar.unpacked'); const findResult = spawnSync('find', [nativeModulesPath, '-name', '*.node']); if (findResult.status) { console.error('Error finding files:'); diff --git a/build/npm/gyp/package-lock.json b/build/npm/gyp/package-lock.json index e9d3b2b28c30bb..80ef060b7e95ef 100644 --- a/build/npm/gyp/package-lock.json +++ b/build/npm/gyp/package-lock.json @@ -1069,9 +1069,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.19", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz", + "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/package-lock.json b/build/package-lock.json index 12162d6054f94c..4271a5f06a58b1 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -1320,28 +1320,28 @@ } }, "node_modules/@textlint/ast-node-types": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.2.2.tgz", - "integrity": "sha512-9ByYNzWV8tpz6BFaRzeRzIov8dkbSZu9q7IWqEIfmRuLWb2qbI/5gTvKcoWT1HYs4XM7IZ8TKSXcuPvMb6eorA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.2.2.tgz", - "integrity": "sha512-oMVaMJ3exFvXhCj3AqmCbLaeYrTNLqaJnLJMIlmnRM3/kZdxvku4OYdaDzgtlI194cVxamOY5AbHBBVnY79kEg==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "15.2.2", - "@textlint/resolver": "15.2.2", - "@textlint/types": "15.2.2", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", "chalk": "^4.1.2", - "debug": "^4.4.1", - "js-yaml": "^3.14.1", - "lodash": "^4.17.21", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -1456,27 +1456,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.2.2.tgz", - "integrity": "sha512-2rmNcWrcqhuR84Iio1WRzlc4tEoOMHd6T7urjtKNNefpTt1owrTJ9WuOe60yD3FrTW0J/R0ux5wxUbP/eaeFOA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.2.2.tgz", - "integrity": "sha512-4hGWjmHt0y+5NAkoYZ8FvEkj8Mez9TqfbTm3BPjoV32cIfEixl2poTOgapn1rfm73905GSO3P1jiWjmgvii13Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "15.2.2", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.2.2.tgz", - "integrity": "sha512-X2BHGAR3yXJsCAjwYEDBIk9qUDWcH4pW61ISfmtejau+tVqKtnbbvEZnMTb6mWgKU1BvTmftd5DmB1XVDUtY3g==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "15.2.2" + "@textlint/ast-node-types": "15.7.1" } }, "node_modules/@types/ansi-colors": { @@ -2408,21 +2408,11 @@ } }, "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/argparse/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true, - "license": "BSD-3-Clause" + "license": "Python-2.0" }, "node_modules/arr-diff": { "version": "4.0.0", @@ -3403,20 +3393,6 @@ "node": ">=0.8.0" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/events": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/events/-/events-3.2.0.tgz", @@ -3642,17 +3618,17 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4004,9 +3980,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -4358,14 +4334,23 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" @@ -4509,10 +4494,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" @@ -4609,15 +4604,25 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -4626,13 +4631,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/markdown-it/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -5488,26 +5486,6 @@ "require-from-string": "^2.0.2" } }, - "node_modules/rc-config-loader/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/rc-config-loader/node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index c125386c34cf6f..5631e280b28810 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -13,38 +13,29 @@ "@vscode/component-explorer-webpack-plugin": "^0.3.1-53" }, "devDependencies": { - "@rspack/cli": "^1.3.18", - "@rspack/core": "^1.3.18", + "@rspack/cli": "^2.1.2", + "@rspack/core": "^2.1.2", + "@rspack/dev-server": "^2.1.0", "@vscode/esm-url-webpack-plugin": "^1.0.1-3", "source-map-loader": "^5.0.0" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", "dev": true, "license": "MIT", "optional": true, @@ -53,9 +44,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", "dev": true, "license": "MIT", "optional": true, @@ -65,8 +56,6 @@ }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "license": "MIT", "peer": true, @@ -77,8 +66,6 @@ }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "license": "MIT", "peer": true, @@ -88,8 +75,6 @@ }, "node_modules/@jridgewell/source-map": { "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", "dev": true, "license": "MIT", "peer": true, @@ -100,16 +85,12 @@ }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT", "peer": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, "license": "MIT", "peer": true, @@ -118,3627 +99,982 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" }, "funding": { "type": "github", - "url": "https://github.com/sponsors/streamich" + "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "tslib": "2" + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, - "node_modules/@jsonjoy.com/buffers": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", - "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "node_modules/@rspack/binding": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-2.1.2.tgz", + "integrity": "sha512-/mFcRSUW7Pl19KeaBIujJvZYNJQu0wD5D3aa5h+Qcph26v7nmLYlX7eajIHGi8tt2qTZX1lXifw2KLIXKwYaRQ==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "license": "MIT", + "optionalDependencies": { + "@rspack/binding-darwin-arm64": "2.1.2", + "@rspack/binding-darwin-x64": "2.1.2", + "@rspack/binding-linux-arm64-gnu": "2.1.2", + "@rspack/binding-linux-arm64-musl": "2.1.2", + "@rspack/binding-linux-riscv64-gnu": "2.1.2", + "@rspack/binding-linux-riscv64-musl": "2.1.2", + "@rspack/binding-linux-x64-gnu": "2.1.2", + "@rspack/binding-linux-x64-musl": "2.1.2", + "@rspack/binding-wasm32-wasi": "2.1.2", + "@rspack/binding-win32-arm64-msvc": "2.1.2", + "@rspack/binding-win32-ia32-msvc": "2.1.2", + "@rspack/binding-win32-x64-msvc": "2.1.2" } }, - "node_modules/@jsonjoy.com/codegen": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "node_modules/@rspack/binding-darwin-arm64": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-2.1.2.tgz", + "integrity": "sha512-IYcxareUOYJZz+uNMSIwn+iDRiVyjZNOjoxO/zL4OFaPK8Ncrw0ka/9DqL9Gd7OpnAXN1zK3uS8yD0O1yIYI3Q==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rspack/binding-darwin-x64": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-2.1.2.tgz", + "integrity": "sha512-aoifkILvx/XEHyvg8yW57xu95nx7f9f/3ah1+RguHSNKcJMcoCep9VX1Ct1N0ftqg8MC0JUObc7xWL5W14hmjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] }, - "node_modules/@jsonjoy.com/fs-core": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.1.tgz", - "integrity": "sha512-YrEi/ZPmgc+GfdO0esBF04qv8boK9Dg9WpRQw/+vM8Qt3nnVIJWIa8HwZ/LXVZ0DB11XUROM8El/7yYTJX+WtA==", + "node_modules/@rspack/binding-linux-arm64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-2.1.2.tgz", + "integrity": "sha512-My4m40tyJSgiCEf3bB2KIEX710q3nZg99LIjy+8Zxgi3oZTkg1bFmFRusFU5U4eN5408zfSqDDGvjDE3Yv7o4w==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-arm64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-2.1.2.tgz", + "integrity": "sha512-yt+GGWUH7WPE8K97cRc8OpZhH7Pbj1vU+lkvKbDtF/rR8X9a/bJsA/nBqyUV2oBKOVbrp5I8rFZlnDskMqgvKw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-riscv64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-2.1.2.tgz", + "integrity": "sha512-uys8Jyw8Z3ralvICbN/L/nZfy5qELIwpOY72rhIqhoDYwFcL4fmMaY7WsvUcJOjCB2rqOcWPaWKuF2oPvo9iDQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-riscv64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-2.1.2.tgz", + "integrity": "sha512-JYNVQwqCaRGQWvjHQYzZkIzQiwllMaJwh4Rdu3ww6W2OJcJUqT08sL1pkOtU0iCxT4VUYiRRcp93VGTGpHr8fg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-gnu": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-2.1.2.tgz", + "integrity": "sha512-KDoPy0Msf/JLhxgPPrJQzZeB4Qpqd32em8AP5lSW2s6jR5I35dHgAe9xc2A++EQtnSrU4GTn6DBvFC7q84SihQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-linux-x64-musl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-2.1.2.tgz", + "integrity": "sha512-66hWmIGvn4zCKAYXJE9Bp5SNSLYnLFq2Ke/efE+ZtWy43Dd5vk9AAOmThVGBwdwmIxmGtHGCp+cAuS4G0wu0TA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rspack/binding-wasm32-wasi": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-2.1.2.tgz", + "integrity": "sha512-EB4SqH8DW/E/OmqssNQvnIVGQiVUyYNlA/pcc6Ia4MlTNwu6eNDppcNLrToH+kSZpL4CpHSFfSM3eIsSuar2Rw==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "1.1.6" } }, - "node_modules/@jsonjoy.com/fs-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.1.tgz", - "integrity": "sha512-ooEPvSW/HQDivPDPZMibHGKZf/QS4WRir1czGZmXmp3MsQqLECZEpN0JobrD8iV9BzsuwdIv+PxtWX9WpPLsIA==", + "node_modules/@rspack/binding-win32-arm64-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-2.1.2.tgz", + "integrity": "sha512-T6Fs/g32MRja/UpCq4AdyPRj8tA0cOkcEa4PrAcn/ztUgK8b/qMVxj5mhMI+n7k+kHZQnpeB1Q4HqdSJi6OocA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "thingies": "^2.5.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-ia32-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-2.1.2.tgz", + "integrity": "sha512-OtxkFVz14mVL4QK8QriSELn9B6PaYGHw1jGJwVDEzpu2ZxSHCTQPz9dVE1ekYtREEqZUkRU7Fp7VfhJSmjTt2Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/binding-win32-x64-msvc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-2.1.2.tgz", + "integrity": "sha512-Am+nx9fLF3nzgD/K05Bs1Bb+WO8SFLWAYRbXkymaL1r+RQxjRj7jd5ap2PhGOCcfaNA4yVWkAFvmFP92eRu7bQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rspack/cli": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-2.1.2.tgz", + "integrity": "sha512-GTBsKuaeqDii6NaGPkQwvycMSjqMUMMRGiAwH0sXsFIn8X7sFa4xpoL7pIhbebVBDGzYhUBqWzNQFuL5u2FeoQ==", + "dev": true, + "license": "MIT", + "bin": { + "rspack": "bin/rspack.js" }, "peerDependencies": { - "tslib": "2" + "@rspack/core": "^2.0.0-0", + "@rspack/dev-server": "^2.0.0-0" + }, + "peerDependenciesMeta": { + "@rspack/dev-server": { + "optional": true + } } }, - "node_modules/@jsonjoy.com/fs-node": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.1.tgz", - "integrity": "sha512-3YaKhP8gXEKN+2O49GLNfNb5l2gbnCFHyAaybbA2JkkbQP3dpdef7WcUaHAulg/c5Dg4VncHsA3NWAUSZMR5KQ==", + "node_modules/@rspack/core": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@rspack/core/-/core-2.1.2.tgz", + "integrity": "sha512-crpNQKhHfnzrIl4Sa4fjH30Ho5aAPgyqpmJZ41SkUFOzyKHdZKYfE5LF3CMh7MiFQFPPxiiKf5BcpxmtZZx4MQ==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", - "glob-to-regex.js": "^1.0.0", - "thingies": "^2.5.0" + "@rspack/binding": "2.1.2" }, "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "tslib": "2" + "@module-federation/runtime-tools": "^0.24.1 || ^2.0.0", + "@swc/helpers": "^0.5.23" + }, + "peerDependenciesMeta": { + "@module-federation/runtime-tools": { + "optional": true + }, + "@swc/helpers": { + "optional": true + } } }, - "node_modules/@jsonjoy.com/fs-node-builtins": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.1.tgz", - "integrity": "sha512-XHkFKQ5GSH3uxm8c3ZYXVrexGdscpWKIcMWKFQpMpMJc8gA3AwOMBJXJlgpdJqmrhPyQXxaY9nbkNeYpacC0Og==", + "node_modules/@rspack/dev-middleware": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@rspack/dev-middleware/-/dev-middleware-2.0.3.tgz", + "integrity": "sha512-GxnGj9jy76G3eCPyZei81fwKLAMLZaPEEqFz1/QDYquhwi/qYZX5fekFJ1XVpuwxGEK9KSX3hxZylfwrs4cmLA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "tslib": "2" + "@rspack/core": "^2.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + } } }, - "node_modules/@jsonjoy.com/fs-node-to-fsa": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.1.tgz", - "integrity": "sha512-pqGHyWWzNck4jRfaGV39hkqpY5QjRUQ/nRbNT7FYbBa0xf4bDG+TE1Gt2KWZrSkrkZZDE3qZUjYMbjwSliX6pg==", + "node_modules/@rspack/dev-server": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-2.1.0.tgz", + "integrity": "sha512-WkCi6bWThVX5Ziv04srPaRoCoUY5FJolO4gqzE7xPO0XbXShsGnwn0vGD0DFfnYFcw9VSsxlmeCDV799lNYclA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "dependencies": { - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1" + "@rspack/dev-middleware": "^2.0.3" }, "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "tslib": "2" + "@rspack/core": "^2.0.0", + "selfsigned": "^5.0.0" + }, + "peerDependenciesMeta": { + "selfsigned": { + "optional": true + } } }, - "node_modules/@jsonjoy.com/fs-node-utils": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.1.tgz", - "integrity": "sha512-vp+7ZzIB8v43G+GLXTS4oDUSQmhAsRz532QmmWBbdYA20s465JvwhkSFvX9cVTqRRAQg+vZ7zWDaIEh0lFe2gw==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "optional": true, "dependencies": { - "@jsonjoy.com/fs-node-builtins": "4.57.1" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "tslib": "^2.4.0" } }, - "node_modules/@jsonjoy.com/fs-print": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.1.tgz", - "integrity": "sha512-Ynct7ZJmfk6qoXDOKfpovNA36ITUx8rChLmRQtW08J73VOiuNsU8PB6d/Xs7fxJC2ohWR3a5AqyjmLojfrw5yw==", + "node_modules/@types/eslint": { + "version": "9.6.1", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "peer": true, "dependencies": { - "@jsonjoy.com/fs-node-utils": "4.57.1", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "@types/estree": "*", + "@types/json-schema": "*" } }, - "node_modules/@jsonjoy.com/fs-snapshot": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.1.tgz", - "integrity": "sha512-/oG8xBNFMbDXTq9J7vepSA1kerS5vpgd3p5QZSPd+nX59uwodGJftI51gDYyHRpP57P3WCQf7LHtBYPqwUg2Bg==", + "node_modules/@types/eslint-scope": { + "version": "3.7.7", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "peer": true, "dependencies": { - "@jsonjoy.com/buffers": "^17.65.0", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/json-pack": "^17.65.0", - "@jsonjoy.com/util": "^17.65.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", - "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", - "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", - "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "17.67.0", - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0", - "@jsonjoy.com/json-pointer": "17.67.0", - "@jsonjoy.com/util": "17.67.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", - "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/util": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { - "version": "17.67.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", - "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "17.67.0", - "@jsonjoy.com/codegen": "17.67.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", - "@jsonjoy.com/util": "^1.9.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/util": "^1.9.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0", - "@jsonjoy.com/codegen": "^1.0.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/error-codes": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/error-codes/-/error-codes-0.22.0.tgz", - "integrity": "sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime/-/runtime-0.22.0.tgz", - "integrity": "sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/runtime-core": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-core": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-core/-/runtime-core-0.22.0.tgz", - "integrity": "sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/error-codes": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@module-federation/runtime-tools": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/runtime-tools/-/runtime-tools-0.22.0.tgz", - "integrity": "sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/webpack-bundler-runtime": "0.22.0" - } - }, - "node_modules/@module-federation/sdk": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/sdk/-/sdk-0.22.0.tgz", - "integrity": "sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@module-federation/webpack-bundler-runtime": { - "version": "0.22.0", - "resolved": "https://registry.npmjs.org/@module-federation/webpack-bundler-runtime/-/webpack-bundler-runtime-0.22.0.tgz", - "integrity": "sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime": "0.22.0", - "@module-federation/sdk": "0.22.0" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz", - "integrity": "sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.5.0", - "@emnapi/runtime": "^1.5.0", - "@tybys/wasm-util": "^0.10.1" - } - }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rspack/binding": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding/-/binding-1.7.10.tgz", - "integrity": "sha512-j+DPEaSJLRgasxXNpYQpvC7wUkQF5WoWPiTfm4fLczwlAmYwGSVkJiyWDrOlvVPiGGYiXIaXEjVWTw6fT6/vnA==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "@rspack/binding-darwin-arm64": "1.7.10", - "@rspack/binding-darwin-x64": "1.7.10", - "@rspack/binding-linux-arm64-gnu": "1.7.10", - "@rspack/binding-linux-arm64-musl": "1.7.10", - "@rspack/binding-linux-x64-gnu": "1.7.10", - "@rspack/binding-linux-x64-musl": "1.7.10", - "@rspack/binding-wasm32-wasi": "1.7.10", - "@rspack/binding-win32-arm64-msvc": "1.7.10", - "@rspack/binding-win32-ia32-msvc": "1.7.10", - "@rspack/binding-win32-x64-msvc": "1.7.10" - } - }, - "node_modules/@rspack/binding-darwin-arm64": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.7.10.tgz", - "integrity": "sha512-bsXi7I6TpH+a4L6okIUh1JDvwT+XcK/L7Yvhu5G2t5YYyd2fl5vMM5O9cePRpEb0RdqJZ3Z8i9WIWHap9aQ8Gw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rspack/binding-darwin-x64": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.7.10.tgz", - "integrity": "sha512-h/kOGL1bUflDDYnbiUjaRE9kagJpour4FatGihueV03+cRGQ6jpde+BjUakqzMx65CeDbeYI6jAiPhElnlAtRw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rspack/binding-linux-arm64-gnu": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.7.10.tgz", - "integrity": "sha512-Z4reus7UxGM4+JuhiIht8KuGP1KgM7nNhOlXUHcQCMswP/Rymj5oJQN3TDWgijFUZs09ULl8t3T+AQAVTd/WvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-arm64-musl": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.7.10.tgz", - "integrity": "sha512-LYaoVmWizG4oQ3g+St3eM5qxsyfH07kLirP7NJcDMgvu3eQ29MeyTZ3ugkgW6LvlmJue7eTQyf6CZlanoF5SSg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-x64-gnu": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.7.10.tgz", - "integrity": "sha512-aIm2G4Kcm3qxDTNqKarK0oaLY2iXnCmpRQQhAcMlR0aS2LmxL89XzVeRr9GFA1MzGrAsZONWCLkxQvn3WUbm4Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-linux-x64-musl": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.7.10.tgz", - "integrity": "sha512-SIHQbAgB9IPH0H3H+i5rN5jo9yA/yTMq8b7XfRkTMvZ7P7MXxJ0dE8EJu3BmCLM19sqnTc2eX+SVfE8ZMDzghA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rspack/binding-wasm32-wasi": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.7.10.tgz", - "integrity": "sha512-J9HDXHD1tj+9FmX4+K3CTkO7dCE2bootlR37YuC2Owc0Lwl1/i2oGT71KHnMqI9faF/hipAaQM5OywkiiuNB7w==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "1.0.7" - } - }, - "node_modules/@rspack/binding-win32-arm64-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.7.10.tgz", - "integrity": "sha512-FaQGSCXH89nMOYW0bVp0bKQDQbrOEFFm7yedla7g6mkWlFVQo5UyBxid5wJUCqGJBtJepRxeRfByWiaI5nVGvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/binding-win32-ia32-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.7.10.tgz", - "integrity": "sha512-/66TNLOeM4R5dHhRWRVbMTgWghgxz+32ym0c/zGGXQRoMbz7210EoL40ALUgdBdeeREO8LoV+Mn7v8/QZCwHzw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/binding-win32-x64-msvc": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.7.10.tgz", - "integrity": "sha512-SUa3v1W7PGFCy6AHRmDsm43/tkfaZFi1TN2oIk5aCdT9T51baDVBjAbehRDu9xFbK4piL3k7uqIVSIrKgVqk1g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rspack/cli": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.11.tgz", - "integrity": "sha512-vUnflkq4F654wTEpCd+L4RYVbet8L2lNqLMmAGIZvoZddlXm4Duvg+eqcFE9iF8plAjFflRcU7DhB7WZa76pwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.7", - "@rspack/dev-server": "~1.1.5", - "exit-hook": "^4.0.0", - "webpack-bundle-analyzer": "4.10.2" - }, - "bin": { - "rspack": "bin/rspack.js" - }, - "peerDependencies": { - "@rspack/core": "^1.0.0-alpha || ^1.x" - } - }, - "node_modules/@rspack/core": { - "version": "1.7.10", - "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.10.tgz", - "integrity": "sha512-dO7J0aHSa9Fg2kGT0+ZsM500lMdlNIyCHavIaz7dTDn6KXvFz1qbWQ/48x3OlNFw1mA0jxAjjw9e7h3sWQZUNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@module-federation/runtime-tools": "0.22.0", - "@rspack/binding": "1.7.10", - "@rspack/lite-tapable": "1.1.0" - }, - "engines": { - "node": ">=18.12.0" - }, - "peerDependencies": { - "@swc/helpers": ">=0.5.1" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@rspack/dev-server": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.1.5.tgz", - "integrity": "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.6.0", - "http-proxy-middleware": "^2.0.9", - "p-retry": "^6.2.0", - "webpack-dev-server": "5.2.2", - "ws": "^8.18.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@rspack/core": "*" - } - }, - "node_modules/@rspack/lite-tapable": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", - "integrity": "sha512-E2B0JhYFmVAwdDiG14+DW0Di4Ze4Jg10Pc4/lILUrd5DRCaklduz2OvJ5HYQ6G+hd+WTzqQb3QnDNfK4yvAFYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/eslint": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", - "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/estree": "*", - "@types/json-schema": "*" - } - }, - "node_modules/@types/eslint-scope": { - "version": "3.7.7", - "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", - "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@types/eslint": "*", - "@types/estree": "*" - } - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", - "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/node-forge": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", - "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vscode/component-explorer": { - "version": "0.2.1-58", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.2.1-58.tgz", - "integrity": "sha512-57kMe/U0+OOlkU6hD4OKUNW9Nly9Bbw9luvYgw+xmbaHv/55SrlR7SPT6nGtqVDEukRCAQ41pF7XsOM5PUpJsA==", - "license": "MIT", - "dependencies": { - "react": "^18.3.1", - "react-dom": "^18.3.1" - } - }, - "node_modules/@vscode/component-explorer-webpack-plugin": { - "version": "0.3.1-53", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-webpack-plugin/-/component-explorer-webpack-plugin-0.3.1-53.tgz", - "integrity": "sha512-e+Ala8W19RnH/Wlh/nguhdGT2MOA9O5XElEYftabe4nBmE4Dxv7QyrETv/dJ0NIkwzg920p2LLkHrxigYg/zPg==", - "license": "MIT", - "dependencies": { - "tinyglobby": "^0.2.16" - }, - "peerDependencies": { - "@vscode/component-explorer": "*" - } - }, - "node_modules/@vscode/esm-url-webpack-plugin": { - "version": "1.0.1-5", - "resolved": "https://registry.npmjs.org/@vscode/esm-url-webpack-plugin/-/esm-url-webpack-plugin-1.0.1-5.tgz", - "integrity": "sha512-XnPSKzCW1Lti9pfc/qAvXwlp7gbFGGJmtXiurzqRrWJS8cm/g87dDoSbh45eioynUSDR6EwCW/kFbKmIR54RhQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause", - "peer": true - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0", - "peer": true - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "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": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", - "dev": true, - "license": "Apache-2.0", - "peer": true, - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0", - "peer": true - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.0" - } - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "dev": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.345", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", - "integrity": "sha512-F9JXQGiMrz6yVNPI2qOVPvB9HzjH5cGzhs8oJ6A28V5L/YnzN/0KsuiibqF+F1Fd9qxFzD1BUnYSd8JfULxTwg==", - "dev": true, - "license": "ISC", - "peer": true - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/exit-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz", - "integrity": "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" + "@types/eslint": "*", + "@types/estree": "*" } }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "node_modules/@types/estree": { + "version": "1.0.8", "dev": true, "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "peer": true }, - "node_modules/is-wsl": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", - "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "node_modules/@types/json-schema": { + "version": "7.0.15", "dev": true, "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" + "peer": true }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "node_modules/@types/node": { + "version": "25.5.0", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" + "undici-types": "~7.18.0" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "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/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", - "dev": true, + "node_modules/@vscode/component-explorer": { + "version": "0.2.1-58", "license": "MIT", "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" - } - }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "react": "^18.3.1", + "react-dom": "^18.3.1" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "node_modules/@vscode/component-explorer-webpack-plugin": { + "version": "0.3.1-53", "license": "MIT", "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" + "tinyglobby": "^0.2.16" }, - "bin": { - "loose-envify": "cli.js" + "peerDependencies": { + "@vscode/component-explorer": "*" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "node_modules/@vscode/esm-url-webpack-plugin": { + "version": "1.0.1-5", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" + "peerDependencies": { + "webpack": "^5.0.0" } }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.1.tgz", - "integrity": "sha512-WvzrWPwMQT+PtbX2Et64R4qXKK0fj/8pO85MrUCzymX3twwCiJCdvntW3HdhG1teLJcHDDLIKx5+c3HckWYZtQ==", - "dev": true, - "license": "Apache-2.0", + "peer": true, "dependencies": { - "@jsonjoy.com/fs-core": "4.57.1", - "@jsonjoy.com/fs-fsa": "4.57.1", - "@jsonjoy.com/fs-node": "4.57.1", - "@jsonjoy.com/fs-node-builtins": "4.57.1", - "@jsonjoy.com/fs-node-to-fsa": "4.57.1", - "@jsonjoy.com/fs-node-utils": "4.57.1", - "@jsonjoy.com/fs-print": "4.57.1", - "@jsonjoy.com/fs-snapshot": "4.57.1", - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "peer": true }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", "dev": true, "license": "MIT", "peer": true }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "peer": true }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "peer": true }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, - "node_modules/mime-types/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "peer": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" } }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", "dev": true, "license": "MIT", - "peer": true - }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "dev": true, - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, - "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", "dev": true, "license": "MIT", - "peer": true + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", "dev": true, "license": "MIT", - "engines": { - "node": ">=0.10.0" + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "BSD-3-Clause", + "peer": true }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "node_modules/@xtuc/long": { + "version": "4.2.2", "dev": true, - "license": "MIT" + "license": "Apache-2.0", + "peer": true }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "node_modules/acorn": { + "version": "8.16.0", "dev": true, "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" + "peer": true, + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">= 0.8" + "node": ">=0.4.0" } }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "node_modules/acorn-import-phases": { + "version": "1.0.4", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.8" + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" } }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "node_modules/ajv": { + "version": "8.18.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "dev": true, - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "node_modules/ajv-formats": { + "version": "2.1.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" + "ajv": "^8.0.0" }, - "engines": { - "node": ">=16.17" + "peerDependencies": { + "ajv": "^8.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "peerDependenciesMeta": { + "ajv": { + "optional": true + } } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "node_modules/ajv-keywords": { + "version": "5.1.0", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "node_modules/baseline-browser-mapping": { + "version": "2.10.27", "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "license": "MIT", - "engines": { - "node": ">=12" + "license": "Apache-2.0", + "peer": true, + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "engines": { + "node": ">=6.0.0" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "node_modules/browserslist": { + "version": "4.28.2", "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", + "peer": true, "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" }, "engines": { - "node": ">= 0.10" + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "node_modules/buffer-from": { + "version": "1.1.2", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.10" - } + "peer": true }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "node_modules/caniuse-lite": { + "version": "1.0.30001791", "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "node_modules/chrome-trace-event": { + "version": "1.0.4", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=6.0" } }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "node_modules/electron-to-chromium": { + "version": "1.5.345", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" }, "engines": { - "node": ">= 0.8" + "node": ">=10.13.0" } }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "node_modules/es-module-lexer": { + "version": "2.1.0", + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } + "peer": true }, - "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "peerDependencies": { - "react": "^18.3.1" + "peer": true, + "engines": { + "node": ">=6" } }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "node_modules/eslint-scope": { + "version": "5.1.1", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "peer": true, "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" }, "engines": { - "node": ">= 6" + "node": ">=8.0.0" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "node_modules/esrecurse": { + "version": "4.3.0", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "peer": true, "dependencies": { - "picomatch": "^2.2.1" + "estraverse": "^5.2.0" }, "engines": { - "node": ">=8.10.0" + "node": ">=4.0" } }, - "node_modules/readdirp/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "peer": true, "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=4.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "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==", + "node_modules/estraverse": { + "version": "4.3.0", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "peer": true, "engines": { - "node": ">=0.10.0" + "node": ">=4.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "node_modules/events": { + "version": "3.3.0", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 4" + "node": ">=0.8.x" } }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "node_modules/fast-deep-equal": { + "version": "3.1.3", "dev": true, "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "peer": true }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "node_modules/fast-uri": { + "version": "3.1.2", "dev": true, "funding": [ { "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" + "url": "https://github.com/sponsors/fastify" }, { - "type": "consulting", - "url": "https://feross.org/support" + "type": "opencollective", + "url": "https://opencollective.com/fastify" } ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } + "license": "BSD-3-Clause", + "peer": true }, - "node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, + "node_modules/fdir": { + "version": "6.5.0", "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, "engines": { - "node": ">= 10.13.0" + "node": ">=12.0.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "node_modules/glob-to-regexp": { + "version": "0.4.1", "dev": true, - "license": "MIT" + "license": "BSD-2-Clause", + "peer": true + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC", + "peer": true }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", + "node_modules/has-flag": { + "version": "4.0.0", "dev": true, "license": "MIT", - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, + "peer": true, "engines": { - "node": ">=10" + "node": ">=8" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "node_modules/jest-worker": { + "version": "27.5.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 10.13.0" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, + "node_modules/js-tokens": { + "version": "4.0.0", "license": "MIT" }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "node_modules/json-schema-traverse": { + "version": "1.0.0", "dev": true, "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" - }, - "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "peer": true }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "node_modules/loader-runner": { + "version": "4.3.2", "dev": true, "license": "MIT", + "peer": true, "engines": { - "node": ">= 0.6" + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "dev": true, + "node_modules/loose-envify": { + "version": "1.4.0", "license": "MIT", "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" + "js-tokens": "^3.0.0 || ^4.0.0" }, - "engines": { - "node": ">= 0.6" + "bin": { + "loose-envify": "cli.js" } }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "node_modules/merge-stream": { + "version": "2.0.0", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.6" - } + "peer": true }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "node_modules/mime-db": { + "version": "1.54.0", "dev": true, "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, + "peer": true, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.6" } }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "node_modules/neo-async": { + "version": "2.6.2", "dev": true, - "license": "ISC" + "license": "MIT", + "peer": true }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", + "node_modules/node-releases": { + "version": "2.0.38", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "peer": true }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "node_modules/picocolors": { + "version": "1.1.1", "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.4", "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, "engines": { - "node": ">= 0.4" + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://github.com/sponsors/jonschlinkert" } }, - "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==", - "dev": true, + "node_modules/react": { + "version": "18.3.1", "license": "MIT", "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "loose-envify": "^1.1.0" }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, + "node_modules/react-dom": { + "version": "18.3.1", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "peerDependencies": { + "react": "^18.3.1" } }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "node_modules/require-from-string": { + "version": "2.0.2", "dev": true, "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, + "peer": true, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "node_modules/safer-buffer": { + "version": "2.1.2", "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", "license": "MIT", "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" + "loose-envify": "^1.1.0" } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "node_modules/schema-utils": { + "version": "4.3.3", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/source-map": { "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", "peer": true, @@ -3748,8 +1084,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3758,8 +1092,6 @@ }, "node_modules/source-map-loader": { "version": "5.0.0", - "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-5.0.0.tgz", - "integrity": "sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==", "dev": true, "license": "MIT", "dependencies": { @@ -3779,8 +1111,6 @@ }, "node_modules/source-map-loader/node_modules/iconv-lite": { "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -3792,8 +1122,6 @@ }, "node_modules/source-map-support": { "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", "peer": true, @@ -3802,112 +1130,8 @@ "source-map": "^0.6.0" } }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/spdy-transport/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/spdy-transport/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/spdy/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/spdy/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, "node_modules/supports-color": { "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, "license": "MIT", "peer": true, @@ -3923,8 +1147,6 @@ }, "node_modules/tapable": { "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "peer": true, @@ -3938,8 +1160,6 @@ }, "node_modules/terser": { "version": "5.46.2", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.2.tgz", - "integrity": "sha512-uxfo9fPcSgLDYob/w1FuL0c99MWiJDnv+5qXSQc5+Ki5NjVNsYi66INnMFBjf6uFz6OnX12piJQPF4IpjJTNTw==", "dev": true, "license": "BSD-2-Clause", "peer": true, @@ -3958,8 +1178,6 @@ }, "node_modules/terser-webpack-plugin": { "version": "5.5.0", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", - "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", "dev": true, "license": "MIT", "peer": true, @@ -3993,40 +1211,12 @@ }, "node_modules/terser/node_modules/commander": { "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, "license": "MIT", "peer": true }, - "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true, - "license": "MIT" - }, "node_modules/tinyglobby": { "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", @@ -4039,98 +1229,20 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, "node_modules/tslib": { "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } + "license": "0BSD", + "optional": true }, "node_modules/undici-types": { "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", "dev": true, "license": "MIT", - "engines": { - "node": ">= 0.8" - } + "peer": true }, "node_modules/update-browserslist-db": { "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -4159,47 +1271,8 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "dev": true, - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/watchpack": { "version": "2.5.1", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", - "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", "dev": true, "license": "MIT", "peer": true, @@ -4211,20 +1284,8 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/webpack": { "version": "5.106.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", - "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", "dev": true, "license": "MIT", "peer": true, @@ -4270,233 +1331,14 @@ } } }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, "node_modules/webpack-sources": { "version": "3.4.1", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz", - "integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==", "dev": true, "license": "MIT", "peer": true, "engines": { "node": ">=10.13.0" } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } } } } diff --git a/build/rspack/package.json b/build/rspack/package.json index fc81f203c25275..a812e140bb22f6 100644 --- a/build/rspack/package.json +++ b/build/rspack/package.json @@ -7,8 +7,9 @@ "serve-out": "rspack serve --config rspack.serve-out.config.mts" }, "devDependencies": { - "@rspack/cli": "^1.3.18", - "@rspack/core": "^1.3.18", + "@rspack/cli": "^2.1.2", + "@rspack/core": "^2.1.2", + "@rspack/dev-server": "^2.1.0", "@vscode/esm-url-webpack-plugin": "^1.0.1-3", "source-map-loader": "^5.0.0" }, diff --git a/build/rspack/rspack.serve-out.config.mts b/build/rspack/rspack.serve-out.config.mts index 1437c0e8b8ca10..c1125f03dbcaf3 100644 --- a/build/rspack/rspack.serve-out.config.mts +++ b/build/rspack/rspack.serve-out.config.mts @@ -44,6 +44,13 @@ export default { }, }, resolve: { + // Component Explorer fixtures live in `src` as `.ts` and import sibling + // modules via `.js` specifiers; try `.ts` first so those resolve, then + // fall back to `.js` for everything loaded from `out`. + extensionAlias: { + '.js': ['.ts', '.js'], + '.mjs': ['.mts', '.mjs'], + }, fallback: { path: path.resolve(repoRoot, 'node_modules', 'path-browserify'), fs: false, @@ -59,9 +66,25 @@ export default { module: { rules: [ { - test: /\.js$/, - enforce: 'pre', - use: ['source-map-loader'], + // Component Explorer fixtures (and any `src` TypeScript they pull + // in) are compiled on the fly with rspack's built-in SWC. + test: /\.ts$/, + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + legacyDecorator: true, + decoratorMetadata: false, + useDefineForClassFields: false, + }, + target: 'es2022', + }, + }, + type: 'javascript/auto', }, { test: /\.css$/, @@ -86,7 +109,7 @@ export default { }, plugins: [ new ComponentExplorerPlugin({ - include: 'out/**/*.fixture.js', + include: 'src/**/*.fixture.ts', }), new rspack.NormalModuleReplacementPlugin(/\.css$/, resource => { if (!resource.request.startsWith('.')) { diff --git a/build/rspack/workbench-rspack.html b/build/rspack/workbench-rspack.html index 58b0ba6bd09493..87882e88e8cee4 100644 --- a/build/rspack/workbench-rspack.html +++ b/build/rspack/workbench-rspack.html @@ -1,5 +1,5 @@ - + diff --git a/build/stylelint.ts b/build/stylelint.ts index 31313a3d2b84d7..38c263963f4877 100644 --- a/build/stylelint.ts +++ b/build/stylelint.ts @@ -7,7 +7,7 @@ import es from 'event-stream'; import vfs from 'vinyl-fs'; import { stylelintFilter } from './filters.ts'; import { getVariableNameValidator } from './lib/stylelint/validateVariableNames.ts'; -import { validateCodiconFontSizes, validateFontSizeTokens, validateFontWeightTokens, validateCornerRadiusTokens, validateSpacingTokens, validateStrokeTokens } from './lib/stylelint/validateDesignTokens.ts'; +import { validateCodiconFontSizes, validateFontSizeTokens, validateFontWeightTokens, validateCornerRadiusTokens, validateSpacingTokens, validateStrokeTokens, validateDeprecatedTokens } from './lib/stylelint/validateDesignTokens.ts'; interface FileWithLines { __lines?: string[]; @@ -32,7 +32,7 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere const layerCheckerDisablePattern = /\/\*\s*stylelint-disable\s+layer-checker\s*\*\//; // Per-category tally of design-token suggestions for the summary footer. - const designTokenCounts: Record = { codicon: 0, 'font-size': 0, weight: 0, radius: 0, spacing: 0, stroke: 0 }; + const designTokenCounts: Record = { codicon: 0, 'font-size': 0, weight: 0, radius: 0, spacing: 0, stroke: 0, deprecated: 0 }; let designTokenFileCount = 0; return es.through(function (this, file: FileWithLines) { @@ -73,6 +73,7 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere for (const v of validateCornerRadiusTokens(contents)) { findings.push({ line: v.line, category: 'radius', message: v.message }); } for (const v of validateSpacingTokens(contents)) { findings.push({ line: v.line, category: 'spacing', message: v.message }); } for (const v of validateStrokeTokens(contents)) { findings.push({ line: v.line, category: 'stroke', message: v.message }); } + for (const v of validateDeprecatedTokens(contents)) { findings.push({ line: v.line, category: 'deprecated', message: v.message }); } if (findings.length > 0) { findings.sort((a, b) => a.line - b.line); @@ -91,7 +92,7 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere if (errorCount > 0) { reporter('All valid variable names are in `build/lib/stylelint/vscode-known-variables.json`\nTo update that file, run `./scripts/test-documentation.sh|bat.`', false); } - const designTokenTotal = designTokenCounts.codicon + designTokenCounts['font-size'] + designTokenCounts.weight + designTokenCounts.radius + designTokenCounts.spacing + designTokenCounts.stroke; + const designTokenTotal = designTokenCounts.codicon + designTokenCounts['font-size'] + designTokenCounts.weight + designTokenCounts.radius + designTokenCounts.spacing + designTokenCounts.stroke + designTokenCounts.deprecated; if (designTokenTotal > 0) { reporter('', false); reporter( @@ -101,7 +102,8 @@ export default function gulpstylelint(reporter: Reporter, designTokensEverywhere ', weight ' + designTokenCounts.weight + ', radius ' + designTokenCounts.radius + ', spacing ' + designTokenCounts.spacing + - ', stroke ' + designTokenCounts.stroke + ')', + ', stroke ' + designTokenCounts.stroke + + ', deprecated ' + designTokenCounts.deprecated + ')', false ); } diff --git a/cgmanifest.json b/cgmanifest.json index b90a69f0349a5e..afc549e769e0b6 100644 --- a/cgmanifest.json +++ b/cgmanifest.json @@ -6,7 +6,7 @@ "git": { "name": "chromium", "repositoryUrl": "https://chromium.googlesource.com/chromium/src", - "commitHash": "6b3fa66a923a9442c8ab0bc71b4b41ff24528d3b" + "commitHash": "21c21077f4937668031f80089a90618b7e1fe779" } }, "licenseDetail": [ @@ -40,7 +40,7 @@ "SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE." ], "isOnlyProductionDependency": true, - "version": "148.0.7778.97" + "version": "148.0.7778.271" }, { "component": { @@ -516,12 +516,12 @@ "git": { "name": "nodejs", "repositoryUrl": "https://github.com/nodejs/node", - "commitHash": "848430679556aed0bd073f2bc263331ad84fa119", - "tag": "24.15.0" + "commitHash": "413e874773eef1e27a8397ddc949dbbe19cadb31", + "tag": "24.17.0" } }, "isOnlyProductionDependency": true, - "version": "24.15.0" + "version": "24.17.0" }, { "component": { @@ -529,13 +529,13 @@ "git": { "name": "electron", "repositoryUrl": "https://github.com/electron/electron", - "commitHash": "87740a867bddf434afec16e1f8b4f02235d3e7f7", - "tag": "42.2.0" + "commitHash": "d7bccf5b2f27969b4e7f34cf877f55cf0813be7a", + "tag": "42.5.0" } }, "isOnlyProductionDependency": true, "license": "MIT", - "version": "42.2.0" + "version": "42.5.0" }, { "component": { diff --git a/eslint.config.js b/eslint.config.js index 51b085eebfe15e..b9ba2cead680a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1577,6 +1577,7 @@ export default defineConfig( 'undici', 'undici-types', 'url', + 'module', 'util', 'vscode-regexpp', 'vscode-textmate', diff --git a/extensions/copilot/.nvmrc b/extensions/copilot/.nvmrc index 5bf4400f22922e..1dd37d53743d1a 100644 --- a/extensions/copilot/.nvmrc +++ b/extensions/copilot/.nvmrc @@ -1 +1 @@ -24.15.0 +24.17.0 diff --git a/extensions/copilot/chat-lib/package-lock.json b/extensions/copilot/chat-lib/package-lock.json index 7480a9c237fcf6..36c24bfb5a07d4 100644 --- a/extensions/copilot/chat-lib/package-lock.json +++ b/extensions/copilot/chat-lib/package-lock.json @@ -147,25 +147,27 @@ } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { - "version": "1.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0-beta.5.tgz", - "integrity": "sha512-fsUarKQDvjhmBO4nIfaZkfNSApm1hZBzcvpNbSrXdcUBxu7lRvKsV5DnwszX7cnhLyVOW9yl1uigtRQ1yDANjA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0.tgz", + "integrity": "sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==", + "license": "MIT", "dependencies": { - "@azure/core-tracing": "^1.0.0", + "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.0.0", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/instrumentation": "^0.41.2", - "tslib": "^2.2.0" + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "tslib": "^2.7.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -776,6 +778,18 @@ "node": ">=8.0.0" } }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@opentelemetry/core": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.30.1.tgz", @@ -801,18 +815,17 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.41.2.tgz", - "integrity": "sha512-rxU72E0pKNH6ae2w5+xgVYZLzc5mlxAbGzF4shxMVK8YC2QQsfN38B2GPbj0jvrKWWNUElfclQ+YTykkNg/grw==", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", "dependencies": { - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.4.2", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.1", - "shimmer": "^1.2.1" + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" @@ -869,6 +882,70 @@ "node": ">=14" } }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.8.0.tgz", + "integrity": "sha512-P3ZM8BGJ5mwjtyfAxRyxsCyWHvaj+xahdhLoS3YiPsEyTHcWTVzx2691C8SrGkpvro3tNFCsWuNNrvM+spKODg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.30.0.tgz", @@ -1229,11 +1306,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/shimmer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz", - "integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA==" - }, "node_modules/@vitest/expect": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", @@ -1372,9 +1444,9 @@ "license": "MIT" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1383,11 +1455,10 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", - "deprecated": "package has been renamed to acorn-import-attributes", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", "license": "MIT", "peerDependencies": { "acorn": "^8" @@ -1427,9 +1498,9 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.7.tgz", - "integrity": "sha512-dxIVB2AAEMec3FDiYThgEbc9R4u1TatrzL+kFgOf+ABaEgHm8+i8ngVLHfKObjHvy2HHPf810OLWTrqyeWT/oA==", + "version": "2.9.8", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.8.tgz", + "integrity": "sha512-eB/EtAXJ6mDLLvHrtZj/7h31qUfnC2Npr2pHGqds5+1OP7BFLsn5us+HCkwTj7Q+1sHXujLphE5Cyvq5grtV6g==", "license": "MIT", "dependencies": { "@azure/core-auth": "1.7.2", @@ -1656,9 +1727,10 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cls-hooked": { "version": "4.2.2", @@ -2381,6 +2453,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2627,6 +2700,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -2661,14 +2735,15 @@ } }, "node_modules/import-in-the-middle": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", - "integrity": "sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" } }, "node_modules/inflight": { @@ -2796,6 +2871,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -3511,9 +3587,10 @@ } }, "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" }, "node_modules/monaco-editor": { "version": "0.44.0", @@ -3946,7 +4023,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { "version": "2.0.0", @@ -4161,22 +4239,23 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", - "integrity": "sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -4318,18 +4397,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -4784,6 +4851,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -4990,9 +5058,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index 53f42cb984ce1b..c74efde64f0f4a 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -1,34 +1,34 @@ { "name": "copilot-chat", - "version": "0.55.0", + "version": "0.57.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "copilot-chat", - "version": "0.55.0", + "version": "0.57.0", "hasInstallScript": true, "license": "SEE LICENSE IN LICENSE.txt", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.69-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", @@ -51,7 +51,7 @@ "isbinaryfile": "^5.0.4", "jsonc-parser": "^3.3.1", "lru-cache": "^11.1.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", "vscode-tas-client": "^0.1.84", @@ -97,16 +97,16 @@ "@vscode/lsif-language-service": "^0.1.0-pre.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/test-web": "^0.0.80", + "@vscode/test-web": "^0.0.81", "@vscode/vsce": "3.6.0", "copyfiles": "^2.4.1", "csv-parse": "^6.0.0", "dotenv": "^17.2.0", - "electron": "^39.8.5", + "electron": "^42.5.0", "esbuild": "0.28.1", "fastq": "^1.19.1", "glob": "^11.1.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -119,7 +119,7 @@ "openai": "^6.7.0", "outdent": "^0.8.0", "picomatch": "^4.0.4", - "playwright": "^1.58.2", + "playwright": "^1.61.1", "prettier": "^3.6.2", "react": "^17.0.2", "react-dom": "17.0.2", @@ -144,7 +144,7 @@ "engines": { "node": ">=22.14.0", "npm": ">=9.0.0", - "vscode": "^1.127.0" + "vscode": "^1.129.0" } }, "node_modules/@anthropic-ai/claude-agent-sdk": { @@ -630,19 +630,36 @@ } }, "node_modules/@azure/opentelemetry-instrumentation-azure-sdk": { - "version": "1.0.0-beta.5", - "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0-beta.5.tgz", - "integrity": "sha512-fsUarKQDvjhmBO4nIfaZkfNSApm1hZBzcvpNbSrXdcUBxu7lRvKsV5DnwszX7cnhLyVOW9yl1uigtRQ1yDANjA==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/opentelemetry-instrumentation-azure-sdk/-/opentelemetry-instrumentation-azure-sdk-1.0.0.tgz", + "integrity": "sha512-Y8rZOIMXQY/GwNRL+uLVuwIn9aEa/KnnggyYUmFxC1MigmRJCNH5NxMmxKSpddXF9SW6Z1ijRd6Pptd2A5OhGw==", + "license": "MIT", "dependencies": { - "@azure/core-tracing": "^1.0.0", + "@azure/core-tracing": "^1.2.0", "@azure/logger": "^1.0.0", - "@opentelemetry/api": "^1.4.1", - "@opentelemetry/core": "^1.15.2", - "@opentelemetry/instrumentation": "^0.41.2", - "tslib": "^2.2.0" + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.211.0", + "@opentelemetry/sdk-trace-web": "^2.0.0", + "tslib": "^2.7.0" }, "engines": { - "node": ">=14.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@azure/opentelemetry-instrumentation-azure-sdk/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "node_modules/@babel/code-frame": { @@ -751,34 +768,35 @@ "node": ">=10" } }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.4.tgz", + "integrity": "sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/get": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz", - "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", + "integrity": "sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", + "env-paths": "^3.0.0", + "graceful-fs": "^4.2.11", "progress": "^2.0.3", - "semver": "^6.2.0", + "semver": "^7.6.3", "sumchecker": "^3.0.1" }, "engines": { - "node": ">=12" + "node": ">=22.12.0" }, "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" + "undici": "^7.24.4" } }, "node_modules/@emnapi/core": { @@ -2951,9 +2969,9 @@ "license": "MIT" }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69-0.tgz", + "integrity": "sha512-Y/ZJBo1dtsP7k2LxDQxQT5pj2ZaN7+dCD4vx5jhX3i2T+YFlOx7ZhfUx8Hpe94wQPDlCs+232TXRHl2Wb5p62w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -2962,20 +2980,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.69-0", + "@github/copilot-darwin-x64": "1.0.69-0", + "@github/copilot-linux-arm64": "1.0.69-0", + "@github/copilot-linux-x64": "1.0.69-0", + "@github/copilot-linuxmusl-arm64": "1.0.69-0", + "@github/copilot-linuxmusl-x64": "1.0.69-0", + "@github/copilot-win32-arm64": "1.0.69-0", + "@github/copilot-win32-x64": "1.0.69-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69-0.tgz", + "integrity": "sha512-RyX31WkNGpXycW5FCAgJ7+MXTmfwSkiohHf7jWShKQPP6m/MEM0CrBuS4XPeWK0gjtWM4MdAwmVe7qXtbzZu1w==", "cpu": [ "arm64" ], @@ -2989,9 +3007,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69-0.tgz", + "integrity": "sha512-NaA44Uq6d1ijcF2eT8/Ql0eacyvjGFGYN8hVFLkD6CsjPmSbupMd39NFt2RWnZDjhg3S68J77OSXH4aj3vcaiA==", "cpu": [ "x64" ], @@ -3005,12 +3023,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69-0.tgz", + "integrity": "sha512-dSFha3BrnGeMKLzqWE8c63tRdKgw3mjV9Apx4/i7L9BMJ0o13oycVVe+D+bEVniWH12gVriIxE1+TQUAAVWLIQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3021,12 +3042,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69-0.tgz", + "integrity": "sha512-2zH3yh7//D7rOriDt4eAc4+tYay+oQj9CcENP0Cb8aNEn27hyZmuKiLoA+xyGSrbhVqA4rzYLC3Hx9RI96xxSQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3037,12 +3061,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69-0.tgz", + "integrity": "sha512-vVezUNBfxp4ej9rTQy3zy8UT23imxFWqzkVu0yFrt6lqr9a7RSmPHhhKkPYkyxCWuzhSxGWLm3Ad6TDTYVVGog==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3053,12 +3080,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69-0.tgz", + "integrity": "sha512-Wlb421yC7iuw6ICMxuNPPL+ItG0f9+vv3wdHUeWhyCMte7+7tqxvhyfxSCzj46V5CXoGo32vUc0oOxmXMfSbyQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -3069,9 +3099,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69-0.tgz", + "integrity": "sha512-H0p9kiCO8Rt6tMuZUPzszoxYaipIIvOBbUtqljV56UX+XwBaSnxME/ZjRCNn480jdrA2QbLjaobZWH2w3C4scg==", "cpu": [ "arm64" ], @@ -3085,9 +3115,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69-0.tgz", + "integrity": "sha512-3W/e8Lhw1eStFJogIP4pf9bxg9izcI/c0KErHLFK+56HqfnzK5ZDQqb+oeqRw3+aq24MvzyLzHA421jPHLIoOQ==", "cpu": [ "x64" ], @@ -3683,16 +3713,16 @@ } }, "node_modules/@koa/router": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.4.0.tgz", - "integrity": "sha512-vKYlXtoCfcAN8z4dHiveYX55rTYOgHEYJNumK1WM9ZAwaArhreGVkyC1LTMGfUQUJyIO/SbwRFBOHeOCY8/MaQ==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz", + "integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==", "dev": true, "license": "MIT", "dependencies": { "debug": "^4.4.3", "http-errors": "^2.0.1", "koa-compose": "^4.1.0", - "path-to-regexp": "^8.3.0" + "path-to-regexp": "^8.4.2" }, "engines": { "node": ">= 20" @@ -4033,9 +4063,9 @@ } }, "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz", - "integrity": "sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.8.0.tgz", + "integrity": "sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==", "license": "Apache-2.0", "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4069,17 +4099,17 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-SwmFRwO8mi6nndzbsjPgSFg7qy1WeNHRFD+s6uCsdiUDUt3+yzI2qiHE3/ub2f37+/CbeGcG+Ugc8Gwr6nu2Aw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4089,9 +4119,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4101,9 +4131,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4116,12 +4146,12 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4132,14 +4162,14 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4150,16 +4180,16 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.214.0.tgz", - "integrity": "sha512-9qv2Tl/Hq6qc5pJCbzFJnzA0uvlb9DgM70yGJPYf3bA5LlLkRCpcn81i4JbcIH4grlQIWY6A+W7YG0LLvS1BAw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.219.0.tgz", + "integrity": "sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/sdk-logs": "0.214.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/sdk-logs": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4169,9 +4199,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4181,9 +4211,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4196,12 +4226,12 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4212,14 +4242,14 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4230,18 +4260,18 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.214.0.tgz", - "integrity": "sha512-IWAVvCO1TlpotRjFmhQFz9RSfQy5BsLtDRBtptSrXZRwfyRPpuql/RMe5zdmu0Gxl3ERDFwOzOqkf3bwy7Jzcw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.219.0.tgz", + "integrity": "sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4251,9 +4281,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4263,9 +4293,9 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4278,12 +4308,12 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4294,14 +4324,14 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4312,13 +4342,13 @@ } }, "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4329,19 +4359,19 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-0NGxWHVYHgbp51SEzmsP+Hdups81eRs229STcSWHo3WO0aqY6RpJ9csxfyEtFgaNrBDv6UfOh0je4ss/ROS6XA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4351,9 +4381,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4366,12 +4396,12 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4382,16 +4412,16 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.214.0.tgz", - "integrity": "sha512-Tx/59RmjBgkXJ3qnsD04rpDrVWL53LU/czpgLJh+Ab98nAroe91I7vZ3uGN9mxwPS0jsZEnmqmHygVwB2vRMlA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.219.0.tgz", + "integrity": "sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4401,9 +4431,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4416,12 +4446,12 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4432,17 +4462,17 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.214.0.tgz", - "integrity": "sha512-pJIcghFGhx3VSCgP5U+yZx+OMNj0t+ttnhC8IjL5Wza7vWIczctF6t3AGcVQffi2dEqX+ZHANoBwoPR8y6RMKA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.219.0.tgz", + "integrity": "sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/exporter-metrics-otlp-http": "0.214.0", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-metrics": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.219.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-metrics": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4452,9 +4482,9 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4467,12 +4497,12 @@ } }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4483,18 +4513,18 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.214.0.tgz", - "integrity": "sha512-FWRZ7AWoTryYhthralHkfXUuyO3l7cRsnr49WcDio1orl2a7KxT8aDZdwQtV1adzoUvZ9Gfo+IstElghCS4zfw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.219.0.tgz", + "integrity": "sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4504,9 +4534,9 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4519,12 +4549,12 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4535,13 +4565,13 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4552,16 +4582,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz", - "integrity": "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.219.0.tgz", + "integrity": "sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4571,9 +4601,9 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4586,12 +4616,12 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4602,13 +4632,13 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4619,16 +4649,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.214.0.tgz", - "integrity": "sha512-ON0spYWb2yAdQ9b+ItNyK0c6qdtcs+0eVR4YFJkhJL7agfT8sHFg0e5EesauSRiTHPZHiDobI92k77q0lwAmqg==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.219.0.tgz", + "integrity": "sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4638,9 +4668,9 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4653,12 +4683,12 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4669,13 +4699,13 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4686,31 +4716,42 @@ } }, "node_modules/@opentelemetry/instrumentation": { - "version": "0.41.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.41.2.tgz", - "integrity": "sha512-rxU72E0pKNH6ae2w5+xgVYZLzc5mlxAbGzF4shxMVK8YC2QQsfN38B2GPbj0jvrKWWNUElfclQ+YTykkNg/grw==", + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz", + "integrity": "sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q==", + "license": "Apache-2.0", "dependencies": { - "@types/shimmer": "^1.0.2", - "import-in-the-middle": "1.4.2", - "require-in-the-middle": "^7.1.1", - "semver": "^7.5.1", - "shimmer": "^1.2.1" + "@opentelemetry/api-logs": "0.211.0", + "import-in-the-middle": "^2.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { - "node": ">=14" + "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation/node_modules/@opentelemetry/api-logs": { + "version": "0.211.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.211.0.tgz", + "integrity": "sha512-swFdZq8MCdmdR22jTVGQDhwqDzcI4M10nhjXkLr1EsIzXgZBqm4ZlmmcWsg3TSNf+3mzgOiqveXmBLZuDi2Lgg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", - "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.219.0.tgz", + "integrity": "sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4720,9 +4761,9 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4735,15 +4776,15 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.214.0.tgz", - "integrity": "sha512-IDP6zcyA24RhNZ289MP6eToIZcinlmirHjX8v3zKCQ2ZhPpt5cGwkN91tCth337lqHIgWcTy90uKRiX/SzALDw==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.219.0.tgz", + "integrity": "sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw==", "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.14.3", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/otlp-exporter-base": "0.219.0", + "@opentelemetry/otlp-transformer": "0.219.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4753,9 +4794,9 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4768,18 +4809,17 @@ } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz", + "integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/sdk-logs": "0.219.0", + "@opentelemetry/sdk-metrics": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4789,9 +4829,9 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz", + "integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/api": "^1.3.0" @@ -4801,9 +4841,9 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4816,12 +4856,12 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4832,14 +4872,14 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.219.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz", + "integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.219.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4850,13 +4890,13 @@ } }, "node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -4930,13 +4970,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz", + "integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -4946,9 +4986,9 @@ } }, "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -4961,12 +5001,12 @@ } }, "node_modules/@opentelemetry/sdk-metrics/node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -5019,14 +5059,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.5.1.tgz", - "integrity": "sha512-9lopQ6ZoElETOEN0csgmtEV5/9C7BMfA7VtF4Jape3i954b6sTY2k3Xw3CxUTKreDck/vpAuJM+EDo4zheUw+A==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.8.0.tgz", + "integrity": "sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/context-async-hooks": "2.5.1", - "@opentelemetry/core": "2.5.1", - "@opentelemetry/sdk-trace-base": "2.5.1" + "@opentelemetry/context-async-hooks": "2.8.0", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -5036,9 +5076,9 @@ } }, "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz", - "integrity": "sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -5050,14 +5090,94 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz", - "integrity": "sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.5.1", - "@opentelemetry/resources": "2.5.1", + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-web/-/sdk-trace-web-2.8.0.tgz", + "integrity": "sha512-P3ZM8BGJ5mwjtyfAxRyxsCyWHvaj+xahdhLoS3YiPsEyTHcWTVzx2691C8SrGkpvro3tNFCsWuNNrvM+spKODg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/sdk-trace-base": "2.8.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-web/node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -5098,14 +5218,14 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.59.1.tgz", - "integrity": "sha512-XDwr0qOrzLXAuBAzg4WO/xctVMb+ldJ54yz9KCpFu8G8MVJzUVFO6BvK1tBtBl4DIoFcoFRKHgUGZT+8wOC8BQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.61.1.tgz", + "integrity": "sha512-t3/zE0i9gik5R/NpRs7G2Xo/6NPeABW6ReplGdtkeWeAkaV764CgFgoKjJo21D2xgjnvDvRYubqBUu4xl0VCqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.59.1" + "playwright-core": "1.61.1" }, "engines": { "node": ">=18" @@ -5130,19 +5250,18 @@ "license": "BSD-3-Clause" }, "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", "license": "BSD-3-Clause" }, "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", "license": "BSD-3-Clause", "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" + "@protobufjs/aspromise": "^1.1.1" } }, "node_modules/@protobufjs/float": { @@ -5151,12 +5270,6 @@ "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", "license": "BSD-3-Clause" }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, "node_modules/@protobufjs/path": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", @@ -5487,28 +5600,70 @@ } }, "node_modules/@secretlint/formatter": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.1.1.tgz", - "integrity": "sha512-Gpd8gTPN121SJ0h/9e6nWlZU7PitfhXUiEzW7Kyswg6kNGs+bSqmgTgWFtbo1VQ4ygJYiveWPNT05RCImBexJw==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/formatter/-/formatter-10.2.2.tgz", + "integrity": "sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA==", "dev": true, "license": "MIT", "dependencies": { - "@secretlint/resolver": "^10.1.1", - "@secretlint/types": "^10.1.1", - "@textlint/linter-formatter": "^14.8.4", - "@textlint/module-interop": "^14.8.4", - "@textlint/types": "^14.8.4", - "chalk": "^4.1.2", + "@secretlint/resolver": "^10.2.2", + "@secretlint/types": "^10.2.2", + "@textlint/linter-formatter": "^15.2.0", + "@textlint/module-interop": "^15.2.0", + "@textlint/types": "^15.2.0", + "chalk": "^5.4.1", "debug": "^4.4.1", "pluralize": "^8.0.0", - "strip-ansi": "^6.0.1", + "strip-ansi": "^7.1.0", "table": "^6.9.0", - "terminal-link": "^2.1.1" + "terminal-link": "^4.0.0" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@secretlint/formatter/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@secretlint/formatter/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/@secretlint/node": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@secretlint/node/-/node-10.1.1.tgz", @@ -5537,9 +5692,9 @@ "license": "MIT" }, "node_modules/@secretlint/resolver": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.1.1.tgz", - "integrity": "sha512-GdQzxnBtdBRjBULvZ8ERkaRqDp0njVwXrzBCav1pb0XshVk76C1cjeDqtTqM4RJ1Awo/g5U5MIWYztYv67v5Gg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/resolver/-/resolver-10.2.2.tgz", + "integrity": "sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w==", "dev": true, "license": "MIT" }, @@ -5591,9 +5746,9 @@ } }, "node_modules/@secretlint/types": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.1.1.tgz", - "integrity": "sha512-/JGAvVkurVHkargk3AC7UxRy+Ymc+52AVBO/fZA5pShuLW2dX4O/rKc4n8cyhQiOb/3ym5ACSlLQuQ8apPfxrQ==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/@secretlint/types/-/types-10.2.2.tgz", + "integrity": "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg==", "dev": true, "license": "MIT", "engines": { @@ -5616,18 +5771,6 @@ "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", "license": "MIT" }, - "node_modules/@sindresorhus/is": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", - "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, "node_modules/@sindresorhus/merge-streams": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz", @@ -5731,41 +5874,29 @@ "tslib": "^2.4.0" } }, - "node_modules/@szmarczak/http-timer": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", - "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", - "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@textlint/ast-node-types": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-14.8.4.tgz", - "integrity": "sha512-+fI7miec/r9VeniFV9ppL4jRCmHNsTxieulTUf/4tvGII3db5hGriKHC4p/diq1SkQ9Sgs7kg6UyydxZtpTz1Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-15.7.1.tgz", + "integrity": "sha512-Wii5UgUKFEh9Uv6wbq1zr4/Kf+dtjiUuzPrrXzKp8H+ifkvKNzi23V4Nz+6wVyHQn5T28AFuc8VH8OtzvGYecA==", "dev": true, "license": "MIT" }, "node_modules/@textlint/linter-formatter": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-14.8.4.tgz", - "integrity": "sha512-sZ0UfYRDBNHnfMVBqLqqYnqTB7Ec169ljlmo+SEHR1T+dHUPYy1/DZK4p7QREXlBSFL4cnkswETCbc9xRodm4Q==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/linter-formatter/-/linter-formatter-15.7.1.tgz", + "integrity": "sha512-TdwZ/debWYFD05K3CcoHtwvnCrza29wZxD+BjDTk/V5N7iRqkK1dTTHSD4A8AIgROLiDkHJmIKQbasbmsg8AvA==", "dev": true, "license": "MIT", "dependencies": { "@azu/format-text": "^1.0.2", "@azu/style-format": "^1.0.1", - "@textlint/module-interop": "14.8.4", - "@textlint/resolver": "14.8.4", - "@textlint/types": "14.8.4", + "@textlint/module-interop": "15.7.1", + "@textlint/resolver": "15.7.1", + "@textlint/types": "15.7.1", "chalk": "^4.1.2", - "debug": "^4.4.1", - "js-yaml": "^3.14.1", - "lodash": "^4.17.21", + "debug": "^4.4.3", + "js-yaml": "^4.1.1", + "lodash": "^4.18.1", "pluralize": "^2.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", @@ -5773,16 +5904,6 @@ "text-table": "^0.2.0" } }, - "node_modules/@textlint/linter-formatter/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, "node_modules/@textlint/linter-formatter/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -5790,20 +5911,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@textlint/linter-formatter/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/@textlint/linter-formatter/node_modules/pluralize": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-2.0.0.tgz", @@ -5811,13 +5918,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@textlint/linter-formatter/node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, "node_modules/@textlint/linter-formatter/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -5834,27 +5934,27 @@ } }, "node_modules/@textlint/module-interop": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-14.8.4.tgz", - "integrity": "sha512-1LdPYLAVpa27NOt6EqvuFO99s4XLB0c19Hw9xKSG6xQ1K82nUEyuWhzTQKb3KJ5Qx7qj14JlXZLfnEuL6A16Bw==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/module-interop/-/module-interop-15.7.1.tgz", + "integrity": "sha512-Jg+sQW2L/cRJypk59wtcMUVVpt8vmit5ZMT3gUnFwevP3A6Qp1HfOtUy9ObT4hBX3lOSGT/ekcCDxR1pL7uH1g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/resolver": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-14.8.4.tgz", - "integrity": "sha512-nMDOgDAVwNU9ommh+Db0U+MCMNDPbQ/1HBNjbnHwxZkCpcT6hsAJwBe38CW/DtWVUv8yeR4R40IYNPT84srNwA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/resolver/-/resolver-15.7.1.tgz", + "integrity": "sha512-8XnO0pgF6mXnm41VvWmBbEIdGPhiCUt31uLZkOis1ECeg/1SoUcIT6Mx/F0e1rukq8l0UlOSeY9a31CsvRMK0g==", "dev": true, "license": "MIT" }, "node_modules/@textlint/types": { - "version": "14.8.4", - "resolved": "https://registry.npmjs.org/@textlint/types/-/types-14.8.4.tgz", - "integrity": "sha512-9nyY8vVXlr8hHKxa6+37omJhXWCwovMQcgMteuldYd4dOxGm14AK2nXdkgtKEUQnzLGaXy46xwLCfhQy7V7/YA==", + "version": "15.7.1", + "resolved": "https://registry.npmjs.org/@textlint/types/-/types-15.7.1.tgz", + "integrity": "sha512-Vye/GmFNBTgVzZFtIFJTmLB+s2A7oIADxNG6r9UhfPuY+Czv0z5G3xeyFZZudPlfxURsKUyPIU5XsjOFqVp33A==", "dev": true, "license": "MIT", "dependencies": { - "@textlint/ast-node-types": "14.8.4" + "@textlint/ast-node-types": "15.7.1" } }, "node_modules/@tybys/wasm-util": { @@ -5879,18 +5979,6 @@ "@types/node": "*" } }, - "node_modules/@types/cacheable-request": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", - "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", - "dev": true, - "dependencies": { - "@types/http-cache-semantics": "*", - "@types/keyv": "^3.1.4", - "@types/node": "*", - "@types/responselike": "^1.0.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -5985,12 +6073,6 @@ "@types/unist": "*" } }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.2.tgz", - "integrity": "sha512-FD+nQWA2zJjh4L9+pFXqWOi0Hs1ryBCfI+985NjluQ1p8EYtoLvjLOKidXBtZ4/IcxDX4o8/E8qDS3540tNliw==", - "dev": true - }, "node_modules/@types/http-errors": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", @@ -6018,15 +6100,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/keyv": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", - "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -6128,15 +6201,6 @@ "@types/react": "*" } }, - "node_modules/@types/responselike": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.1.tgz", - "integrity": "sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/sarif": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz", @@ -6171,11 +6235,6 @@ "@types/node": "*" } }, - "node_modules/@types/shimmer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.0.3.tgz", - "integrity": "sha512-F/IjUGnV6pIN7R4ZV4npHJVoNtaLZWvb+2/9gctxjb99wkpI7Ozg8VPogwDiTRyjLwZXAYxjvdg1KS8LTHKdDA==" - }, "node_modules/@types/sinon": { "version": "17.0.4", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-17.0.4.tgz", @@ -6270,16 +6329,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/yauzl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.1.tgz", - "integrity": "sha512-CHzgNU3qYBnp/O4S3yv2tXPlvMTq0YWSTVg2/JYLqWZGHwwgJGAwd00poay/11asPq8wLFwHzubyInqHIFmmiw==", - "dev": true, - "optional": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.36.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", @@ -7100,26 +7149,26 @@ } }, "node_modules/@vscode/test-web": { - "version": "0.0.80", - "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.80.tgz", - "integrity": "sha512-QgsRPp8IuPcdbUdVtqQkFN/sGd+6cr6g0ENEVFOHwJbQqZtJSiZD5w0e6tgmoeq9nBjNqZCq87OO1GkwFHkPyw==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.81.tgz", + "integrity": "sha512-qAYNX1mf4hE0L3T/186J8AH+Z7Inm81OHMACkkyKE2J6HJZlXou0OgABkSvd8gt0BiPjI+V+xkduAaQ8Kcjexg==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", - "@koa/router": "^15.3.0", - "@playwright/browser-chromium": "^1.58.2", + "@koa/router": "^15.6.0", + "@playwright/browser-chromium": "^1.61.0", "gunzip-maybe": "^1.4.2", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "koa": "^3.1.2", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", + "koa": "^3.2.1", "koa-morgan": "^1.0.1", "koa-mount": "^4.2.0", "koa-static": "^5.0.0", "minimist": "^1.2.8", - "playwright": "^1.58.2", - "tar-fs": "^3.1.1", - "tinyglobby": "^0.2.15", + "playwright": "^1.61.0", + "tar-fs": "^3.1.2", + "tinyglobby": "^0.2.17", "vscode-uri": "^3.1.0" }, "bin": { @@ -7129,10 +7178,50 @@ "node": ">=20" } }, + "node_modules/@vscode/test-web/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vscode/test-web/node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7145,13 +7234,14 @@ } }, "node_modules/@vscode/test-web/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "dev": true, "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -7415,10 +7505,11 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-assertions": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", - "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "node_modules/acorn-import-attributes": { + "version": "1.9.5", + "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", + "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "license": "MIT", "peerDependencies": { "acorn": "^8" } @@ -7474,8 +7565,24 @@ } } }, - "node_modules/ansi-regex": { - "version": "5.0.1", + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "engines": { @@ -7524,9 +7631,9 @@ } }, "node_modules/applicationinsights": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.7.tgz", - "integrity": "sha512-dxIVB2AAEMec3FDiYThgEbc9R4u1TatrzL+kFgOf+ABaEgHm8+i8ngVLHfKObjHvy2HHPf810OLWTrqyeWT/oA==", + "version": "2.9.8", + "resolved": "https://registry.npmjs.org/applicationinsights/-/applicationinsights-2.9.8.tgz", + "integrity": "sha512-eB/EtAXJ6mDLLvHrtZj/7h31qUfnC2Npr2pHGqds5+1OP7BFLsn5us+HCkwTj7Q+1sHXujLphE5Cyvq5grtV6g==", "license": "MIT", "dependencies": { "@azure/core-auth": "1.7.2", @@ -7727,11 +7834,19 @@ } }, "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", "dev": true, - "license": "Apache-2.0" + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/balanced-match": { "version": "1.0.2", @@ -7740,20 +7855,26 @@ "dev": true }, "node_modules/bare-events": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.6.0.tgz", - "integrity": "sha512-EKZ5BTXYExaNqi3I3f9RtEsaI/xBSGjE0XZCZilPzFAV/goswFHuPd9jEZlPIZ/iNZJwDSao9qRiScySz7MbQg==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.5.5", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz", - "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", @@ -7774,42 +7895,45 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.2.tgz", + "integrity": "sha512-h530JsrkYi8518ZfR57GHaLoI5YzXkGGEV0Y+mf4KYPBn4OnNajiznwkDq7FgE+Vnmyss9Utnzi44y7sowiAXA==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -7819,12 +7943,11 @@ } }, "node_modules/bare-url": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz", - "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-path": "^3.0.0" } @@ -7978,13 +8101,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "dev": true }, - "node_modules/boolean": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", - "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", - "dev": true, - "optional": true - }, "node_modules/boundary": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boundary/-/boundary-2.0.0.tgz", @@ -8180,43 +8296,6 @@ "node": ">=8" } }, - "node_modules/cacheable-lookup": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", - "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", - "dev": true, - "engines": { - "node": ">=10.6.0" - } - }, - "node_modules/cacheable-request": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", - "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", - "dev": true, - "dependencies": { - "clone-response": "^1.0.2", - "get-stream": "^5.1.0", - "http-cache-semantics": "^4.0.0", - "keyv": "^4.0.0", - "lowercase-keys": "^2.0.0", - "normalize-url": "^6.0.1", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cacheable-request/node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -8378,9 +8457,10 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==" + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" }, "node_modules/cli-cursor": { "version": "5.0.0", @@ -8458,18 +8538,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/clone-response": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", - "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", - "dev": true, - "dependencies": { - "mimic-response": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cls-hooked": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/cls-hooked/-/cls-hooked-4.2.2.tgz", @@ -8927,6 +8995,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "dev": true, + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -8942,6 +9011,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "dev": true, + "optional": true, "engines": { "node": ">=10" }, @@ -8994,15 +9064,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -9096,13 +9157,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "optional": true - }, "node_modules/diagnostic-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/diagnostic-channel/-/diagnostic-channel-1.1.1.tgz", @@ -9273,24 +9327,41 @@ "license": "MIT" }, "node_modules/electron": { - "version": "39.8.5", - "resolved": "https://registry.npmjs.org/electron/-/electron-39.8.5.tgz", - "integrity": "sha512-q6+LiQIcTadSyvtPgLDQkCtVA9jQJXQVMrQcctfOJILh6OFMN+UJJLRkuUTy8CZDYeCIBn1ZycqsL1dAXugxZA==", + "version": "42.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.0.tgz", + "integrity": "sha512-cYEKS9XFz+c9fAB4jI0x49yz1FFzB55r3q96wu9YkwwJMv7t9202IE/ltlgy6yitl/J4M7C8JQcmUqdzDvPl/w==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { - "@electron/get": "^2.0.0", - "@types/node": "^22.7.7", - "extract-zip": "^2.0.1" + "@electron-internal/extract-zip": "^1.0.1", + "@electron/get": "^5.0.0", + "@types/node": "^24.9.0" }, "bin": { - "electron": "cli.js" + "electron": "cli.js", + "install-electron": "install.js" }, "engines": { - "node": ">= 12.20.55" + "node": ">= 22.12.0" } }, + "node_modules/electron/node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/electron/node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/embla-carousel": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", @@ -9376,12 +9447,29 @@ } }, "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", "dev": true, + "license": "MIT", "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/error-ex": { @@ -9533,13 +9621,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es6-error": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", - "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", - "dev": true, - "optional": true - }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -9651,20 +9732,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/estraverse": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", @@ -9694,6 +9761,16 @@ "node": ">= 0.6" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -9864,26 +9941,6 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9951,15 +10008,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "dev": true, - "dependencies": { - "pend": "~1.2.0" - } - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -10070,17 +10118,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -10112,20 +10160,6 @@ "dev": true, "optional": true }, - "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -10286,21 +10320,6 @@ "node": ">= 0.4" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -10360,24 +10379,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/global-agent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", - "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "es6-error": "^4.1.1", - "matcher": "^3.0.0", - "roarr": "^2.15.3", - "semver": "^7.3.2", - "serialize-error": "^7.0.1" - }, - "engines": { - "node": ">=10.0" - } - }, "node_modules/globalthis": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", @@ -10454,31 +10455,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "11.8.6", - "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", - "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^4.0.0", - "@szmarczak/http-timer": "^4.0.5", - "@types/cacheable-request": "^6.0.1", - "@types/responselike": "^1.0.0", - "cacheable-lookup": "^5.0.3", - "cacheable-request": "^7.0.2", - "decompress-response": "^6.0.0", - "http2-wrapper": "^1.0.0-beta.5.2", - "lowercase-keys": "^2.0.0", - "p-cancelable": "^2.0.0", - "responselike": "^2.0.0" - }, - "engines": { - "node": ">=10.19.0" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -10602,9 +10578,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -10732,12 +10708,6 @@ "node": ">= 0.6" } }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true - }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -10770,19 +10740,6 @@ "node": ">= 14" } }, - "node_modules/http2-wrapper": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", - "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", - "dev": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.0.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -10833,14 +10790,15 @@ "dev": true }, "node_modules/import-in-the-middle": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.4.2.tgz", - "integrity": "sha512-9WOz1Yh/cvO/p69sxRmhyQwrIGGSp7EIdcb+fFNVi7CzQGQB8U1/1XrKVSbEd/GNOAeM0peJtmi7+qphe7NvAw==", + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-2.0.6.tgz", + "integrity": "sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==", + "license": "Apache-2.0", "dependencies": { - "acorn": "^8.8.2", - "acorn-import-assertions": "^1.9.0", - "cjs-module-lexer": "^1.2.2", - "module-details-from-path": "^1.0.3" + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" } }, "node_modules/inflight": { @@ -11012,6 +10970,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -11559,10 +11518,20 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -11580,13 +11549,6 @@ "bignumber.js": "^9.0.0" } }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, "node_modules/json-parse-better-errors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", @@ -11628,28 +11590,12 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, - "optional": true - }, "node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jsonwebtoken": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", @@ -11772,9 +11718,9 @@ } }, "node_modules/koa": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.2.tgz", - "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", "dev": true, "license": "MIT", "dependencies": { @@ -11919,16 +11865,20 @@ } }, "node_modules/koa/node_modules/mime-types": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", - "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/leven": { @@ -12221,9 +12171,20 @@ } }, "node_modules/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } @@ -12374,15 +12335,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowercase-keys": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", - "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/lru-cache": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", @@ -12431,14 +12383,24 @@ } }, "node_modules/markdown-it": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz", - "integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", - "linkify-it": "^5.0.0", + "linkify-it": "^5.0.1", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" @@ -12447,19 +12409,6 @@ "markdown-it": "bin/markdown-it.mjs" } }, - "node_modules/matcher": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", - "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", - "dev": true, - "optional": true, - "dependencies": { - "escape-string-regexp": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -12599,15 +12548,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/mimic-response": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", - "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -12958,9 +12898,10 @@ } }, "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" }, "node_modules/monaco-editor": { "version": "0.44.0", @@ -13236,18 +13177,6 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", - "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -13757,15 +13686,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/p-cancelable": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", - "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -13936,7 +13856,8 @@ "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true }, "node_modules/path-scurry": { "version": "2.0.0", @@ -14011,7 +13932,8 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/picocolors": { "version": "1.1.1", @@ -14052,13 +13974,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -14071,9 +13993,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", - "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -14098,19 +14020,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/playwright/node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", @@ -14242,6 +14151,7 @@ "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.4.0" } @@ -14280,24 +14190,23 @@ "license": "MIT" }, "node_modules/protobufjs": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.8.tgz", - "integrity": "sha512-dvpCIeLPbXZS/Ete7yLaO7RenOdken2NHKykBXbsaGxZT0UTltcarBciw+A78SRQs9iMAAVpsYA+l8b1hTePIA==", + "version": "7.6.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.4.tgz", + "integrity": "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==", "hasInstallScript": true, "license": "BSD-3-Clause", "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", - "long": "^5.0.0" + "long": "^5.3.2" }, "engines": { "node": ">=12.0.0" @@ -14316,6 +14225,24 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -14392,18 +14319,6 @@ } ] }, - "node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14698,22 +14613,23 @@ } }, "node_modules/require-in-the-middle": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-7.2.0.tgz", - "integrity": "sha512-3TLx5TGyAY6AOqLBoXmHkNql0HIf2RGbuMgCDT2WO/uGVAPJs6h7Kl+bN6TIZGd9bWhWPwnDnTHGtW8Iu77sdw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" }, "engines": { - "node": ">=8.6.0" + "node": ">=9.3.0 || >=8.10.0 <9.0.0" } }, "node_modules/resolve": { "version": "1.22.6", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.6.tgz", "integrity": "sha512-njhxM7mV12JfufShqGy3Rz8j11RPdLy4xi15UurGJeoHLfJpVXKdh3ueuOqbYUcDZnffr6X739JBo5LzyahEsw==", + "dev": true, "dependencies": { "is-core-module": "^2.13.0", "path-parse": "^1.0.7", @@ -14726,12 +14642,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true - }, "node_modules/resolve-path": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/resolve-path/-/resolve-path-1.4.0.tgz", @@ -14796,18 +14706,6 @@ "node": ">= 0.6" } }, - "node_modules/responselike": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", - "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", - "dev": true, - "dependencies": { - "lowercase-keys": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -14855,24 +14753,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/roarr": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", - "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", - "dev": true, - "optional": true, - "dependencies": { - "boolean": "^3.0.1", - "detect-node": "^2.0.4", - "globalthis": "^1.0.1", - "json-stringify-safe": "^5.0.1", - "semver-compare": "^1.0.0", - "sprintf-js": "^1.1.2" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -15175,13 +15055,6 @@ "node": ">=10" } }, - "node_modules/semver-compare": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", - "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", - "dev": true, - "optional": true - }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -15242,35 +15115,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/serialize-error": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", - "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", - "dev": true, - "optional": true, - "dependencies": { - "type-fest": "^0.13.1" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/serialize-error/node_modules/type-fest": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", - "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/serialize-javascript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", @@ -15585,6 +15429,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15646,13 +15508,6 @@ "integrity": "sha512-eWN+LnM3GR6gPu35WxNgbGl8rmY1AEmoMDvL/QD6zYmPWgywxWqJWNdLGT+ke8dKNWrcYgYjPpG5gbTfghP8rw==", "dev": true }, - "node_modules/sprintf-js": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", - "dev": true, - "optional": true - }, "node_modules/stack-chain": { "version": "1.3.7", "resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz", @@ -15716,17 +15571,15 @@ "license": "MIT" }, "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -15953,6 +15806,7 @@ "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", "integrity": "sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==", "dev": true, + "license": "Apache-2.0", "dependencies": { "debug": "^4.1.0" }, @@ -15973,9 +15827,9 @@ } }, "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz", + "integrity": "sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==", "dev": true, "license": "MIT", "dependencies": { @@ -15983,13 +15837,17 @@ "supports-color": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.18" + }, + "funding": { + "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "engines": { "node": ">= 0.4" }, @@ -16027,24 +15885,6 @@ "dev": true, "license": "MIT" }, - "node_modules/table/node_modules/slice-ansi": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", - "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "astral-regex": "^2.0.0", - "is-fullwidth-code-point": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/table/node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -16215,56 +16055,37 @@ "resolved": "https://registry.npmjs.org/tas-client/-/tas-client-0.2.33.tgz", "integrity": "sha512-V+uqV66BOQnWxvI6HjDnE4VkInmYZUQ4dgB7gzaDyFyFSK1i1nF/j7DpS9UbQAgV9NaF1XpcyuavnM1qOeiEIg==" }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "node_modules/teex": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", + "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "streamx": "^2.12.5" } }, - "node_modules/terminal-link/node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "node_modules/terminal-link": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", + "integrity": "sha512-lk+vH+MccxNqgVqSnkMVKx4VLJfnLjDBGzH16JVZjKE2DoxP57s6/vt6JmXV5I3jBcfGrxNrYtC+mPtU7WJztA==", "dev": true, "license": "MIT", "dependencies": { - "type-fest": "^0.21.3" + "ansi-escapes": "^7.0.0", + "supports-hyperlinks": "^3.2.0" }, "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/terminal-link/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz", + "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -16275,7 +16096,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/textextensions": { "version": "6.11.0", @@ -16653,9 +16475,9 @@ "license": "MIT" }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -16680,15 +16502,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -17307,9 +17120,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17468,13 +17281,16 @@ } }, "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz", + "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==", "dev": true, + "license": "MIT", "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" + "pend": "~1.2.0" + }, + "engines": { + "node": ">=12" } }, "node_modules/yazl": { diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 45091436218e04..c1616a8e0cf3f2 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2,7 +2,7 @@ "name": "copilot-chat", "displayName": "GitHub Copilot", "description": "AI chat features powered by Copilot", - "version": "0.56.0", + "version": "0.57.0", "build": "1", "completionsCoreVersion": "1.378.1799", "internalLargeStorageAriaKey": "ec712b3202c5462fb6877acae7f1f9d7-c19ad55e-3e3c-4f99-984b-827f6d95bd9e-6917", @@ -1826,6 +1826,11 @@ "secret": true, "description": "API key for OpenAI", "title": "API Key" + }, + "zeroDataRetentionEnabled": { + "type": "boolean", + "default": false, + "markdownDescription": "Whether Zero Data Retention (ZDR) is enabled for this provider group. When `true`, OpenAI Responses requests from this group do not send `previous_response_id`." } }, "required": [ @@ -1913,12 +1918,16 @@ }, "maxInputTokens": { "type": "number", - "description": "Maximum number of input tokens supported by the model" + "markdownDescription": "Maximum number of input (prompt) tokens supported by the model. Optional when `contextWindow` is set, in which case it is derived as `contextWindow - maxOutputTokens`." }, "maxOutputTokens": { "type": "number", "description": "Maximum number of output tokens supported by the model" }, + "contextWindow": { + "type": "number", + "markdownDescription": "The model's full context window (input + output) in tokens, e.g. `1000000` for a 1M model. When set it is the source of truth for the context window and `maxInputTokens` can be omitted. Otherwise the window is derived as `maxInputTokens + maxOutputTokens`." + }, "editTools": { "type": "array", "description": "List of edit tools supported by the model. If this is not configured, the editor will try multiple edit tools and pick the best one.\n\n- 'find-replace': Find and replace text in a document.\n- 'multi-find-replace': Find and replace text in a document.\n- 'apply-patch': A file-oriented diff format used by some OpenAI models\n- 'code-rewrite': A general but slower editing tool that allows the model to rewrite and code snippet and provide only the replacement to the editor.", @@ -1976,8 +1985,19 @@ "url", "toolCalling", "vision", - "maxInputTokens", "maxOutputTokens" + ], + "anyOf": [ + { + "required": [ + "maxInputTokens" + ] + }, + { + "required": [ + "contextWindow" + ] + } ] } } @@ -2084,12 +2104,16 @@ }, "maxInputTokens": { "type": "number", - "description": "Maximum number of input tokens supported by the model" + "markdownDescription": "Maximum number of input (prompt) tokens supported by the model. Optional when `contextWindow` is set, in which case it is derived as `contextWindow - maxOutputTokens`." }, "maxOutputTokens": { "type": "number", "description": "Maximum number of output tokens supported by the model" }, + "contextWindow": { + "type": "number", + "markdownDescription": "The model's full context window (input + output) in tokens, e.g. `1000000` for a 1M model. When set it is the source of truth for the context window and `maxInputTokens` can be omitted. Otherwise the window is derived as `maxInputTokens + maxOutputTokens`." + }, "editTools": { "type": "array", "description": "List of edit tools supported by the model. If this is not configured, the editor will try multiple edit tools and pick the best one.\n\n- 'find-replace': Find and replace text in a document.\n- 'multi-find-replace': Find and replace text in a document.\n- 'apply-patch': A file-oriented diff format used by some OpenAI models\n- 'code-rewrite': A general but slower editing tool that allows the model to rewrite and code snippet and provide only the replacement to the editor.", @@ -2139,6 +2163,30 @@ "additionalProperties": { "type": "string" } + }, + "modelOptions": { + "type": "object", + "markdownDescription": "Sampling parameters to send with requests to this model. These override Copilot's defaults but are overridden by explicit per-request values. Set a property to `null` to omit it and use the model server's default.", + "properties": { + "temperature": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "markdownDescription": "Sampling temperature. Set to `null` to omit the parameter." + }, + "top_p": { + "type": [ + "number", + "null" + ], + "minimum": 0, + "maximum": 1, + "markdownDescription": "Nucleus sampling probability. Set to `null` to omit the parameter." + } + }, + "additionalProperties": false } }, "required": [ @@ -2147,8 +2195,19 @@ "url", "toolCalling", "vision", - "maxInputTokens", "maxOutputTokens" + ], + "anyOf": [ + { + "required": [ + "maxInputTokens" + ] + }, + { + "required": [ + "contextWindow" + ] + } ] } } @@ -2211,12 +2270,16 @@ }, "maxInputTokens": { "type": "number", - "description": "Maximum number of input tokens supported by the model" + "markdownDescription": "Maximum number of input (prompt) tokens supported by the model. Optional when `contextWindow` is set, in which case it is derived as `contextWindow - maxOutputTokens`." }, "maxOutputTokens": { "type": "number", "description": "Maximum number of output tokens supported by the model" }, + "contextWindow": { + "type": "number", + "markdownDescription": "The model's full context window (input + output) in tokens, e.g. `1000000` for a 1M model. When set it is the source of truth for the context window and `maxInputTokens` can be omitted. Otherwise the window is derived as `maxInputTokens + maxOutputTokens`." + }, "thinking": { "type": "boolean", "default": false, @@ -2261,8 +2324,19 @@ "url", "toolCalling", "vision", - "maxInputTokens", "maxOutputTokens" + ], + "anyOf": [ + { + "required": [ + "maxInputTokens" + ] + }, + { + "required": [ + "contextWindow" + ] + } ] } } @@ -3162,6 +3236,14 @@ "default": true, "markdownDescription": "%github.copilot.config.cloudAgent.enabled%" }, + "github.copilot.chat.localIndex.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.localIndex.enabled%", + "tags": [ + "onExp" + ] + }, "github.copilot.chat.codeGeneration.useInstructionFiles": { "type": "boolean", "default": true, @@ -3335,6 +3417,11 @@ "default": true, "description": "%github.copilot.config.agent.currentEditorContext.enabled%" }, + "github.copilot.chat.preferLongContext.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.preferLongContext.enabled%" + }, "github.copilot.enable": { "type": "object", "scope": "window", @@ -3455,6 +3542,11 @@ "tags": [ "onExp" ] + }, + "github.copilot.chat.imageUpload.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "%github.copilot.config.imageUpload.enabled%" } } }, @@ -3600,15 +3692,6 @@ "onExp" ] }, - "github.copilot.chat.imageUpload.enabled": { - "type": "boolean", - "default": true, - "tags": [ - "experimental", - "onExp" - ], - "markdownDescription": "%github.copilot.config.imageUpload.enabled%" - }, "github.copilot.chat.codeGeneration.instructions": { "markdownDeprecationMessage": "%github.copilot.config.codeGeneration.instructions.deprecated%", "type": "array", @@ -4043,19 +4126,6 @@ "clear-both" ] }, - "github.copilot.chat.responsesApiReasoningSummary": { - "type": "string", - "default": "detailed", - "markdownDescription": "%github.copilot.config.responsesApiReasoningSummary%", - "tags": [ - "experimental", - "onExp" - ], - "enum": [ - "off", - "detailed" - ] - }, "github.copilot.chat.responsesApiContextManagement.enabled": { "type": "boolean", "default": false, @@ -4074,10 +4144,10 @@ "onExp" ] }, - "github.copilot.chat.responsesApi.persistentCoT.enabled": { + "github.copilot.chat.responsesApi.promptCacheBreakpoint.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%github.copilot.config.responsesApi.persistentCoT.enabled%", + "markdownDescription": "%github.copilot.config.responsesApi.promptCacheBreakpoint.enabled%", "tags": [ "experimental", "onExp" @@ -4092,10 +4162,19 @@ "onExp" ] }, - "github.copilot.chat.claude47OpusPrompt.enabled": { + "github.copilot.chat.claude48OpusPrompt.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.claude48OpusPrompt.enabled%", + "tags": [ + "experimental", + "onExp" + ] + }, + "github.copilot.chat.claudeSonnet5Prompt.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%github.copilot.config.claude47OpusPrompt.enabled%", + "markdownDescription": "%github.copilot.config.claudeSonnet5Prompt.enabled%", "tags": [ "experimental", "onExp" @@ -4297,15 +4376,6 @@ "experimental" ] }, - "github.copilot.chat.localIndex.enabled": { - "type": "boolean", - "default": false, - "markdownDescription": "%github.copilot.config.localIndex.enabled%", - "tags": [ - "experimental", - "onExp" - ] - }, "github.copilot.chat.conversationCompaction.usePrismCompaction": { "type": "boolean", "default": false, @@ -4335,7 +4405,7 @@ }, "github.copilot.chat.tools.grepSearch.outputFormat": { "type": "string", - "default": "tag", + "default": "grep", "enum": [ "grep", "tag" @@ -4345,6 +4415,24 @@ "experimental", "onExp" ] + }, + "github.copilot.chat.tools.grepSearch.defaultMaxResults": { + "type": "number", + "default": 20, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.defaultMaxResults%", + "tags": [ + "experimental", + "onExp" + ] + }, + "github.copilot.chat.tools.grepSearch.maxResultsCap": { + "type": "number", + "default": 200, + "markdownDescription": "%github.copilot.chat.tools.grepSearch.maxResultsCap%", + "tags": [ + "experimental", + "onExp" + ] } } }, @@ -7127,16 +7215,16 @@ "@vscode/lsif-language-service": "^0.1.0-pre.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/test-web": "^0.0.80", + "@vscode/test-web": "^0.0.81", "@vscode/vsce": "3.6.0", "copyfiles": "^2.4.1", "csv-parse": "^6.0.0", "dotenv": "^17.2.0", - "electron": "^39.8.5", + "electron": "^42.5.0", "esbuild": "0.28.1", "fastq": "^1.19.1", "glob": "^11.1.0", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.0", "minimist": "^1.2.8", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", @@ -7149,7 +7237,7 @@ "openai": "^6.7.0", "outdent": "^0.8.0", "picomatch": "^4.0.4", - "playwright": "^1.58.2", + "playwright": "^1.61.1", "prettier": "^3.6.2", "react": "^17.0.2", "react-dom": "17.0.2", @@ -7175,22 +7263,22 @@ "@anthropic-ai/claude-agent-sdk": "0.2.112", "@anthropic-ai/sdk": "^0.82.0", "@github/blackbird-external-ingest-utils": "^0.3.0", - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.69-0", "@google/genai": "^1.22.0", "@humanwhocodes/gitignore-to-minimatch": "1.0.2", "@microsoft/tiktokenizer": "^1.0.10", "@modelcontextprotocol/sdk": "^1.25.2", "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.212.0", - "@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.214.0", - "@opentelemetry/exporter-logs-otlp-proto": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-http": "^0.214.0", - "@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", - "@opentelemetry/exporter-trace-otlp-proto": "^0.214.0", + "@opentelemetry/exporter-logs-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.219.0", + "@opentelemetry/exporter-logs-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-http": "^0.219.0", + "@opentelemetry/exporter-metrics-otlp-proto": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-grpc": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.219.0", + "@opentelemetry/exporter-trace-otlp-proto": "^0.219.0", "@opentelemetry/resources": "^2.5.1", "@opentelemetry/sdk-logs": "^0.212.0", "@opentelemetry/sdk-metrics": "^2.5.1", @@ -7213,7 +7301,7 @@ "isbinaryfile": "^5.0.4", "jsonc-parser": "^3.3.1", "lru-cache": "^11.1.0", - "markdown-it": "^14.1.1", + "markdown-it": "^14.2.0", "minimatch": "^10.2.1", "undici": "^7.24.1", "vscode-tas-client": "^0.1.84", @@ -7221,6 +7309,7 @@ }, "overrides": { "string_decoder": "npm:string_decoder@1.2.0", + "yauzl": "^3.3.1", "zod": "3.25.76" }, "vscodeCommit": "94c8e2adc50e26ef70af85a0de3a9efed757acaa", diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 0a4569e6442329..2e291010fea41b 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -278,7 +278,9 @@ "github.copilot.config.autoFix": "Automatically fix diagnostics for edited files.", "github.copilot.config.rateLimitAutoSwitchToAuto": "Automatically switch to the Auto model and retry when you hit a per-model rate limit.", "github.copilot.tools.createNewWorkspace.userDescription": "Scaffold a new workspace in VS Code", - "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'tag'.", + "github.copilot.chat.tools.grepSearch.outputFormat": "The output format for the grep search tool. Can be either 'grep' or 'tag'. The default is 'grep'.", + "github.copilot.chat.tools.grepSearch.defaultMaxResults": "The default maximum number of results to return from the grep search tool. The default is 20.", + "github.copilot.chat.tools.grepSearch.maxResultsCap": "The maximum number of results that can be returned from the grep search tool. The default is 200.", "copilot.tools.errors.description": "Check errors for a particular file", "copilot.tools.applyPatch.description": "Edit text files in the workspace", "copilot.tools.findTestFiles.description": "For a source code file, find the file that contains the tests. For a test file, find the file that contains the code under test", @@ -325,6 +327,7 @@ "copilot.tools.createDirectory.name": "Create Directory", "copilot.tools.createDirectory.description": "Create new directories in your workspace", "github.copilot.config.agent.currentEditorContext.enabled": "When enabled, Copilot will include the name of the current active editor in the context for agent mode.", + "github.copilot.config.preferLongContext.enabled": "When enabled, models that offer their full (long) context window at no additional cost will only show the long context option in the model picker and use it by default. When disabled, both the smaller default and larger long context options are shown so you can select a smaller context window.", "github.copilot.config.customInstructionsInSystemMessage": "When enabled, custom instructions and mode instructions will be appended to the system message instead of a user message.", "github.copilot.config.organizationCustomAgents.enabled": "When enabled, Copilot will load custom agents defined by your GitHub Organization.", "github.copilot.config.organizationInstructions.enabled": "When enabled, Copilot will load custom instructions defined by your GitHub Organization.", @@ -344,12 +347,12 @@ "github.copilot.config.anthropic.promptCaching.extendedTtlMessages": "Also extend the 1 hour prompt cache TTL to message-level breakpoints. Requires `chat.anthropic.promptCaching.extendedTtl` to be enabled; has no effect on its own.\n\n**Note**: This is an experimental feature.", "github.copilot.config.modelCapabilityOverrides": "Per-model capability overrides keyed by model id, intended for evaluating preview and tenanted models against an existing model's capability profile. For each model id, declare an aliased `family`. Setting `family` to a known production family (e.g. `\"claude-opus-4.7\"`) makes the model receive that family's full capability profile — Anthropic family detection, latest Opus prompt, multi-replace tools, tool search, context editing, extended cache TTL — without a code change.\n\n**Note**: This is an advanced setting for evaluation use; it is not intended for regular end-user configuration.", "github.copilot.config.useResponsesApi": "Use the Responses API instead of the Chat Completions API when supported. Enables reasoning and reasoning summaries.\n\n**Note**: This is an experimental feature that is not yet activated for all users.\n\n**Important**: For Custom Endpoint models, the API type is independent of this setting and is determined per-model via the `apiType` property, or inferred from the `url` path when omitted.", - "github.copilot.config.responsesApiReasoningSummary": "Sets the reasoning summary style used for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApiContextManagement.enabled": "Enables context management for the Responses API. Requires `#github.copilot.chat.useResponsesApi#`.", "github.copilot.config.responsesApi.promptCacheKey.enabled": "Enables prompt cache key being set for the Responses API.", - "github.copilot.config.responsesApi.persistentCoT.enabled": "Enables persistent chain of thought for supported Responses API models.", + "github.copilot.config.responsesApi.promptCacheBreakpoint.enabled": "Enables explicit prompt cache breakpoint markers for the Responses API.", "github.copilot.config.updated53CodexPrompt.enabled": "Enables the updated prompt for gpt-5.3-codex model.", - "github.copilot.config.claude47OpusPrompt.enabled": "Enables the updated system prompt tuned for the Claude Opus 4.7 model.", + "github.copilot.config.claude48OpusPrompt.enabled": "Enables the updated system prompt tuned for the Claude Opus 4.8 model.", + "github.copilot.config.claudeSonnet5Prompt.enabled": "Enables the updated system prompt tuned for the Claude Sonnet 5 model.", "github.copilot.config.gpt55GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gpt-5.5 models.", "github.copilot.config.gemini3GetChangedFilesTool.enabled": "Enables the Get Changed Files tool for gemini-3 models.", "github.copilot.config.gemini3LowReasoningEffort.enabled": "Sets the reasoning effort to low for gemini-3 models.", diff --git a/extensions/copilot/script/alternativeAction/index.ts b/extensions/copilot/script/alternativeAction/index.ts index 8f90cd8fa273d5..93bc883633bba1 100644 --- a/extensions/copilot/script/alternativeAction/index.ts +++ b/extensions/copilot/script/alternativeAction/index.ts @@ -46,7 +46,7 @@ async function extractFromCsv(csvContents: string): Promise<(Scoring.t | undefin if (!altAction || !altAction.recording) { return undefined; } - return Processor.createScoringForAlternativeAction(altAction, coalesce([parseSuggestedEdit(obj.postProcessingOutcome.suggestedEdit)]), false); + return Processor.createScoring(altAction.recording.entries ?? [], altAction.recording.requestTime, coalesce([parseSuggestedEdit(obj.postProcessingOutcome.suggestedEdit)]), false); }); return scoredEdits; @@ -113,7 +113,12 @@ async function handleAlternativeActionJson(inputFilePath: string) { } isAccepted = data.suggestionStatus === 'accepted'; } - const scoring = Processor.createScoringForAlternativeAction(altAction, edits, isAccepted); + const altActionRecording = altAction.recording; + if (!altActionRecording) { + console.error('Alternative action has no recording'); + return; + } + const scoring = Processor.createScoring(altActionRecording.entries ?? [], altActionRecording.requestTime, edits, isAccepted); if (!scoring) { console.error('Failed to create scoring from alternative action'); return; diff --git a/extensions/copilot/script/setup/getEnv.mts b/extensions/copilot/script/setup/getEnv.mts index 0cd82c9a7371b6..dd04e6e7d2c3f4 100644 --- a/extensions/copilot/script/setup/getEnv.mts +++ b/extensions/copilot/script/setup/getEnv.mts @@ -57,8 +57,7 @@ async function fetchSecrets(): Promise<{ [key: string]: string | undefined }> { secrets['HMAC_SECRET'] = await fetchSecret(keyVaultClient, 'hmac-secret'); if (!process.stdin.isTTY) { // only in automation - secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth'); - secrets['VSCODE_COPILOT_CHAT_TOKEN'] = await fetchSecret(keyVaultClient, 'copilot-token'); + secrets['GITHUB_OAUTH_TOKEN'] = await fetchSecret(keyVaultClient, 'capi-oauth-pipeline-token'); secrets['BLACKBIRD_EMBEDDINGS_KEY'] = await fetchSecret(keyVaultClient, 'vsc-aoai-key'); secrets['BLACKBIRD_REDIS_CACHE_KEY'] = await fetchSecret(keyVaultClient, 'blackbird-redis-cache-key'); diff --git a/extensions/copilot/src/extension/byok/common/byokProvider.ts b/extensions/copilot/src/extension/byok/common/byokProvider.ts index a966f2e63c18df..a47b6d00637b63 100644 --- a/extensions/copilot/src/extension/byok/common/byokProvider.ts +++ b/extensions/copilot/src/extension/byok/common/byokProvider.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { Disposable, LanguageModelChatInformation, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart } from 'vscode'; import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; -import { EndpointEditToolName, IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; +import { EndpointEditToolName, IChatModelInformation, IChatModelRequestOptions, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { TokenizerType } from '../../../util/common/tokenizer'; export const enum BYOKAuthType { @@ -47,8 +47,19 @@ export type BYOKModelConfig = BYOKGlobalKeyModelConfig | BYOKPerModelConfig | BY export interface BYOKModelCapabilities { name: string; url?: string; - maxInputTokens: number; + /** + * The maximum number of prompt (input) tokens. Optional when {@link contextWindow} + * is supplied, in which case it is derived as `contextWindow - maxOutputTokens`. + */ + maxInputTokens?: number; maxOutputTokens: number; + /** + * The model's full context window (input + output) in tokens. Many providers + * publish this directly (e.g. "Context Length: 1M"). When set it is used as the + * source of truth for the context window; otherwise the window is derived as + * `maxInputTokens + maxOutputTokens` for backward compatibility. + */ + contextWindow?: number; toolCalling: boolean; vision: boolean; thinking?: boolean; @@ -56,6 +67,7 @@ export interface BYOKModelCapabilities { streaming?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record; + modelOptions?: IChatModelRequestOptions; supportedEndpoints?: ModelSupportedEndpoint[]; zeroDataRetentionEnabled?: boolean; supportsReasoningEffort?: string[]; @@ -92,6 +104,33 @@ export function isNoAuthConfig(config: BYOKModelConfig): config is BYOKNoAuthMod return !('apiKey' in config) && !('deploymentUrl' in config); } +/** + * Resolves a model's token limits from its BYOK capabilities, honoring an explicit + * {@link BYOKModelCapabilities.contextWindow} when provided. + * + * - When `contextWindow` is set it is the source of truth for the full window and, if + * `maxInputTokens` is omitted, the prompt budget is derived as + * `contextWindow - maxOutputTokens`. + * - Otherwise the window falls back to `maxInputTokens + maxOutputTokens` for backward + * compatibility. + * + * The returned limits are always internally consistent: `maxOutputTokens` is clamped so + * it never exceeds the context window, and `maxInputTokens` is clamped to the remaining + * budget (`contextWindow - maxOutputTokens`). This prevents invalid combinations such as + * `maxOutputTokens > contextWindow`, or a `maxInputTokens` supplied alongside a smaller + * `contextWindow` overflowing the window. + */ +export function resolveModelTokenLimits(capabilities: Pick): { contextWindow: number; maxInputTokens: number; maxOutputTokens: number } { + const contextWindow = capabilities.contextWindow ?? ((capabilities.maxInputTokens ?? 0) + capabilities.maxOutputTokens); + // The output budget can never exceed the full window. + const maxOutputTokens = Math.min(capabilities.maxOutputTokens, contextWindow); + // The prompt budget is whatever remains after the output reservation; an explicitly + // provided maxInputTokens is clamped to that remaining budget. + const remainingInputBudget = Math.max(0, contextWindow - maxOutputTokens); + const maxInputTokens = Math.min(capabilities.maxInputTokens ?? remainingInputBudget, remainingInputBudget); + return { contextWindow, maxInputTokens, maxOutputTokens }; +} + export function resolveModelInfo(modelId: string, providerName: string, knownModels: BYOKKnownModels | undefined, modelCapabilities?: BYOKModelCapabilities): IChatModelInformation { // Model Capabilities are something the user has decided on so those take precedence, then we rely on known model info, then defaults. let knownModelInfo = modelCapabilities; @@ -99,7 +138,9 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod knownModelInfo = knownModels[modelId]; } const modelName = knownModelInfo?.name || modelId; - const contextWinow = knownModelInfo ? (knownModelInfo.maxInputTokens + knownModelInfo.maxOutputTokens) : 128000; + const limits = knownModelInfo + ? resolveModelTokenLimits(knownModelInfo) + : { contextWindow: 128000, maxInputTokens: 100000, maxOutputTokens: 8192 }; const modelInfo: IChatModelInformation = { id: modelId, name: modelName, @@ -118,9 +159,9 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod }, tokenizer: TokenizerType.O200K, limits: { - max_context_window_tokens: contextWinow, - max_prompt_tokens: knownModelInfo?.maxInputTokens || 100000, - max_output_tokens: knownModelInfo?.maxOutputTokens || 8192 + max_context_window_tokens: limits.contextWindow, + max_prompt_tokens: limits.maxInputTokens, + max_output_tokens: limits.maxOutputTokens } }, is_chat_default: false, @@ -128,6 +169,7 @@ export function resolveModelInfo(modelId: string, providerName: string, knownMod model_picker_enabled: true, supported_endpoints: knownModelInfo?.supportedEndpoints, zeroDataRetentionEnabled: knownModelInfo?.zeroDataRetentionEnabled, + modelOptions: knownModelInfo?.modelOptions, reasoningEffortFormat: knownModelInfo?.reasoningEffortFormat }; if (knownModelInfo?.requestHeaders && Object.keys(knownModelInfo.requestHeaders).length > 0) { @@ -144,12 +186,13 @@ export function byokKnownModelsToAPIInfo(providerName: string, knownModels: BYOK } export function byokKnownModelToAPIInfo(providerName: string, id: string, capabilities: BYOKModelCapabilities): LanguageModelChatInformation { + const limits = resolveModelTokenLimits(capabilities); return { id, name: capabilities.name, version: '1.0.0', - maxOutputTokens: capabilities.maxOutputTokens, - maxInputTokens: capabilities.maxInputTokens, + maxOutputTokens: limits.maxOutputTokens, + maxInputTokens: limits.maxInputTokens, // `detail` is intentionally omitted: when this model is resolved // via a configured provider group, `LanguageModelsService` will // fall back to the group name so multiple instances of the same diff --git a/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts b/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts index 8a78e8e0e3f7e1..cd34a9381644d9 100644 --- a/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/common/test/byokProvider.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { CopilotToken } from '../../../../platform/authentication/common/copilotToken'; -import { byokKnownModelToAPIInfo, BYOKModelCapabilities, isClientBYOKAllowed, resolveModelInfo } from '../byokProvider'; +import { byokKnownModelToAPIInfo, BYOKModelCapabilities, isClientBYOKAllowed, resolveModelInfo, resolveModelTokenLimits } from '../byokProvider'; describe('byokKnownModelToAPIInfo', () => { const baseCapabilities: BYOKModelCapabilities = { @@ -43,6 +43,21 @@ describe('byokKnownModelToAPIInfo', () => { expect(info.capabilities.editTools).toBeUndefined(); }); + + it('derives maxInputTokens from contextWindow when maxInputTokens is omitted', () => { + // The value surfaced here becomes `model.maxInputTokens`, which the custom + // endpoint/OAI/azure providers read when building the endpoint. + const info = byokKnownModelToAPIInfo('TestProvider', 'm1', { + name: 'BigContextModel', + contextWindow: 1000000, + maxOutputTokens: 384000, + toolCalling: true, + vision: false, + }); + + expect(info.maxInputTokens).toBe(1000000 - 384000); + expect(info.maxOutputTokens).toBe(384000); + }); }); describe('resolveModelInfo', () => { @@ -71,6 +86,88 @@ describe('resolveModelInfo', () => { expect(info.capabilities.supports.reasoning_effort).toBeUndefined(); expect(info.reasoningEffortFormat).toBeUndefined(); }); + + it('propagates configured model options into chat-endpoint inputs', () => { + const info = resolveModelInfo('m1', 'TestProvider', undefined, { + ...baseCapabilities, + modelOptions: { + temperature: null, + top_p: 0.95, + }, + }); + + expect(info.modelOptions).toEqual({ + temperature: null, + top_p: 0.95, + }); + }); + + it('honors an explicit contextWindow as the source of truth for the context window', () => { + // A model documented as: Context Length 1M, Max Output 384K. The user can now + // declare the real capability directly instead of back-computing maxInputTokens. + const info = resolveModelInfo('m1', 'TestProvider', undefined, { + ...baseCapabilities, + contextWindow: 1000000, + maxOutputTokens: 384000, + maxInputTokens: undefined, + }); + + expect(info.capabilities.limits?.max_context_window_tokens).toBe(1000000); + // The prompt budget is derived as contextWindow - maxOutputTokens. + expect(info.capabilities.limits?.max_prompt_tokens).toBe(1000000 - 384000); + expect(info.capabilities.limits?.max_output_tokens).toBe(384000); + }); + + it('derives the context window as maxInputTokens + maxOutputTokens when contextWindow is absent', () => { + const info = resolveModelInfo('m1', 'TestProvider', undefined, { + ...baseCapabilities, + maxInputTokens: 616000, + maxOutputTokens: 384000, + }); + + expect(info.capabilities.limits?.max_context_window_tokens).toBe(616000 + 384000); + expect(info.capabilities.limits?.max_prompt_tokens).toBe(616000); + }); + + it('falls back to a 128000 context window when no capabilities are known', () => { + const info = resolveModelInfo('m1', 'TestProvider', undefined, undefined); + + expect(info.capabilities.limits?.max_context_window_tokens).toBe(128000); + }); +}); + +describe('resolveModelTokenLimits', () => { + it('derives the window from maxInputTokens + maxOutputTokens when contextWindow is absent', () => { + expect(resolveModelTokenLimits({ maxInputTokens: 616000, maxOutputTokens: 384000 })).toEqual({ + contextWindow: 1000000, + maxInputTokens: 616000, + maxOutputTokens: 384000, + }); + }); + + it('derives maxInputTokens from contextWindow when maxInputTokens is omitted', () => { + expect(resolveModelTokenLimits({ contextWindow: 1000000, maxOutputTokens: 384000 })).toEqual({ + contextWindow: 1000000, + maxInputTokens: 616000, + maxOutputTokens: 384000, + }); + }); + + it('clamps maxOutputTokens so it never exceeds the context window', () => { + expect(resolveModelTokenLimits({ contextWindow: 1000, maxOutputTokens: 8192 })).toEqual({ + contextWindow: 1000, + maxInputTokens: 0, + maxOutputTokens: 1000, + }); + }); + + it('clamps an explicit maxInputTokens to the remaining budget when it overflows the window', () => { + expect(resolveModelTokenLimits({ contextWindow: 1000000, maxInputTokens: 900000, maxOutputTokens: 384000 })).toEqual({ + contextWindow: 1000000, + maxInputTokens: 616000, + maxOutputTokens: 384000, + }); + }); }); describe('isClientBYOKAllowed', () => { diff --git a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts index 0cf838e3c235a4..aed5d1b64dd52f 100644 --- a/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts +++ b/extensions/copilot/src/extension/byok/node/openAIEndpoint.ts @@ -265,10 +265,10 @@ export class OpenAIEndpoint extends ChatEndpoint { body.previous_response_id = undefined; } this._applyReasoningEffort(body, options); - return body; + return this._applyConfiguredModelOptions(body, options); } else if (this.useMessagesApi) { // Delegate to base ChatEndpoint for Messages API dispatch - return super.createRequestBody(options); + return this._applyConfiguredModelOptions(super.createRequestBody(options), options); } else { // Handle Chat Completions: provide callback for thinking data processing const supportsThinking = !!this.modelMetadata.capabilities.supports.thinking; @@ -290,8 +290,32 @@ export class OpenAIEndpoint extends ChatEndpoint { }; const body = createCapiRequestBody(options, this.model, callback); this._applyReasoningEffort(body, options); + return this._applyConfiguredModelOptions(body, options); + } + } + + private _applyConfiguredModelOptions(body: IEndpointBody, options: ICreateEndpointBodyOptions): IEndpointBody { + const modelOptions = this.modelMetadata.modelOptions; + if (!modelOptions) { return body; } + + for (const key of ['temperature', 'top_p'] as const) { + const requestValue = options.requestOptions?.[key]; + if (requestValue !== undefined) { + body[key] = requestValue; + continue; + } + + const configuredValue = modelOptions[key]; + if (configuredValue === null) { + delete body[key]; + } else if (configuredValue !== undefined) { + body[key] = configuredValue; + } + } + + return body; } /** diff --git a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts index b9b9eed5184d42..e8716894eff285 100644 --- a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts +++ b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts @@ -7,8 +7,8 @@ import { Raw } from '@vscode/prompt-tsx'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ChatFetchResponseType, ChatResponse } from '../../../../platform/chat/common/commonTypes'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; -import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; +import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; import { ChatEndpoint } from '../../../../platform/endpoint/node/chatEndpoint'; import { ICreateEndpointBodyOptions, IEndpointBody, IMakeChatRequestOptions } from '../../../../platform/networking/common/networking'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; @@ -246,9 +246,19 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { describe('Responses API mode (useResponsesApi = true)', () => { it('should preserve reasoning object when thinking is supported', () => { - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); + const modelWithReasoningEffort = { + ...modelMetadata, + capabilities: { + ...modelMetadata.capabilities, + supports: { + ...modelMetadata.capabilities.supports, + reasoning_effort: ['low', 'medium', 'high'] + } + } + }; + const endpoint = instaService.createInstance(OpenAIEndpoint, - modelMetadata, + modelWithReasoningEffort, 'test-api-key', 'https://api.openai.com/v1/chat/completions'); @@ -275,7 +285,6 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { } }; - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiReasoningSummary, 'detailed'); const endpoint = instaService.createInstance(OpenAIEndpoint, modelWithoutThinking, 'test-api-key', diff --git a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts index c160d03e025747..0d42778c40c40e 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/azureProvider.ts @@ -131,6 +131,7 @@ export class AzureBYOKModelProvider extends AbstractCustomOAIBYOKModelProvider { const modelCapabilities = { maxInputTokens: model.maxInputTokens, maxOutputTokens: model.maxOutputTokens, + contextWindow: modelConfiguration?.contextWindow, toolCalling: !!model.capabilities?.toolCalling || false, vision: !!model.capabilities?.imageInput || false, name: model.name, diff --git a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts index db5df5f555f46f..565e6c05a8a0f5 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts @@ -6,7 +6,7 @@ import { IChatMLFetcher } from '../../../platform/chat/common/chatMLFetcher'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { IDomainService } from '../../../platform/endpoint/common/domainService'; -import { EndpointEditToolName, IChatModelInformation, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; +import { EndpointEditToolName, IChatModelInformation, IChatModelRequestOptions, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; import { IChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager'; @@ -90,14 +90,18 @@ interface _CustomEndpointModelConfig { name: string; url: string; apiType?: CustomEndpointApiType; - maxInputTokens: number; + /** Optional when {@link contextWindow} is set; then derived as `contextWindow - maxOutputTokens`. */ + maxInputTokens?: number; maxOutputTokens: number; + /** The model's full context window (input + output) in tokens, e.g. 1000000 for a 1M model. */ + contextWindow?: number; toolCalling: boolean; vision: boolean; thinking?: boolean; streaming?: boolean; editTools?: EndpointEditToolName[]; requestHeaders?: Record; + modelOptions?: IChatModelRequestOptions; zeroDataRetentionEnabled?: boolean; supportsReasoningEffort?: string[]; reasoningEffortFormat?: 'chat-completions' | 'responses'; @@ -152,6 +156,7 @@ export class CustomEndpointBYOKModelProvider extends AbstractOpenAICompatibleLMP const modelCapabilities = { maxInputTokens: model.maxInputTokens, maxOutputTokens: model.maxOutputTokens, + contextWindow: modelConfiguration?.contextWindow, toolCalling: !!model.capabilities?.toolCalling || false, vision: !!model.capabilities?.imageInput || false, name: model.name, @@ -159,6 +164,7 @@ export class CustomEndpointBYOKModelProvider extends AbstractOpenAICompatibleLMP thinking: modelConfiguration?.thinking ?? false, streaming: modelConfiguration?.streaming, requestHeaders: modelConfiguration?.requestHeaders, + modelOptions: modelConfiguration?.modelOptions, zeroDataRetentionEnabled: modelConfiguration?.zeroDataRetentionEnabled, supportsReasoningEffort: modelConfiguration?.supportsReasoningEffort, reasoningEffortFormat: modelConfiguration?.reasoningEffortFormat diff --git a/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts index 0237f910396e5d..318f14dedbae4f 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/customOAIProvider.ts @@ -53,8 +53,11 @@ export interface CustomOAIModelProviderConfig extends LanguageModelChatConfigura interface _CustomOAIModelConfig { name: string; url: string; - maxInputTokens: number; + /** Optional when {@link contextWindow} is set; then derived as `contextWindow - maxOutputTokens`. */ + maxInputTokens?: number; maxOutputTokens: number; + /** The model's full context window (input + output) in tokens, e.g. 1000000 for a 1M model. */ + contextWindow?: number; toolCalling: boolean; vision: boolean; thinking?: boolean; @@ -138,6 +141,7 @@ export abstract class AbstractCustomOAIBYOKModelProvider extends AbstractOpenAIC const modelCapabilities = { maxInputTokens: model.maxInputTokens, maxOutputTokens: model.maxOutputTokens, + contextWindow: modelConfiguration?.contextWindow, toolCalling: !!model.capabilities?.toolCalling || false, vision: !!model.capabilities?.imageInput || false, name: model.name, diff --git a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts index cc10693357616a..27df5d409e37fe 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/openAIProvider.ts @@ -9,10 +9,22 @@ import { IFetcherService } from '../../../platform/networking/common/fetcherServ import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { BYOKKnownModels } from '../common/byokProvider'; -import { AbstractOpenAICompatibleLMProvider } from './abstractLanguageModelChatProvider'; +import { OpenAIEndpoint } from '../node/openAIEndpoint'; +import { AbstractOpenAICompatibleLMProvider, LanguageModelChatConfiguration, OpenAICompatibleLanguageModelChatInformation } from './abstractLanguageModelChatProvider'; import { IBYOKStorageService } from './byokStorageService'; -export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { +export interface OpenAIProviderConfig extends LanguageModelChatConfiguration { + readonly zeroDataRetentionEnabled?: boolean; +} + +export function applyOpenAIProviderConfig(modelInfo: IChatModelInformation, configuration: OpenAIProviderConfig | undefined): IChatModelInformation { + return { + ...modelInfo, + zeroDataRetentionEnabled: configuration?.zeroDataRetentionEnabled ?? modelInfo.zeroDataRetentionEnabled, + }; +} + +export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { public static readonly providerName = 'OpenAI'; public static readonly providerId = this.providerName.toLowerCase(); @@ -43,6 +55,14 @@ export class OAIBYOKLMProvider extends AbstractOpenAICompatibleLMProvider { return 'https://api.openai.com/v1'; } + protected override async createOpenAIEndPoint(model: OpenAICompatibleLanguageModelChatInformation): Promise { + const modelInfo = applyOpenAIProviderConfig(this.getModelInfo(model.id, model.url), model.configuration); + const url = modelInfo.supported_endpoints?.includes(ModelSupportedEndpoint.Responses) ? + `${model.url}/responses` : + `${model.url}/chat/completions`; + return this._instantiationService.createInstance(OpenAIEndpoint, modelInfo, model.configuration?.apiKey ?? '', url); + } + protected override getModelInfo(modelId: string, modelUrl: string): IChatModelInformation { const modelInfo = super.getModelInfo(modelId, modelUrl); modelInfo.supported_endpoints = [ diff --git a/extensions/copilot/src/extension/byok/vscode-node/openRouterProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/openRouterProvider.ts index fe0e4c02716df0..02b9302b453c60 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/openRouterProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/openRouterProvider.ts @@ -25,11 +25,28 @@ interface OpenRouterModelData { architecture?: { input_modalities?: string[]; }; + /** + * The model's actual maximum context window, independent of which provider + * OpenRouter ranks highest. Prefer this over `top_provider.context_length`, + * which only reflects the primary provider and can be far smaller for + * multi-provider models. + * @see https://openrouter.ai/docs/guides/overview/models + */ + context_length?: number; top_provider: { context_length: number; + /** Maximum tokens the primary provider will produce in a response. */ + max_completion_tokens?: number; }; } +/** + * Fallback output-token budget used only when OpenRouter does not report + * `top_provider.max_completion_tokens` for a model. The value is heuristic — most + * tool-capable models do report an explicit budget, in which case this is unused. + */ +const DEFAULT_MAX_OUTPUT_TOKENS = 16_000; + export class OpenRouterLMProvider extends AbstractOpenAICompatibleLMProvider { public static readonly providerName = 'OpenRouter'; @@ -73,12 +90,21 @@ export class OpenRouterLMProvider extends AbstractOpenAICompatibleLMProvider { const supportsReasoningEffort = supportedParameters.includes('reasoning') || supportedParameters.includes('reasoning_effort') ? ['low', 'medium', 'high'] : undefined; + // Prefer the model-level `context_length` (the real capability) over + // `top_provider.context_length`, which only reflects OpenRouter's + // highest-ranked provider and can be much smaller for multi-provider models. + const contextWindow = openRouterModelData.context_length ?? openRouterModelData.top_provider.context_length; + // Reserve output tokens from the window. Clamp the reserve so a small-context + // model (or a missing/oversized `max_completion_tokens`) never yields a + // non-positive prompt budget. + const requestedMaxOutputTokens = openRouterModelData.top_provider.max_completion_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS; + const maxOutputTokens = Math.min(requestedMaxOutputTokens, Math.floor(contextWindow / 2)); return { name: openRouterModelData.name, toolCalling: supportedParameters.includes('tools'), vision: openRouterModelData.architecture?.input_modalities?.includes('image') ?? false, - maxInputTokens: openRouterModelData.top_provider.context_length - 16000, - maxOutputTokens: 16000, + maxInputTokens: contextWindow - maxOutputTokens, + maxOutputTokens, supportsReasoningEffort }; } diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts index 9a3197f855e508..0b62c122673477 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts @@ -6,6 +6,7 @@ import { Raw } from '@vscode/prompt-tsx'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { BlockedExtensionService, IBlockedExtensionService } from '../../../../platform/chat/common/blockedExtensionService'; +import { ChatLocation } from '../../../../platform/chat/common/commonTypes'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; import { TokenizerType } from '../../../../util/common/tokenizer'; @@ -278,6 +279,180 @@ describe('CustomEndpointBYOKModelProvider', () => { expect(endpoint.ownsAuthorization).toBe(true); }); + it('issue #321514: applies configured model options over default sampling parameters', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: 1, + top_p: 0.95, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-custom-model-options', + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: 1, + topP: 0.95, + }); + }); + + it('omits sampling parameters configured as null', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: null, + top_p: null, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-omitted-model-options', + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: undefined, + topP: undefined, + }); + }); + + it('keeps explicit per-request sampling parameters ahead of configured model options', () => { + const metadata: IChatModelInformation = { + ...makeMetadata(undefined), + modelOptions: { + temperature: 1, + top_p: null, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + 'https://api.example.com/v1/chat/completions'); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: 'test-req-explicit-model-options', + requestOptions: { + temperature: 0.7, + top_p: 0.9, + }, + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + expect({ + temperature: body.temperature, + topP: body.top_p, + }).toEqual({ + temperature: 0.7, + topP: 0.9, + }); + }); + + it('applies configured model options to Responses and Messages API bodies', () => { + const results = [ + { + supportedEndpoints: [ModelSupportedEndpoint.Responses], + url: 'https://api.example.com/v1/responses', + }, + { + supportedEndpoints: [ModelSupportedEndpoint.Messages], + url: 'https://api.example.com/v1/messages', + }, + ].map(({ supportedEndpoints, url }) => { + const metadata: IChatModelInformation = { + ...makeMetadata(supportedEndpoints), + modelOptions: { + temperature: 1, + top_p: 0.95, + }, + }; + const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, + metadata, + 'test-api-key', + url); + const body = endpoint.createRequestBody({ + debugName: 'test', + messages: [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }], + requestId: `test-req-${endpoint.apiType}-model-options`, + postOptions: { + temperature: 0.1, + top_p: 1, + stream: true, + }, + finishedCb: undefined, + location: ChatLocation.Other, + }); + + return { + apiType: endpoint.apiType, + temperature: body.temperature, + topP: body.top_p, + }; + }); + + expect(results).toEqual([ + { + apiType: 'responses', + temperature: 1, + topP: 0.95, + }, + { + apiType: 'messages', + temperature: 1, + topP: 0.95, + }, + ]); + }); + it('replaces default Bearer with user-supplied Authorization header on Chat Completions endpoints', () => { const metadata = makeMetadata(undefined); metadata.requestHeaders = { 'Authorization': 'Bearer user-token' }; diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts new file mode 100644 index 00000000000000..b56047aee93134 --- /dev/null +++ b/extensions/copilot/src/extension/byok/vscode-node/test/openAIProvider.spec.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { IChatModelInformation } from '../../../../platform/endpoint/common/endpointProvider'; +import { TokenizerType } from '../../../../util/common/tokenizer'; +import { applyOpenAIProviderConfig } from '../openAIProvider'; + +function createModelInfo(zeroDataRetentionEnabled: boolean | undefined): IChatModelInformation { + return { + id: 'gpt-4.1', + name: 'GPT-4.1', + vendor: 'OpenAI', + version: '1.0.0', + is_chat_default: false, + is_chat_fallback: false, + model_picker_enabled: true, + zeroDataRetentionEnabled, + capabilities: { + type: 'chat', + family: 'gpt-4.1', + supports: { + streaming: true, + tool_calls: true, + vision: false, + thinking: false, + }, + tokenizer: TokenizerType.O200K, + limits: { + max_context_window_tokens: 128000, + max_prompt_tokens: 100000, + max_output_tokens: 8192, + }, + }, + }; +} + +describe('applyOpenAIProviderConfig', () => { + it('uses provider-level zeroDataRetentionEnabled when configured', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(undefined), { + apiKey: 'test-key', + zeroDataRetentionEnabled: true, + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); + + it('falls back to model metadata zeroDataRetentionEnabled when provider-level value is unset', () => { + const merged = applyOpenAIProviderConfig(createModelInfo(true), { + apiKey: 'test-key', + }); + + expect(merged.zeroDataRetentionEnabled).toBe(true); + }); +}); diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/openRouterProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/openRouterProvider.spec.ts new file mode 100644 index 00000000000000..702a51d68e776e --- /dev/null +++ b/extensions/copilot/src/extension/byok/vscode-node/test/openRouterProvider.spec.ts @@ -0,0 +1,109 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, vi } from 'vitest'; +import { BYOKModelCapabilities } from '../../common/byokProvider'; +import { OpenRouterLMProvider } from '../openRouterProvider'; + +/** + * Tests for issue #324671: + * OpenRouter BYOK previously derived the context window from `top_provider.context_length`, + * which is the window of the highest-ranked provider — NOT the model's real capability + * (`context_length`). For multi-provider models this could be off by 32×. These tests + * verify the fix: the model-level `context_length` is preferred, with `top_provider` + * used only as a fallback. + */ + +/** Exposes the protected `resolveModelCapabilities` for focused testing. */ +class TestableOpenRouterLMProvider extends OpenRouterLMProvider { + public resolveCapabilities(modelData: unknown): BYOKModelCapabilities | undefined { + return this.resolveModelCapabilities(modelData); + } +} + +function createProvider(): TestableOpenRouterLMProvider { + const logService = { + trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), + show: vi.fn(), createSubLogger: vi.fn(), withExtraTarget: vi.fn(), + }; + logService.createSubLogger.mockReturnValue(logService); + logService.withExtraTarget.mockReturnValue(logService); + + return new TestableOpenRouterLMProvider( + { getAPIKey: vi.fn().mockResolvedValue(undefined), storeAPIKey: vi.fn(), deleteAPIKey: vi.fn() } as any, + { fetch: vi.fn() } as any, + logService as any, + { createInstance: vi.fn().mockReturnValue({}) } as any, + { isConfigured: vi.fn().mockReturnValue(false), getConfig: vi.fn(), setConfig: vi.fn() } as any, + {} as any, + ); +} + +describe('OpenRouterLMProvider context window (issue #324671)', () => { + it('derives maxInputTokens from the model-level context_length, not top_provider', () => { + const provider = createProvider(); + + // `xiaomi/mimo-v2.5`: the model supports 1M tokens, but the highest-ranked + // provider only serves 32K. The Xiaomi provider (1M) is not the top provider. + const caps = provider.resolveCapabilities({ + id: 'xiaomi/mimo-v2.5', + name: 'MiMo v2.5', + supported_parameters: ['tools'], + architecture: { input_modalities: ['text'] }, + context_length: 1048576, // actual model capability (1M) + top_provider: { context_length: 32000 }, // highest-ranked provider (32K) + }); + + // Uses the real 1M window minus the default 16K output reserve. + expect(caps?.maxInputTokens).toBe(1048576 - 16000); + expect(caps?.maxOutputTokens).toBe(16000); + }); + + it('honors top_provider.max_completion_tokens as the output budget', () => { + const provider = createProvider(); + + const caps = provider.resolveCapabilities({ + id: 'some/reasoning-model', + name: 'Reasoning Model', + supported_parameters: ['tools'], + context_length: 200000, + top_provider: { context_length: 200000, max_completion_tokens: 64000 }, + }); + + expect(caps?.maxOutputTokens).toBe(64000); + expect(caps?.maxInputTokens).toBe(200000 - 64000); + }); + + it('falls back to top_provider.context_length when the model omits context_length', () => { + const provider = createProvider(); + + const caps = provider.resolveCapabilities({ + id: 'legacy/model', + name: 'Legacy Model', + supported_parameters: ['tools'], + top_provider: { context_length: 128000 }, + }); + + expect(caps?.maxInputTokens).toBe(128000 - 16000); + }); + + it('clamps the output reserve so a small-context model keeps a positive prompt budget', () => { + const provider = createProvider(); + + // An 8K model whose provider reports a 16K completion budget larger than the + // window. Without clamping, maxInputTokens would go negative (8000 - 16000). + const caps = provider.resolveCapabilities({ + id: 'tiny/model', + name: 'Tiny Model', + supported_parameters: ['tools'], + context_length: 8000, + top_provider: { context_length: 8000, max_completion_tokens: 16000 }, + }); + + // Reserve is capped at half the window, so the prompt budget stays positive. + expect(caps?.maxOutputTokens).toBe(4000); + expect(caps?.maxInputTokens).toBe(4000); + }); +}); diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts index 8b282e80b4ee7c..c80d418b44eb42 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts @@ -12,16 +12,17 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle'; const NOTIFICATION_ID = 'copilot.byokUtilityModelHint'; const UTILITY_MODEL_SETTING = 'chat.utilityModel'; const UTILITY_SMALL_MODEL_SETTING = 'chat.utilitySmallModel'; +const BYOK_UTILITY_MODEL_DEFAULT_SETTING = 'chat.byokUtilityModelDefault'; +const MAIN_AGENT_BYOK_UTILITY_MODEL_DEFAULT = 'mainAgent'; /** * Shows a chat input notification in air-gapped BYOK scenarios (no GitHub * session) when at least one BYOK model is available but the utility model - * settings are still defaults. The default utility models require GitHub - * Copilot access, so without it the utility slots silently fall back and - * degrade the experience until the user points them at a BYOK model. + * settings are still defaults. Some utility flows are unavailable until the + * user points them at a BYOK model or selects the main agent model as the BYOK utility default. * * The notification hides automatically once the user signs in, BYOK models - * disappear, or both utility settings are configured. + * disappear, both utility settings are configured, or the main agent model is the BYOK utility default. */ export class ByokUtilityModelNotificationContribution extends Disposable { @@ -39,7 +40,11 @@ export class ByokUtilityModelNotificationContribution extends Disposable { this._register(this._authService.onDidAuthenticationChange(() => this._update())); this._register(vscode.lm.onDidChangeChatModels(() => this._update())); this._register(this._configService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(UTILITY_MODEL_SETTING) || e.affectsConfiguration(UTILITY_SMALL_MODEL_SETTING)) { + if ( + e.affectsConfiguration(UTILITY_MODEL_SETTING) + || e.affectsConfiguration(UTILITY_SMALL_MODEL_SETTING) + || e.affectsConfiguration(BYOK_UTILITY_MODEL_DEFAULT_SETTING) + ) { this._update(); } })); @@ -68,8 +73,9 @@ export class ByokUtilityModelNotificationContribution extends Disposable { const signedOut = !this._authService.anyGitHubSession; const utilityUnset = !this._isUtilityOverrideSet(UTILITY_MODEL_SETTING); const utilitySmallUnset = !this._isUtilityOverrideSet(UTILITY_SMALL_MODEL_SETTING); + const byokUtilityModelDefault = this._configService.getNonExtensionConfig(BYOK_UTILITY_MODEL_DEFAULT_SETTING); - if (!signedOut || !this._hasByokModels || (!utilityUnset && !utilitySmallUnset)) { + if (!signedOut || !this._hasByokModels || byokUtilityModelDefault === MAIN_AGENT_BYOK_UTILITY_MODEL_DEFAULT || (!utilityUnset && !utilitySmallUnset)) { this._hideNotification(); return; } @@ -92,7 +98,7 @@ export class ByokUtilityModelNotificationContribution extends Disposable { notification.message = vscode.l10n.t('Set BYOK utility models'); notification.description = vscode.l10n.t('Unlocks full AI features.'); notification.actions = [ - { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: ['chat.utility'] }, + { label: vscode.l10n.t('Configure'), commandId: 'workbench.action.openSettings', commandArgs: [BYOK_UTILITY_MODEL_DEFAULT_SETTING] }, ]; } else if (utilityUnset) { notification.message = vscode.l10n.t('Set BYOK utility model'); diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts index c39ebb30723d3c..442c720f48db4c 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts @@ -116,7 +116,7 @@ describe('ByokUtilityModelNotificationContribution', () => { expect(mockNotification.message).toBe('Set BYOK utility models'); expect(mockNotification.actions).toHaveLength(1); expect(mockNotification.actions[0].commandId).toBe('workbench.action.openSettings'); - expect(mockNotification.actions[0].commandArgs).toEqual(['chat.utility']); + expect(mockNotification.actions[0].commandArgs).toEqual(['chat.byokUtilityModelDefault']); }); test('shows notification with single action when only chat.utilityModel is unset', async () => { @@ -166,6 +166,30 @@ describe('ByokUtilityModelNotificationContribution', () => { expect(mockNotification.show).not.toHaveBeenCalled(); }); + test('does not show notification when the main agent model is the BYOK utility default', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ + 'chat.byokUtilityModelDefault': 'mainAgent', + }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).not.toHaveBeenCalled(); + }); + + test('continues to show notification when Copilot is the BYOK utility default while signed out', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService({ + 'chat.byokUtilityModelDefault': 'copilot', + }); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(mockNotification.show).toHaveBeenCalled(); + }); + test('hides notification once both utility settings are configured', async () => { const { authService } = createAuthService({ anyGitHubSession: undefined }); const { configService, set } = createConfigService(); @@ -183,6 +207,20 @@ describe('ByokUtilityModelNotificationContribution', () => { expect(mockNotification.hide).toHaveBeenCalled(); }); + test('hides notification when the main agent model is the BYOK utility default', async () => { + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService, set } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + expect(mockNotification.show).toHaveBeenCalled(); + + set('chat.byokUtilityModelDefault', 'mainAgent'); + await flushAsync(); + + expect(mockNotification.hide).toHaveBeenCalled(); + }); + test('hides notification when user signs in', async () => { const { authService, emitter } = createAuthService({ anyGitHubSession: undefined }); const { configService } = createConfigService(); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index cf54c989bbc45d..4905e1b8bd395a 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -28,6 +28,7 @@ import { IOctoKitService } from '../../../../platform/github/common/githubServic import { LanguageModelToolMCPSource } from '../../../../vscodeTypes'; import { IClaudePluginService } from './claudeSkills'; import { ExternalEditTracker } from '../../common/externalEditTracker'; +import { resolveAppModulePathSync } from '../../copilotcli/node/appNodeModules'; import { buildMcpServersFromRegistry } from '../common/claudeMcpServerRegistry'; import { dispatchMessage, ClaudeProxyError, KnownClaudeError } from '../common/claudeMessageDispatch'; import { IClaudeRuntimeDataService } from '../common/claudeRuntimeDataService'; @@ -499,7 +500,7 @@ export class ClaudeCodeSession extends Disposable { ANTHROPIC_AUTH_TOKEN: `${serverConfig.nonce}.${this.sessionId}`, CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1', USE_BUILTIN_RIPGREP: '0', - PATH: `${this.envService.appRoot}/node_modules/@vscode/ripgrep-universal/bin/${process.platform}-${process.arch}${pathSep}${process.env.PATH}`, + PATH: `${resolveAppModulePathSync(this.envService.appRoot, '@vscode', 'ripgrep-universal', 'bin', `${process.platform}-${process.arch}`)}${pathSep}${process.env.PATH}`, // Forward OTel configuration to the Claude SDK subprocess ...deriveClaudeOTelEnv(this._otelService.config), }, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts index 603c74457c4929..fe11ad14a684b2 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/copilotCLITools.ts @@ -610,7 +610,7 @@ export function buildChatHistoryFromEvents(sessionId: string, modelId: string | } }); ((event.data.attachments || [])) - .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) + .filter(attachment => attachment.type === 'selection' || attachment.type === 'github_reference' || attachment.type === 'blob' || attachment.type === 'github_actions_job' || attachment.type === 'github_url' || attachment.type === 'github_tree_comparison' || attachment.type === 'github_release' || attachment.type === 'github_repository' || attachment.type === 'github_file_diff' || attachment.type === 'github_commit' || attachment.type === 'github_file' || attachment.type === 'extension_context' ? true : !isInstructionAttachmentPath(attachment.path)) .forEach(attachment => { if (attachment.type === 'github_reference') { return; @@ -1264,6 +1264,9 @@ function formatSearchToolInvocationCompleted(invocation: ChatToolInvocationPart, } const searchInPath = toolCall.arguments.path ? ` in \`${toolCall.arguments.path}\`` : ''; let files: string[] = []; + // TODO: Revisit this legacy SDK terminal content usage. SDK `terminal` + // content is deprecated for shell command exit metadata; this search + // result parsing should not rely on it long term. if (Array.isArray(result.result?.contents) && result.result.contents.length > 0 && result.result.contents[0].type === 'terminal' && typeof result.result.contents[0].text === 'string') { const matches = result.result.contents[0].text.trim(); const noMatches = matches.length === 0; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/appNodeModules.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/appNodeModules.ts new file mode 100644 index 00000000000000..efa0a76237c66b --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/appNodeModules.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 { existsSync, promises as fs } from 'fs'; +import * as path from 'path'; + +/** + * Roots under VS Code's app root that may hold its bundled `node_modules`, in + * priority order. + * + * In a packaged build VS Code's `node_modules` is bundled into a + * `node_modules.asar` archive and any native binaries are extracted alongside it + * into `node_modules.asar.unpacked`. In development they live in a plain + * `node_modules`. Consumers that reach into VS Code's own modules for native + * binaries (ripgrep, node-pty, the MXC sandbox binaries, …) must therefore probe + * both roots, preferring the plain `node_modules` used in dev and marketplace + * installs. + */ +export const APP_NODE_MODULES_ROOTS = ['node_modules', 'node_modules.asar.unpacked'] as const; + +/** + * Resolves a path to one of VS Code's bundled module resources, checking both + * the plain `node_modules` (dev) and `node_modules.asar.unpacked` (packaged) + * roots. Returns the first existing path, or the plain `node_modules` path when + * neither exists so callers still get a stable default. + */ +export function resolveAppModulePathSync(appRoot: string, ...segments: string[]): string { + for (const root of APP_NODE_MODULES_ROOTS) { + const candidate = path.join(appRoot, root, ...segments); + if (existsSync(candidate)) { + return candidate; + } + } + return path.join(appRoot, APP_NODE_MODULES_ROOTS[0], ...segments); +} + +/** + * Async variant of {@link resolveAppModulePathSync}. + */ +export async function resolveAppModulePath(appRoot: string, ...segments: string[]): Promise { + for (const root of APP_NODE_MODULES_ROOTS) { + const candidate = path.join(appRoot, root, ...segments); + try { + await fs.access(candidate); + return candidate; + } catch { + // Not present under this root; try the next one. + } + } + return path.join(appRoot, APP_NODE_MODULES_ROOTS[0], ...segments); +} diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts index 0c1302828dd1c1..898ee08a1a2c59 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotCli.ts @@ -25,8 +25,9 @@ import { URI } from '../../../../util/vs/base/common/uri'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ensureNodePtyShim } from './nodePtyShim'; import { ensureRipgrepShim } from './ripgrepShim'; +import { resolveAppModulePathSync } from './appNodeModules'; import { CancellationToken } from '../../../../util/vs/base/common/cancellation'; -import { formatTokenCount, getModelCapabilitiesDescription, getReasoningEffortDescription, normalizeTokenPrices } from '../../../conversation/common/languageModelAccess'; +import { formatTokenCount, getAutoModelDescription, getModelCapabilitiesDescription, getReasoningEffortDescription, normalizeTokenPrices } from '../../../conversation/common/languageModelAccess'; export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel'; @@ -60,6 +61,7 @@ export interface CopilotCLIModelInfo { readonly supportsReasoningEffort?: boolean; readonly defaultReasoningEffort?: string; readonly supportedReasoningEfforts?: string[]; + readonly warningText?: Record; } export interface ICopilotCLIModels { @@ -225,6 +227,7 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { private _buildModelInfos(models: CopilotCLIModelInfo[]): vscode.LanguageModelChatInformation[] { const isReasoningEffortEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); const isAutoModelEnabled = this.configurationService.getConfig(ConfigKey.Advanced.CLIAutoModelEnabled); + const preferLongContext = this.configurationService.getConfig(ConfigKey.PreferLongContext); const modelsInfo: vscode.LanguageModelChatInformation[] = models.map((model, index) => { const multiplier = model.multiplier === undefined ? undefined : `${model.multiplier}x`; const modelInfo: vscode.LanguageModelChatInformation = { @@ -246,12 +249,13 @@ export class CopilotCLIModels extends Disposable implements ICopilotCLIModels { longContextCacheWriteCost: model.longContextCacheWriteCost, multiplierNumeric: model.multiplier, isUserSelectable: true, - ...buildConfigurationSchema(model, isReasoningEffortEnabled), + ...buildConfigurationSchema(model, isReasoningEffortEnabled, preferLongContext), capabilities: { imageInput: model.supportsVision, toolCalling: true }, targetChatSessionType: 'copilotcli', + warningText: model.warningText, isDefault: !isAutoModelEnabled && index === 0 ? true : undefined, }; const tooltip = getModelCapabilitiesDescription(modelInfo) ?? ''; @@ -271,7 +275,7 @@ function buildAutoModel(defaultModel?: CopilotCLIModelInfo): vscode.LanguageMode return { id: 'auto', name: 'Auto', - tooltip: l10n.t('Auto selects the best model based on your request complexity and model performance.'), + tooltip: getAutoModelDescription(), family: defaultModel?.id ?? '', version: '', maxInputTokens: defaultModel?.maxInputTokens ?? defaultModel?.maxContextWindowTokens ?? 0, @@ -288,7 +292,7 @@ function buildAutoModel(defaultModel?: CopilotCLIModelInfo): vscode.LanguageMode export const COPILOT_CLI_CONTEXT_SIZE_PROPERTY = 'contextSize'; -function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo, isReasoningEffortEnabled: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { +function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo, isReasoningEffortEnabled: boolean, preferLongContext: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { const properties: Record[string]> = {}; // Reasoning effort config @@ -315,7 +319,7 @@ function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo, isReasoningEff if (defaultContextMax && defaultContextMax < fullMax) { const hasLongContextSurcharge = modelInfo.longContextInputCost !== undefined || modelInfo.longContextOutputCost !== undefined; - if (hasLongContextSurcharge) { + if (hasLongContextSurcharge || !preferLongContext) { properties[COPILOT_CLI_CONTEXT_SIZE_PROPERTY] = { type: 'number', title: l10n.t('Context Size'), @@ -329,7 +333,7 @@ function buildConfigurationSchema(modelInfo: CopilotCLIModelInfo, isReasoningEff group: 'tokens', }; } else { - // No surcharge — show only the long context option as a non-switchable indicator. + // No surcharge and the user prefers long context — show only the long context option as a non-switchable indicator. See microsoft/vscode#322950, microsoft/vscode#323116. properties[COPILOT_CLI_CONTEXT_SIZE_PROPERTY] = { type: 'number', title: l10n.t('Context Size'), @@ -588,10 +592,11 @@ export class CopilotCLISDK implements ICopilotCLISDK { await this._ensureShimsPromise; // The SDK's sandbox auto-detection looks for `mxc-bin//wxc-exec.exe` (and the // Linux/macOS equivalents) under `MXC_BIN_DIR`. VS Code core ships the MXC - // sandbox binaries at `/node_modules/@microsoft/mxc-sdk/bin//`, so - // point `MXC_BIN_DIR` there. The @github/copilot package's own `mxc-bin/` is excluded + // sandbox binaries at `/node_modules/@microsoft/mxc-sdk/bin//` + // (or `node_modules.asar.unpacked/...` in a packaged build), so point + // `MXC_BIN_DIR` there. The @github/copilot package's own `mxc-bin/` is excluded // from the product build (see build/.moduleignore). - process.env['MXC_BIN_DIR'] = path.join(this.envService.appRoot, 'node_modules', '@microsoft', 'mxc-sdk', 'bin'); + process.env['MXC_BIN_DIR'] = resolveAppModulePathSync(this.envService.appRoot, '@microsoft', 'mxc-sdk', 'bin'); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts index 4e94515ba261ef..ca6ed26a50c05b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSession.ts @@ -2779,14 +2779,58 @@ export class CopilotCLISession extends DisposableStore implements ICopilotCLISes private _renderAttachments(attachments: Attachment[]): string[] { const lines: string[] = []; for (const attachment of attachments) { - if (attachment.type === 'github_reference') { - lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); - } else if (attachment.type === 'blob') { - lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); - } else if (attachment.type === 'extension_context') { - lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); - } else { - lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + switch (attachment.type) { + case 'github_reference': { + lines.push(`- ${attachment.title}: (${attachment.number}, ${attachment.type}, ${attachment.referenceType})`); + break; + } + case 'github_actions_job': { + lines.push(`- ${attachment.jobName}: (${attachment.jobId}, ${attachment.type})`); + break; + } + case 'github_commit': { + lines.push(`- ${attachment.message}: (${attachment.oid}, ${attachment.type})`); + break; + } + case 'github_file': { + lines.push(`- ${attachment.path}: (${attachment.ref}, ${attachment.type})`); + break; + } + case 'github_file_diff': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_release': { + lines.push(`- ${attachment.name}: (${attachment.tagName}, ${attachment.type})`); + break; + } + case 'github_repository': { + lines.push(`- ${attachment.repo.name}: (${attachment.url}, ${attachment.type})`); + break; + } + case 'github_tree_comparison': { + lines.push(`- ${attachment.head}: (${attachment.base}, ${attachment.type})`); + break; + } + case 'github_url': { + lines.push(`- ${attachment.url}: (${attachment.type})`); + break; + } + case 'github_snippet': { + lines.push(`- ${attachment.path}: (${attachment.type})`); + break; + } + case 'blob': { + lines.push(`- ${attachment.displayName ?? 'blob'} (${attachment.type}, ${attachment.mimeType})`); + break; + } + case 'extension_context': { + lines.push(`- ${attachment.title ?? 'extension_context'} (${attachment.type}, ${attachment.extensionId})`); + break; + } + default: { + lines.push(`- ${attachment.displayName} (${attachment.type}, ${attachment.type === 'selection' ? attachment.filePath : attachment.path})`); + } } } return lines; diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts index 3c47edec8ce054..83c72fb12f5a91 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AutoModeSessionResult, internal, LocalSessionMetadata, AutoModeSessionManager as SDKAutoModeSessionManager, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import type { internal, LocalSessionMetadata, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import { createReadStream } from 'node:fs'; import { devNull } from 'node:os'; @@ -55,196 +55,9 @@ import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile'; -const AUTO_MODE_REFRESH_LEAD_TIME_MS = 300 * 1000; export const COPILOT_CLI_CHAT_PANEL_SYSTEM_MESSAGE = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.'; type SDKPackage = Awaited>; -type AutoModeResolveArgs = Parameters[0]; -type AutoModeResolveResult = Awaited>; -type AutoModeListener = Parameters[0]; - -class AutoModeSessionManagerCompat { - - private current: AutoModeSessionResult | undefined; - private previousConcreteModel: string | undefined; - private inflight: Promise | undefined; - private readonly listeners = new Set(); - - constructor(private readonly sdkPackage: Pick) { } - - recordPreviousConcreteModel(modelId: string | undefined): void { - if (modelId && !this.sdkPackage.isAutoModel(modelId)) { - this.previousConcreteModel = modelId; - } - } - - getLastResolved(): string | undefined { - return this.current?.selectedModel; - } - - getDiscountPercent(): number | undefined { - const discountedCosts = this.current?.discountedCosts; - if (!discountedCosts) { - return undefined; - } - - const selectedModelDiscount = this.current?.selectedModel ? discountedCosts[this.current.selectedModel] : undefined; - if (selectedModelDiscount !== undefined) { - return Math.round(selectedModelDiscount * 100); - } - - const allDiscounts = Object.values(discountedCosts); - if (allDiscounts.length === 0) { - return undefined; - } - - return Math.round((allDiscounts.reduce((sum, discount) => sum + discount, 0) / allDiscounts.length) * 100); - } - - getPreviousConcreteModel(): string | undefined { - return this.previousConcreteModel; - } - - subscribe(listener: AutoModeListener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - async resolve(args: AutoModeResolveArgs): Promise { - if (this.isFresh() && this.current) { - const current = this.current; - this.applySessionToken(args.settings, current.sessionToken); - return { modelId: current.selectedModel, sessionToken: current.sessionToken }; - } - - if (this.inflight) { - const resolved = await this.inflight; - if (resolved) { - this.applySessionToken(args.settings, resolved.sessionToken); - } - - return resolved; - } - - this.inflight = this.doResolve(args).finally(() => { - this.inflight = undefined; - }); - - return this.inflight; - } - - clear(settings?: AutoModeResolveArgs['settings']): void { - this.current = undefined; - if (settings) { - this.clearSessionToken(settings); - } - this.notify(); - } - - handleModelChange(prevModel: string | undefined, nextModel: string, settings?: AutoModeResolveArgs['settings']): void { - if (this.sdkPackage.isAutoModel(nextModel) && !this.sdkPackage.isAutoModel(prevModel)) { - this.recordPreviousConcreteModel(prevModel); - } else if (!this.sdkPackage.isAutoModel(nextModel) && this.sdkPackage.isAutoModel(prevModel)) { - this.clear(settings); - } - } - - private notify(): void { - const resolvedModel = this.current?.selectedModel; - const discountPercent = this.getDiscountPercent(); - for (const listener of this.listeners) { - try { - listener(resolvedModel, discountPercent); - } catch { - // Ignore listener failures to mirror the SDK manager behavior. - } - } - } - - private async doResolve(args: AutoModeResolveArgs): Promise { - const { logger, settings } = args; - - if (this.current) { - try { - const refreshed = await this.sdkPackage.refreshAutoModeSession({ ...args, existingToken: this.current.sessionToken }); - this.current = refreshed; - this.applySessionToken(settings, refreshed.sessionToken); - this.notify(); - return { modelId: refreshed.selectedModel, sessionToken: refreshed.sessionToken }; - } catch (error) { - if (this.isUnauthorizedError(error)) { - logger.debug('Auto-mode refresh unauthorized; acquiring a new session'); - } else if (error instanceof this.sdkPackage.AutoModeUnsupportedError) { - logger.debug(`Auto-mode refresh unsupported: ${error.message}`); - this.current = undefined; - this.notify(); - return undefined; - } else if (error instanceof this.sdkPackage.AutoModeUnavailableError) { - logger.debug(`Auto-mode unavailable during refresh: ${error.message}`); - this.current = undefined; - this.notify(); - return undefined; - } else { - logger.debug(`Auto-mode refresh failed; reusing last token until expiry: ${this.formatError(error)}`); - this.applySessionToken(settings, this.current.sessionToken); - return { modelId: this.current.selectedModel, sessionToken: this.current.sessionToken }; - } - } - } - - try { - const acquired = await this.sdkPackage.acquireAutoModeSession(args); - this.current = acquired; - this.applySessionToken(settings, acquired.sessionToken); - this.notify(); - logger.debug(`Auto-mode session acquired: selected_model=${acquired.selectedModel}${acquired.expiresAt ? ` expires_at=${acquired.expiresAt}` : ''}`); - return { modelId: acquired.selectedModel, sessionToken: acquired.sessionToken }; - } catch (error) { - if (error instanceof this.sdkPackage.AutoModeUnsupportedError) { - logger.debug(`Auto-mode unsupported: ${error.message}`); - return undefined; - } - - if (error instanceof this.sdkPackage.AutoModeUnavailableError) { - logger.debug(`Auto-mode unavailable: ${error.message}`); - return undefined; - } - - logger.debug(`Auto-mode acquire failed: ${this.formatError(error)}`); - return undefined; - } - } - - private isFresh(): boolean { - return this.current ? (this.current.expiresAt ? this.current.expiresAt * 1000 - Date.now() > AUTO_MODE_REFRESH_LEAD_TIME_MS : true) : false; - } - - private isUnauthorizedError(error: unknown): error is { kind: 'unauthorized' } { - return typeof error === 'object' && error !== null && 'kind' in error && error.kind === 'unauthorized'; - } - - private applySessionToken(settings: AutoModeResolveArgs['settings'], sessionToken: string): void { - if (!settings) { - return; - } - - settings.api ??= {}; - settings.api.copilot ??= {}; - settings.api.copilot.capiSessionToken = sessionToken; - } - - private clearSessionToken(settings: AutoModeResolveArgs['settings']): void { - if (settings?.api?.copilot) { - delete settings.api.copilot.capiSessionToken; - } - } - - private formatError(error: unknown): string { - return error instanceof Error ? error.message : String(error); - } -} export interface ICopilotCLISessionItem { readonly id: string; @@ -407,7 +220,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS return new internal.LocalSessionManager({ createFeatureFlagService: createLocalFeatureFlagServiceCreator(), telemetryService: new internal.NoopTelemetryService(), - autoModeManager: this.createAutoModeManager(sdkPackage), + autoModeManager: new sdkPackage.AutoModeSessionManager(), }, { flushDebounceMs: undefined, settings: undefined, version: undefined }); } catch (error) { @@ -455,21 +268,6 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS })); } - private createAutoModeManager(sdkPackage: SDKPackage): SDKAutoModeSessionManager { - if (typeof sdkPackage.AutoModeSessionManager === 'function') { - try { - return new sdkPackage.AutoModeSessionManager(); - } catch (error) { - if (!(error instanceof TypeError)) { - throw error; - } - } - } - - this.logService.warn('Failed to construct SDK AutoModeSessionManager, using compatibility fallback.'); - return new AutoModeSessionManagerCompat(sdkPackage) as unknown as SDKAutoModeSessionManager; - } - getSessionWorkingDirectory(sessionId: string): Uri | undefined { return this._sessionWorkingDirectories.get(sessionId); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts index bb3553204138a0..ef9de437523e16 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/nodePtyShim.ts @@ -6,6 +6,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import { ILogService } from '../../../../platform/log/common/logService'; +import { APP_NODE_MODULES_ROOTS } from './appNodeModules'; let shimCreated: Promise | undefined = undefined; @@ -52,11 +53,18 @@ async function _ensureNodePtyShim(extensionPath: string, vscodeAppRoot: string, } export async function resolveNodePtySourcePath(vscodeAppRoot: string, logService: ILogService): Promise { - const nodePtyRoot = path.join(vscodeAppRoot, 'node_modules', 'node-pty'); - const candidatePaths = [ - path.join(nodePtyRoot, 'build', 'Release'), - path.join(nodePtyRoot, 'prebuilds', process.platform + '-' + process.arch), - ]; + // In a packaged build VS Code's `node_modules` is bundled into a + // `node_modules.asar` archive and native binaries are extracted alongside it + // into `node_modules.asar.unpacked`. Check both roots so the shim works in + // development (plain `node_modules`) and in a packaged install + // (`node_modules.asar.unpacked`). + const candidatePaths = APP_NODE_MODULES_ROOTS.flatMap(root => { + const nodePtyRoot = path.join(vscodeAppRoot, root, 'node-pty'); + return [ + path.join(nodePtyRoot, 'build', 'Release'), + path.join(nodePtyRoot, 'prebuilds', process.platform + '-' + process.arch), + ]; + }); for (const candidatePath of candidatePaths) { if (await isDirectory(candidatePath)) { diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/ripgrepShim.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/ripgrepShim.ts index da2cc10b0bc582..42dc2d06b3ac29 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/ripgrepShim.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/ripgrepShim.ts @@ -6,6 +6,7 @@ import { promises as fs } from 'fs'; import * as path from 'path'; import { ILogService } from '../../../../platform/log/common/logService'; +import { resolveAppModulePath } from './appNodeModules'; let shimCreated: Promise | undefined = undefined; @@ -40,7 +41,7 @@ export async function ensureRipgrepShim(extensionPath: string, vscodeAppRoot: st } async function _ensureRipgrepShim(extensionPath: string, vscodeAppRoot: string, logService: ILogService): Promise { - const vscodeRipgrepPath = path.join(vscodeAppRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', process.platform + '-' + process.arch); + const vscodeRipgrepPath = await resolveAppModulePath(vscodeAppRoot, '@vscode', 'ripgrep-universal', 'bin', process.platform + '-' + process.arch); await copyRipgrepShim(extensionPath, vscodeRipgrepPath, logService); } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/appNodeModules.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/appNodeModules.spec.ts new file mode 100644 index 00000000000000..7bf600ae603deb --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/appNodeModules.spec.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { resolveAppModulePath, resolveAppModulePathSync } from '../appNodeModules'; + +describe('appNodeModules', () => { + let appRoot: string; + + beforeEach(async () => { + appRoot = await mkdtemp(join(tmpdir(), 'copilot-app-node-modules-')); + }); + + afterEach(async () => { + await rm(appRoot, { recursive: true, force: true }); + }); + + it('prefers plain node_modules when present (sync)', async () => { + const plain = join(appRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin'); + const unpacked = join(appRoot, 'node_modules.asar.unpacked', '@vscode', 'ripgrep-universal', 'bin'); + await mkdir(plain, { recursive: true }); + await mkdir(unpacked, { recursive: true }); + + expect(resolveAppModulePathSync(appRoot, '@vscode', 'ripgrep-universal', 'bin')).toBe(plain); + }); + + it('falls back to node_modules.asar.unpacked in a packaged build (sync)', async () => { + const unpacked = join(appRoot, 'node_modules.asar.unpacked', '@microsoft', 'mxc-sdk', 'bin'); + await mkdir(unpacked, { recursive: true }); + + expect(resolveAppModulePathSync(appRoot, '@microsoft', 'mxc-sdk', 'bin')).toBe(unpacked); + }); + + it('returns the plain node_modules path when neither root exists (sync)', () => { + expect(resolveAppModulePathSync(appRoot, '@vscode', 'ripgrep-universal', 'bin')).toBe( + join(appRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin') + ); + }); + + it('resolves across both roots (async)', async () => { + const unpacked = join(appRoot, 'node_modules.asar.unpacked', 'node-pty'); + await mkdir(unpacked, { recursive: true }); + + await expect(resolveAppModulePath(appRoot, 'node-pty')).resolves.toBe(unpacked); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts index 531be8525c5ea4..eabe2168224b4a 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliModels.spec.ts @@ -44,7 +44,7 @@ function buildAutoModel(defaultModel?: CopilotCLIModelInfo): LanguageModelChatIn return { id: 'auto', name: 'Auto', - tooltip: 'Auto selects the best model based on your request complexity and model performance.', + tooltip: 'Auto routes based on your task and real-time system health and model performance. [Learn More](https://docs.github.com/en/copilot/concepts/models/auto-model-selection)', family: defaultModel?.id ?? '', version: '', maxInputTokens: defaultModel?.maxInputTokens ?? defaultModel?.maxContextWindowTokens ?? 0, diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts index 65d2be693fa38b..a0b4553e3b9a4c 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts @@ -204,44 +204,6 @@ describe('CopilotCLISessionService', () => { // --- Tests ---------------------------------------------------------------------------------- - it('falls back to a compatibility auto-mode manager when the SDK export is not constructable', async () => { - const sdk = { - getPackage: vi.fn(async () => ({ - internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, - LocalSession: MockLocalSession, - createLocalFeatureFlagServiceCreator, - AutoModeSessionManager: {} as never, - acquireAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode acquire'); }), - refreshAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode refresh'); }), - AutoModeUnavailableError: class extends Error { }, - AutoModeUnsupportedError: class extends Error { }, - isAutoModel: (model: string | undefined) => model === 'auto', - noopTelemetryBinder: {}, - })), - getRequestId: vi.fn(() => undefined), - } as unknown as ICopilotCLISDK; - - const services = disposables.add(createExtensionUnitTestingServices()); - const accessor = services.createTestingAccessor(); - const configurationService = accessor.get(IConfigurationService); - const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService; - const nullMcpServer = disposables.add(new NullMcpService()); - const delegationService = new class extends mock() { - override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; } - override async summarize(): Promise { return undefined; } - }(); - const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels(), new NullExperimentationService())); - - const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager & { opts: { autoModeManager: Record } }; - - expect(localManager.opts.autoModeManager).toEqual(expect.objectContaining({ - resolve: expect.any(Function), - clear: expect.any(Function), - handleModelChange: expect.any(Function), - subscribe: expect.any(Function), - })); - }); - describe('CopilotCLISessionService.getChatHistory', () => { it('refreshes cached custom agent mode instructions when custom agents change', async () => { const agentUri = URI.file('/workspace/.github/agents/review.agent.md'); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/nodePtyShim.spec.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/nodePtyShim.spec.ts index aefa359a698e55..11675e9b9fb889 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/nodePtyShim.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/nodePtyShim.spec.ts @@ -38,6 +38,22 @@ describe('nodePtyShim', () => { await expect(resolveNodePtySourcePath(testDir, logService)).resolves.toBe(prebuildDir); }); + it('resolves node-pty from node_modules.asar.unpacked in a packaged build', async () => { + const unpackedBuildDir = join(testDir, 'node_modules.asar.unpacked', 'node-pty', 'build', 'Release'); + await mkdir(unpackedBuildDir, { recursive: true }); + + await expect(resolveNodePtySourcePath(testDir, logService)).resolves.toBe(unpackedBuildDir); + }); + + it('prefers plain node_modules over node_modules.asar.unpacked', async () => { + const buildDir = join(testDir, 'node_modules', 'node-pty', 'build', 'Release'); + const unpackedBuildDir = join(testDir, 'node_modules.asar.unpacked', 'node-pty', 'build', 'Release'); + await mkdir(buildDir, { recursive: true }); + await mkdir(unpackedBuildDir, { recursive: true }); + + await expect(resolveNodePtySourcePath(testDir, logService)).resolves.toBe(buildDir); + }); + it('throws when node-pty binaries are missing', async () => { await expect(resolveNodePtySourcePath(testDir, logService)).rejects.toThrow('Unable to find node-pty binaries'); }); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts index 86e5c373287e37..f2fcb50255496d 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLICustomizationProvider.ts @@ -92,7 +92,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod if (root.type === type) { folders.push({ uri: URI.joinPath(folder, ...root.path), - label: root.path.join('/'), + label: root.path[0], + source: 'local' }); } } @@ -101,7 +102,8 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod if (root.type === type) { folders.push({ uri: URI.joinPath(this.envService.userHome, ...root.path), - label: `~/${root.path.join('/')}`, + label: `~/${root.path[0]}`, + source: 'user' }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts index e324defc723318..e1f9222abbee42 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -177,7 +177,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch if (root.type === type) { folders.push({ uri: URI.joinPath(folder, ...root.path), - label: root.path.join('/'), + label: root.path[0], + source: 'local' }); } } @@ -186,7 +187,8 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch if (root.type === type) { folders.push({ uri: URI.joinPath(this.envService.userHome, ...root.path), - label: `~/${root.path.join('/')}`, + label: `~/${root.path[0]}`, + source: 'user' }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index bd49c2e4e74f82..ec0ddd71bafe53 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -1063,7 +1063,7 @@ export function registerCLIChatCommands( ): IDisposable { const disposableStore = new DisposableStore(); - async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise { const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); @@ -1078,7 +1078,7 @@ export function registerCLIChatCommands( if (!repository) { throw new Error(l10n.t('No active repository found to delete worktree.')); } - await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath); + await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel }); } catch (error) { vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error))); } @@ -1125,7 +1125,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(id, { keepWorktree }); + await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label }); } } })); @@ -1154,7 +1154,7 @@ export function registerCLIChatCommands( const id = SessionIdForCLI.parse(sessionItem.resource); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(id); const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(id, worktreePath); - await deleteSessionById(id, { keepWorktree }); + await deleteSessionById(id, { keepWorktree, sessionLabel: sessionItem.label }); } } })); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index fbf3f93c5640cf..86a67c9d174519 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -2224,7 +2224,7 @@ export function registerCLIChatCommands( ); return siblings.length > 0; } - async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean }): Promise { + async function deleteSessionById(sessionId: string, options?: { keepWorktree?: boolean; sessionLabel?: string }): Promise { const worktree = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); @@ -2239,7 +2239,7 @@ export function registerCLIChatCommands( if (!repository) { throw new Error(l10n.t('No active repository found to delete worktree.')); } - await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath); + await gitService.deleteWorktree(repository.rootUri, worktreePath.fsPath, { label: options?.sessionLabel }); } catch (error) { vscode.window.showErrorMessage(l10n.t('Failed to delete worktree: {0}', error instanceof Error ? error.message : String(error))); } @@ -2265,7 +2265,7 @@ export function registerCLIChatCommands( ); if (result === deleteLabel) { - await deleteSessionById(sessionId, { keepWorktree }); + await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label }); copilotcliSessionItemProvider.notifySessionsChange(); } } @@ -2296,7 +2296,7 @@ export function registerCLIChatCommands( const sessionId = copilotcliSessionItemProvider.untitledSessionIdMapping.get(id) ?? id; const worktreePath = await copilotCLIWorktreeManagerService.getWorktreePath(sessionId); const keepWorktree = !!worktreePath && await shouldKeepWorktreeForOtherSessions(sessionId, worktreePath); - await deleteSessionById(sessionId, { keepWorktree }); + await deleteSessionById(sessionId, { keepWorktree, sessionLabel: sessionItem.label }); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts index 415d083e585e2d..457660e220f3e9 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/taskApiBackend.ts @@ -506,8 +506,6 @@ export class TaskApiError extends Error { */ export class TaskApiHttpClient implements ITaskApiClient { - private static readonly SIGN_IN_DETAIL = l10n.t('Sign in to GitHub to use the Cloud Agent (Task API).'); - constructor( private readonly _capiClientService: ICAPIClientService, private readonly _authService: IAuthenticationService, @@ -515,8 +513,7 @@ export class TaskApiHttpClient implements ITaskApiClient { ) { } private async _authHeaders(): Promise> { - const session = await this._authService.getGitHubSession('permissive', { silent: true }) - ?? await this._authService.getGitHubSession('permissive', { createIfNone: { detail: TaskApiHttpClient.SIGN_IN_DETAIL } }); + const session = await this._authService.getGitHubSession('permissive', { silent: true }); const token = session?.accessToken; if (!token) { throw new Error(l10n.t('Sign in to GitHub to use the Cloud Agent.')); diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotCompletionFeedbackTracker.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotCompletionFeedbackTracker.ts index 394a90cfa8f3a8..271e20a60ea0b6 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotCompletionFeedbackTracker.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/copilotCompletionFeedbackTracker.ts @@ -3,12 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Command, commands, InlineCompletionItem } from 'vscode'; +import { Command, commands } from 'vscode'; import { Disposable } from '../../../../../util/vs/base/common/lifecycle'; import { IInstantiationService, ServicesAccessor } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { collectCompletionDiagnostics, formatDiagnosticsAsMarkdown } from '../../lib/src/diagnostics'; import { telemetry, TelemetryData } from '../../lib/src/telemetry'; import { CMDSendCompletionsFeedbackChat } from './constants'; +import type { GhostTextCompletionItem } from './ghostText/ghostTextProvider'; export const sendCompletionFeedbackCommand: Command = { command: CMDSendCompletionsFeedbackChat, @@ -17,32 +18,27 @@ export const sendCompletionFeedbackCommand: Command = { }; export class CopilotCompletionFeedbackTracker extends Disposable { - private lastShownCopilotCompletionItem: InlineCompletionItem | undefined; + private lastShownCopilotCompletionItem: GhostTextCompletionItem | undefined; constructor(@IInstantiationService private readonly instantiationService: IInstantiationService) { super(); this._register(commands.registerCommand(sendCompletionFeedbackCommand.command, async () => { - const commandArg: unknown = this.lastShownCopilotCompletionItem?.command?.arguments?.[0]; - let telemetryArg: TelemetryData | undefined; - if (commandArg && typeof commandArg === 'object' && 'telemetry' in commandArg) { - if (commandArg.telemetry instanceof TelemetryData) { - telemetryArg = commandArg.telemetry; - } - } + const item = this.lastShownCopilotCompletionItem; + const telemetryArg: TelemetryData | undefined = item?.copilotCompletion.telemetry; this.instantiationService.invokeFunction(telemetry, 'ghostText.sentFeedback', telemetryArg); - await this.instantiationService.invokeFunction(openGitHubIssue, this.lastShownCopilotCompletionItem, telemetryArg); + await this.instantiationService.invokeFunction(openGitHubIssue, item, telemetryArg); })); } - trackItem(item: InlineCompletionItem) { + trackItem(item: GhostTextCompletionItem) { this.lastShownCopilotCompletionItem = item; } } async function openGitHubIssue( accessor: ServicesAccessor, - item: InlineCompletionItem | undefined, + item: GhostTextCompletionItem | undefined, telemetry: TelemetryData | undefined ) { const body = generateGitHubIssueBody(accessor, item, telemetry); @@ -55,10 +51,10 @@ async function openGitHubIssue( function generateGitHubIssueBody( accessor: ServicesAccessor, - item: InlineCompletionItem | undefined, + item: GhostTextCompletionItem | undefined, telemetry: TelemetryData | undefined ) { - const diagnostics = collectCompletionDiagnostics(accessor, telemetry); + const diagnostics = collectCompletionDiagnostics(accessor, telemetry, item?.opportunityId); const formattedDiagnostics = formatDiagnosticsAsMarkdown(diagnostics); if (typeof item?.insertText !== 'string') { return ''; diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts index c080824e5da682..e9349e5fc81a3c 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/extension/src/vscodeInlineCompletionItemProvider.ts @@ -33,6 +33,7 @@ import { NextEditProviderTelemetryBuilder, TelemetrySender } from '../../../../i import { InlineEditLogger } from '../../../../inlineEdits/vscode-node/parts/inlineEditLogger'; import { GhostTextLogContext } from '../../../common/ghostTextContext'; import { ICompletionsTelemetryService } from '../../bridge/src/completionsTelemetryServiceBridge'; +import { ICompletionsCopilotTokenManager } from '../../lib/src/auth/copilotTokenManager'; import { BuildInfo } from '../../lib/src/config'; import { CopilotConfigPrefix } from '../../lib/src/constants'; import { handleException } from '../../lib/src/defaultHandlers'; @@ -82,6 +83,7 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I @IInstantiationService private readonly instantiationService: IInstantiationService, @ICompletionsTelemetryService private readonly telemetryService: ICompletionsTelemetryService, @ICompletionsExtensionStatus private readonly extensionStatusService: ICompletionsExtensionStatus, + @ICompletionsCopilotTokenManager private readonly copilotTokenManager: ICompletionsCopilotTokenManager, @ILogService logService: ILogService, @IRequestLogger private readonly requestLogger: IRequestLogger, ) { @@ -184,9 +186,14 @@ export class CopilotInlineCompletionItemProvider extends Disposable implements I this.logSuggestion(logContext, doc, list); logContext.setResponseResults(list.items); + // Only offer the "Send Copilot Completion Feedback" command to paid users. + // Free and unauthenticated users would otherwise spam the issue tracker. + const copilotToken = this.copilotTokenManager.token; + const canSendCompletionFeedback = !!copilotToken && !copilotToken.isFreeUser && !copilotToken.isNoAuthUser; + return { ...list, - commands: [sendCompletionFeedbackCommand], + commands: canSendCompletionFeedback ? [sendCompletionFeedbackCommand] : [], }; } catch (e) { this.instantiationService.invokeFunction(exception, e, '._provideInlineCompletionItems', myLogger); diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/diagnostics.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/diagnostics.ts index 0b43014b7345ea..3d442905cf0031 100644 --- a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/diagnostics.ts +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/diagnostics.ts @@ -23,8 +23,14 @@ interface Section { items: SectionItems; } -export function collectCompletionDiagnostics(accessor: ServicesAccessor, telemetry: TelemetryData | undefined): Report { +export function collectCompletionDiagnostics(accessor: ServicesAccessor, telemetry: TelemetryData | undefined, opportunityId?: string): Report { const telemetryItems: SectionItems = {}; + // The opportunity ID (VS Code core's `InlineCompletionContext.requestUuid`) is sourced from the shown + // item rather than `telemetry.properties.opportunityId`, since the latter can be stale for cached or + // typing-as-suggested completions whose telemetry is derived from an earlier request. + if (opportunityId) { + telemetryItems['Opportunity ID'] = opportunityId; + } if (telemetry !== undefined) { if (telemetry.properties.headerRequestId) { telemetryItems['Header Request ID'] = telemetry.properties.headerRequestId; @@ -32,9 +38,6 @@ export function collectCompletionDiagnostics(accessor: ServicesAccessor, telemet if (telemetry.properties.choiceIndex) { telemetryItems['Choice Index'] = telemetry.properties.choiceIndex; } - if (telemetry.properties.opportunityId) { - telemetryItems['Opportunity ID'] = telemetry.properties.opportunityId; - } if (telemetry.properties.clientCompletionId) { telemetryItems['Client Completion ID'] = telemetry.properties.clientCompletionId; } diff --git a/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/diagnostics.test.ts b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/diagnostics.test.ts new file mode 100644 index 00000000000000..cc41621230cbb1 --- /dev/null +++ b/extensions/copilot/src/extension/completions-core/vscode-node/lib/src/test/diagnostics.test.ts @@ -0,0 +1,62 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ServicesAccessor } from '../../../../../../util/vs/platform/instantiation/common/instantiation'; +import { BuildInfo } from '../config'; +import { collectCompletionDiagnostics, formatDiagnosticsAsMarkdown } from '../diagnostics'; +import { TelemetryWithExp } from '../telemetry'; +import { createLibTestingContext } from './context'; + +suite('collectCompletionDiagnostics', function () { + let accessor: ServicesAccessor; + + setup(function () { + accessor = createLibTestingContext().createTestingAccessor(); + }); + + test('includes the opportunity ID alongside the telemetry fields', function () { + const telemetry = TelemetryWithExp.createEmptyConfigForTesting(); + telemetry.properties.headerRequestId = 'header-123'; + telemetry.properties.choiceIndex = '0'; + // A stale value that must be ignored: the opportunity ID comes from the item, not from telemetry. + telemetry.properties.opportunityId = 'stale-opportunity-id'; + telemetry.properties.clientCompletionId = 'client-abc'; + telemetry.properties.engineName = 'test-model'; + + const report = collectCompletionDiagnostics(accessor, telemetry, 'icr-current-uuid'); + + assert.deepStrictEqual(report.sections, [ + { + name: 'Copilot Extension', + items: { + Version: BuildInfo.getVersion(), + Editor: 'lib-tests-editor 1', + 'Opportunity ID': 'icr-current-uuid', + 'Header Request ID': 'header-123', + 'Choice Index': '0', + 'Client Completion ID': 'client-abc', + 'Model ID': 'test-model', + }, + }, + ]); + assert.ok(formatDiagnosticsAsMarkdown(report).includes('- Opportunity ID: icr-current-uuid')); + }); + + test('includes the opportunity ID even when no telemetry is available', function () { + const report = collectCompletionDiagnostics(accessor, undefined, 'icr-only'); + + assert.deepStrictEqual(report.sections, [ + { + name: 'Copilot Extension', + items: { + Version: BuildInfo.getVersion(), + Editor: 'lib-tests-editor 1', + 'Opportunity ID': 'icr-only', + }, + }, + ]); + }); +}); diff --git a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts index c36f3137f1e82c..80f6d98b4954e1 100644 --- a/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/common/languageModelAccess.ts @@ -144,6 +144,64 @@ export function getModelCapabilitiesDescription(endpoint: IChatEndpoint | Langua return undefined; } +/** + * Documentation link surfaced in the Auto model description. + * NOTE: Also defined in src/vs/workbench/contrib/chat/common/languageModels.ts (ILanguageModelChatMetadata.autoModelSelectionDocsUrl) — keep in sync. + */ +const AUTO_MODEL_DOCS_URL = 'https://docs.github.com/en/copilot/concepts/models/auto-model-selection'; + +/** + * Classifies an Auto discount range (given as fractions, e.g. `0.1` for 10%) + * into whole-number percentages. Returns `undefined` when there is no discount + * to show, `{ low }` for a single value, or `{ low, high }` for a range. + */ +function classifyDiscountRange(discountRange?: { low: number; high: number }): { low: number; high?: number } | undefined { + if (!discountRange) { + return undefined; + } + const low = Math.round(discountRange.low * 100); + const high = Math.round(discountRange.high * 100); + if (low === high) { + return low !== 0 ? { low } : undefined; + } + return { low, high }; +} + +/** + * Formats the Auto discount as a short label (e.g. "10% discount" or + * "10% to 20% discount"). Returns `undefined` when there is no discount. + * + * @param discountRange Discount as fractions (e.g. `0.1` for 10%). + */ +export function getAutoModelDiscountLabel(discountRange?: { low: number; high: number }): string | undefined { + const discount = classifyDiscountRange(discountRange); + if (!discount) { + return undefined; + } + return discount.high === undefined + ? l10n.t('{0}% discount', discount.low) + : l10n.t('{0}% to {1}% discount', discount.low, discount.high); +} + +/** + * Builds the shared description shown for the Auto model. The discount sentence + * is only included when a non-zero discount is provided. + * + * @param discountRange Discount as fractions (e.g. `0.1` for 10%). When omitted + * or zero, the discount sentence is left out entirely. + */ +export function getAutoModelDescription(discountRange?: { low: number; high: number }): string { + const base = l10n.t('Auto routes based on your task and real-time system health and model performance.'); + const learnMore = l10n.t('[Learn More]({0})', AUTO_MODEL_DOCS_URL); + const discount = classifyDiscountRange(discountRange); + const discountSentence = !discount + ? undefined + : discount.high === undefined + ? l10n.t('Models routed via auto receive a {0}% discount.', discount.low) + : l10n.t('Models routed via auto receive a {0}% to {1}% discount.', discount.low, discount.high); + return discountSentence ? `${base} ${discountSentence} ${learnMore}` : `${base} ${learnMore}`; +} + function formatAicPrice(price: number): string { if (price === 0) { return '0'; diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 3df2c26add911f..40e1a4e4ad4bf6 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -10,6 +10,7 @@ import { IAuthenticationService } from '../../../platform/authentication/common/ import { CopilotToken } from '../../../platform/authentication/common/copilotToken'; import { IBlockedExtensionService } from '../../../platform/chat/common/blockedExtensionService'; import { ChatFetchResponseType, ChatLocation, getErrorDetailsFromChatFetchError } from '../../../platform/chat/common/commonTypes'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { getTextPart } from '../../../platform/chat/common/globalStringUtils'; import { EmbeddingType, getWellKnownEmbeddingTypeInfo, IEmbeddingsComputer } from '../../../platform/embeddings/common/embeddingsComputer'; import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; @@ -17,7 +18,7 @@ import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpo import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer'; import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint'; import { IAutomodeService } from '../../../platform/endpoint/node/automodeService'; -import { CopilotChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint'; +import { CopilotChatEndpoint } from '../../../platform/endpoint/node/copilotChatEndpoint'; import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService'; import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IOctoKitService } from '../../../platform/github/common/githubService'; @@ -32,6 +33,7 @@ import { ITelemetryService } from '../../../platform/telemetry/common/telemetry' import { isEncryptedThinkingDelta } from '../../../platform/thinking/common/thinking'; import { BaseTokensPerCompletion } from '../../../platform/tokenizer/node/tokenizer'; import { TelemetryCorrelationId } from '../../../util/common/telemetryCorrelationId'; +import { CancellationTokenSource } from '../../../util/vs/base/common/cancellation'; import { Emitter } from '../../../util/vs/base/common/event'; import { Disposable, MutableDisposable } from '../../../util/vs/base/common/lifecycle'; import { isBoolean, isDefined, isNumber, isString, isStringArray } from '../../../util/vs/base/common/types'; @@ -42,7 +44,7 @@ import { IExtensionContribution } from '../../common/contributions'; import { PromptRenderer } from '../../prompts/node/base/promptRenderer'; import { isImageDataPart } from '../common/languageModelChatMessageHelpers'; import { LanguageModelAccessPrompt } from './languageModelAccessPrompt'; -import { formatPricingLabel, formatTokenCount, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess'; +import { formatPricingLabel, formatTokenCount, getAutoModelDescription, getAutoModelDiscountLabel, getModelCapabilitiesDescription, buildReasoningEffortSchemaProperty } from '../common/languageModelAccess'; /** * Markers in the autoModelHint experiment variable that indicate the auto model @@ -54,19 +56,8 @@ const experimentalAutoModelHintMarkers = ['minimax', 'mp3yn0h7', 'yaqq2gxh']; * Builds a configurationSchema for the model picker based on the endpoint's supported capabilities. * Models that support reasoning_effort get a "Thinking Effort" dropdown in the model picker UI. */ -/** - * Returns the available context size options for a model, or undefined if the - * model does not support configurable context sizes. - * - * Driven entirely by CAPI billing metadata: - * - When CAPI returns a `long_context` tier with higher prices, offers - * `default.context_max` as the default and `modelMaxPromptTokens` as an - * opt-in larger option so the user knowingly opts into higher billing. - * - When there is no `long_context` tier, or prices match the default tier - * (i.e. `longContext` is absent on the pricing object), no selector is - * shown — the model silently uses the full context window. - */ -function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; description: string; isDefault: boolean }[] | undefined { +// Returns the available context size options for a model, or undefined if the model has no configurable context sizes. With a long_context surcharge, offers the default tier and the full window as an opt-in. Without a surcharge, `chat.preferLongContext.enabled` decides: enabled shows only the full window, disabled (default) still shows both so the smaller window stays selectable. +function getContextSizeOptions(endpoint: IChatEndpoint, preferLongContext: boolean): { value: number; description: string; isDefault: boolean }[] | undefined { const pricing = endpoint.tokenPricing; // Only offer a selector when CAPI provides a default context max, @@ -85,9 +76,8 @@ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; descri const hasLongContextSurcharge = !!pricing.longContext; - // When both tiers cost the same, show only the full context window as a - // non-switchable indicator — the user always gets the larger window. - if (!hasLongContextSurcharge) { + // When both tiers cost the same and the user prefers long context, show only the full window as a non-switchable indicator. See microsoft/vscode#322950, microsoft/vscode#323116. + if (preferLongContext && !hasLongContextSurcharge) { return [ { value: fullMax, description: vscode.l10n.t('Longer sessions'), isDefault: true }, ]; @@ -104,7 +94,7 @@ function getContextSizeOptions(endpoint: IChatEndpoint): { value: number; descri } // Auto model delegates to different backends, so don't expose config pickers -function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { +function buildConfigurationSchema(endpoint: IChatEndpoint, preferLongContext: boolean): { configurationSchema?: vscode.LanguageModelConfigurationSchema } { if (endpoint instanceof AutoChatEndpoint) { return {}; } @@ -118,7 +108,7 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem } // Context size config - const contextSizeOptions = getContextSizeOptions(endpoint); + const contextSizeOptions = getContextSizeOptions(endpoint, preferLongContext); if (contextSizeOptions) { const defaultOption = contextSizeOptions.find(o => o.isDefault); properties.contextSize = { @@ -141,23 +131,6 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem const utilityAliasFamilies: readonly ChatEndpointFamily[] = ['copilot-utility-small', 'copilot-utility']; -/** - * Checks whether `endpoint` is the built-in Copilot endpoint for a utility alias. - */ -function isDefaultEndpointForUtilityFamily(family: ChatEndpointFamily, endpoint: IChatEndpoint): boolean { - if (!(endpoint instanceof CopilotChatEndpoint)) { - return false; - } - switch (family) { - case 'copilot-utility-small': - return endpoint.family === CopilotUtilitySmallChatEndpoint.capiFamily; - case 'copilot-utility': - return endpoint.isFallback; - default: - return false; - } -} - /** * Builds the {@link vscode.LanguageModelChatInformation} entry that publishes a * utility-family alias (e.g. `copilot-utility-small`) under the copilot vendor. @@ -229,6 +202,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib private _utilityAliasEndpoints: Map = new Map(); // Overrides resolved outside model-info publication, reused on the next alias publish. private readonly _resolvedUtilityEndpoints = new Map(); + private readonly _utilityOverridesRefresh = this._register(new MutableDisposable()); private _lmWrapper: CopilotLanguageModelWrapper; private _promptBaseCountCache: LanguageModelAccessPromptBaseCountCache; @@ -241,6 +215,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib @IVSCodeExtensionContext private readonly _vsCodeExtensionContext: IVSCodeExtensionContext, @IAutomodeService private readonly _automodeService: IAutomodeService, @IExperimentationService private readonly _expService: IExperimentationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, ) { super(); @@ -260,6 +235,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } override dispose(): void { + this._utilityOverridesRefresh.value?.cancel(); super.dispose(); } @@ -283,7 +259,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib this._onDidChange.fire(); })); this._register(this._endpointProvider.onDidModelsRefresh(() => { - // Drop stale overrides; model publication uses defaults until refresh completes. + // Drop stale resolutions; aliases are re-published once the refresh re-resolves them. this._resolvedUtilityEndpoints.clear(); void this._refreshUtilityOverrides(); this._onDidChange.fire(); @@ -317,6 +293,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } const seenFamilies = new Set(); + const preferLongContext = this._configurationService.getConfig(ConfigKey.PreferLongContext); for (const endpoint of chatEndpoints) { if (seenFamilies.has(endpoint.family) && !endpoint.showInModelPicker) { @@ -331,14 +308,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib if (endpoint.degradationReason) { modelTooltip = endpoint.degradationReason; } else if (endpoint instanceof AutoChatEndpoint) { - const baseAutoTooltip = vscode.l10n.t('Auto selects the best model based on your request complexity and model performance.'); - if (endpoint.discountRange.high === endpoint.discountRange.low && endpoint.discountRange.low !== 0) { - modelTooltip = `${baseAutoTooltip} ${vscode.l10n.t('Model use through Auto is billed at a {0}% discount.', endpoint.discountRange.low * 100)}`; - } else if (endpoint.discountRange.high !== endpoint.discountRange.low) { - modelTooltip = `${baseAutoTooltip} ${vscode.l10n.t('Model use through Auto is billed at a {0}% to {1}% discount.', endpoint.discountRange.low * 100, endpoint.discountRange.high * 100)}`; - } else { - modelTooltip = baseAutoTooltip; - } + modelTooltip = getAutoModelDescription(endpoint.discountRange); const isOrgManaged = !!this._authenticationService.copilotToken?.isManagedPlan; const autoModeHint = this._expService.getTreatmentVariable('copilotchat.autoModelHint'); const showExperimentalHint = !isOrgManaged && !!autoModeHint && experimentalAutoModelHintMarkers.some(marker => autoModeHint.includes(marker)); @@ -356,11 +326,7 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib let modelDetail: string | undefined; if (endpoint instanceof AutoChatEndpoint) { - if (endpoint.discountRange.high === endpoint.discountRange.low && endpoint.discountRange.low !== 0) { - modelDetail = `${endpoint.discountRange.low * 100}% discount`; - } else if (endpoint.discountRange.high !== endpoint.discountRange.low) { - modelDetail = `${endpoint.discountRange.low * 100}% to ${endpoint.discountRange.high * 100}% discount`; - } + modelDetail = getAutoModelDiscountLabel(endpoint.discountRange); } if (endpoint.customModel) { const customModel = endpoint.customModel; @@ -401,11 +367,12 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib [ApiChatLocation.Editor]: endpoint instanceof AutoChatEndpoint, // inline chat gets 'Auto' by default }, isUserSelectable: endpoint.showInModelPicker, + warningText: endpoint instanceof AutoChatEndpoint ? undefined : endpoint.warningText, capabilities: { imageInput: endpoint instanceof AutoChatEndpoint ? true : endpoint.supportsVision, toolCalling: endpoint.supportsToolCalls, }, - ...buildConfigurationSchema(endpoint), + ...buildConfigurationSchema(endpoint, preferLongContext), }; models.push(model); @@ -414,14 +381,18 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib this._currentModels = models; this._chatEndpoints = chatEndpoints; - this._registerUtilityAliasModels(models, allEndpoints); + this._registerUtilityAliasModels(models); return models; } - /** Publishes utility aliases without waiting for override resolution. */ + /** + * Publishes utility aliases from the resolved-endpoint cache. The cache is + * populated asynchronously by {@link _refreshUtilityOverrides} (the single + * gate that decides, per the BYOK/override policy, whether a family resolves + * to a model at all), so a family with no cached endpoint publishes nothing. + */ private _registerUtilityAliasModels( models: vscode.LanguageModelChatInformation[], - allEndpoints: readonly IChatEndpoint[], ): void { this._utilityAliasEndpoints.clear(); const session = this._authenticationService.anyGitHubSession; @@ -429,15 +400,15 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib for (const family of utilityAliasFamilies) { const cached = this._resolvedUtilityEndpoints.get(family); - const endpoint = cached?.endpoint ?? allEndpoints.find(e => isDefaultEndpointForUtilityFamily(family, e)); - if (!endpoint) { + if (!cached) { continue; } + const endpoint = cached.endpoint; this._utilityAliasEndpoints.set(family, endpoint); try { // Copilot defaults clone an existing entry; synthesized override aliases need baseCount. - const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, cached?.baseCount ?? 0, requiresAuthorization); + const aliasInfo = buildUtilityAliasModelInfo(family, endpoint, models, cached.baseCount, requiresAuthorization); this._logService.trace(`[LanguageModelAccess] Publishing alias '${family}' -> ${endpoint.model} (${aliasInfo.synthesized ? 'synthesized' : 'cloned'}, ${endpoint instanceof CopilotChatEndpoint ? 'copilot' : 'override'}).`); models.push(aliasInfo.info); } catch (err) { @@ -445,23 +416,44 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib } } - // Override resolution may hang, so keep it off the model-info request path. + // Resolution may hang (override lookups, base-count tokenization), so keep it off + // the model-info request path. Newly resolved endpoints are published on the next + // request once `_refreshUtilityOverrides` fires `_onDidChange`. void this._refreshUtilityOverrides().catch(err => { this._logService.warn(`[LanguageModelAccess] Failed to refresh utility overrides: ${err}`); }); } - /** Resolves configured utility model overrides for the next alias publish. */ + /** + * Resolves each utility family through {@link IEndpointProvider.getChatEndpoint}, + * which applies the BYOK/override policy (returning the configured override, the + * built-in Copilot model, or throwing when no model should be used). Successful + * resolutions are cached for {@link _registerUtilityAliasModels} to publish. + */ private async _refreshUtilityOverrides(): Promise { + this._utilityOverridesRefresh.value?.cancel(); + const cancellationSource = new CancellationTokenSource(); + this._utilityOverridesRefresh.value = cancellationSource; + const token = cancellationSource.token; let didChange = false; for (const family of utilityAliasFamilies) { let resolved: IChatEndpoint | undefined; try { resolved = await this._endpointProvider.getChatEndpoint(family); } catch (err) { - this._logService.warn(`[LanguageModelAccess] Failed to resolve utility alias '${family}' in background: ${err}`); + if (token.isCancellationRequested) { + return; + } + // Expected when the policy declines a family (e.g. BYOK main model with no + // configured utility model), so this is not necessarily a failure. The cache + // is cleared on policy changes via `onDidModelsRefresh`, so leaving any prior + // entry intact here only preserves a still-valid alias across transient errors. + this._logService.trace(`[LanguageModelAccess] No utility alias resolved for '${family}' in background: ${err}`); continue; } + if (token.isCancellationRequested) { + return; + } if (!resolved) { continue; } @@ -475,9 +467,15 @@ export class LanguageModelAccess extends Disposable implements IExtensionContrib try { baseCount = await this._promptBaseCountCache.getBaseCount(resolved); } catch (err) { + if (token.isCancellationRequested) { + return; + } this._logService.warn(`[LanguageModelAccess] Failed to compute baseCount for utility alias '${family}' -> ${resolved.model}; keeping previously-published alias. Error: ${err}`); continue; } + if (token.isCancellationRequested) { + return; + } this._resolvedUtilityEndpoints.set(family, { endpoint: resolved, baseCount }); didChange = true; } @@ -782,6 +780,15 @@ export class CopilotLanguageModelWrapper extends Disposable { const result = await wrappedRequest(); + if (result.type === ChatFetchResponseType.Length) { + // The model stopped generating because it hit the length/context-window limit + // (finish_reason "length"). The partial text has already been streamed to the + // consumer via the finished callback, so treat this as a successful (truncated) + // response instead of throwing "Response too long." and discarding the output. + this._logService.warn(`[LanguageModelAccess] Response from model '${_endpoint.model}' was truncated because it hit the length limit; returning the partial response.`); + return undefined; + } + if (result.type !== ChatFetchResponseType.Success) { if (result.type === ChatFetchResponseType.ExtensionBlocked) { if (extensionId) { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/terminalFixGenerator.ts b/extensions/copilot/src/extension/conversation/vscode-node/terminalFixGenerator.ts index e1bd3a65f0b7aa..89d26fb1dc8dc0 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/terminalFixGenerator.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/terminalFixGenerator.ts @@ -80,6 +80,12 @@ export async function generateTerminalFixes(instantiationService: IInstantiation } } r(picks); + }, err => { + // Generating terminal fixes is best-effort: a failed model request rejects here. + // Surface it as "No fixes found" instead of leaving an unhandled rejection that + // also strands the quick pick on "Generating…". + instantiationService.invokeFunction(accessor => accessor.get(ILogService)).error(err); + r([]); }); }); picksPromise.then(picks => { diff --git a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts index 5040f58cde7350..35779997bb377b 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/test/languageModelAccess.test.ts @@ -143,6 +143,41 @@ suite('CopilotLanguageModelWrapper', () => { assert.deepStrictEqual(decoded, expectedUsage); }); }); + + suite('length finish reason', () => { + let wrapper: CopilotLanguageModelWrapper; + let endpoint: IChatEndpoint; + let fetcher: MockChatMLFetcher; + setup(async () => { + createAccessor(); + fetcher = accessor.get(IChatMLFetcher) as MockChatMLFetcher; + endpoint = await accessor.get(IEndpointProvider).getChatEndpoint('copilot-utility'); + wrapper = instaService.createInstance(CopilotLanguageModelWrapper); + }); + + test('does not throw when the response is truncated due to length', async () => { + // A model (e.g. a custom/BYOK endpoint) can legitimately stop generating + // because it hit the context window ceiling, returning finish_reason "length" + // together with the partial text it already streamed. This must not surface + // as a hard "Response too long." failure that discards the generated text. + fetcher.setNextResponse({ + type: ChatFetchResponseType.Length, + reason: 'Response too long.', + requestId: 'test-request-id', + serverRequestId: 'test-server-request-id', + truncatedValue: 'partial answer' + }); + + await wrapper.provideLanguageModelResponse( + endpoint, + [vscode.LanguageModelChatMessage.User('hello')], + { requestInitiator: 'unknown', toolMode: vscode.LanguageModelChatToolMode.Auto }, + vscode.extensions.all[0].id, + { report: () => { } }, + CancellationToken.None + ); + }); + }); }); suite('LanguageModelAccess model info', () => { @@ -176,6 +211,7 @@ suite('LanguageModelAccess model info', () => { testingServiceCollection.define(IAutomodeService, { _serviceBrand: undefined, resolveAutoModeEndpoint: async () => endpoint, + consumeLastRoutingDecision: () => undefined, invalidateRouterCache: () => { }, } as unknown as IAutomodeService); testingServiceCollection.define(IEndpointProvider, { @@ -248,6 +284,89 @@ suite('LanguageModelAccess model info', () => { languageModelAccess.dispose(); } }); + + test('does not publish utility aliases when the provider declines to resolve them (BYOK)', async () => { + const testingServiceCollection = createExtensionTestingServices(); + testingServiceCollection.define(IEndpointProvider, { + _serviceBrand: undefined, + onDidModelsRefresh: Event.None, + getAllCompletionModels: async () => [], + getAllChatEndpoints: async () => [], + getChatEndpoint: async () => { throw new Error('No utility model is configured while the selected main model is BYOK.'); }, + getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); }, + } as unknown as IEndpointProvider); + const accessor = testingServiceCollection.createTestingAccessor(); + const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess); + const internals = languageModelAccess as unknown as { + _resolvedUtilityEndpoints: Map; + _promptBaseCountCache: { getBaseCount(endpoint: IChatEndpoint): Promise }; + _registerUtilityAliasModels(models: vscode.LanguageModelChatInformation[]): void; + _refreshUtilityOverrides(): Promise; + }; + internals._promptBaseCountCache = { getBaseCount: async () => 0 }; + + try { + // The provider gates every utility family, so nothing is resolved or cached. + await internals._refreshUtilityOverrides(); + assert.strictEqual(internals._resolvedUtilityEndpoints.size, 0); + + // With an empty cache, no aliases are published into the model list. + const models: vscode.LanguageModelChatInformation[] = []; + internals._registerUtilityAliasModels(models); + assert.deepStrictEqual(models.map(model => model.id), []); + } finally { + languageModelAccess.dispose(); + } + }); + + test('does not cache a utility endpoint from an obsolete refresh', async () => { + const endpoint = { + model: 'gpt-4o-mini', + modelProvider: 'copilot', + } as IChatEndpoint; + const baseCount = new DeferredPromise(); + const baseCountStarted = new DeferredPromise(); + let endpointRequestCount = 0; + const testingServiceCollection = createExtensionTestingServices(); + testingServiceCollection.define(IEndpointProvider, { + _serviceBrand: undefined, + onDidModelsRefresh: Event.None, + getAllCompletionModels: async () => [], + getAllChatEndpoints: async () => [], + getChatEndpoint: async () => { + if (endpointRequestCount++ === 0) { + return endpoint; + } + throw new Error('No utility model configured'); + }, + getEmbeddingsEndpoint: async () => { throw new Error('Not implemented in test'); }, + } as unknown as IEndpointProvider); + const accessor = testingServiceCollection.createTestingAccessor(); + const languageModelAccess = accessor.get(IInstantiationService).createInstance(LanguageModelAccess); + const internals = languageModelAccess as unknown as { + _resolvedUtilityEndpoints: Map; + _promptBaseCountCache: { getBaseCount(endpoint: IChatEndpoint): Promise }; + _refreshUtilityOverrides(): Promise; + }; + internals._promptBaseCountCache = { + getBaseCount: async () => { + baseCountStarted.complete(); + return baseCount.p; + } + }; + + try { + const obsoleteRefresh = internals._refreshUtilityOverrides(); + await baseCountStarted.p; + const currentRefresh = internals._refreshUtilityOverrides(); + baseCount.complete(0); + await Promise.all([obsoleteRefresh, currentRefresh]); + + assert.strictEqual(internals._resolvedUtilityEndpoints.size, 0); + } finally { + languageModelAccess.dispose(); + } + }); }); suite('buildUtilityAliasModelInfo', () => { @@ -480,4 +599,3 @@ suite('formatPricingLabel', () => { assert.strictEqual(formatPricingLabel(tier(3, 15)), 'In: 3 · Out: 15 AICs/1M tokens'); }); }); - diff --git a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts index 9ee4f792802c3b..8bdadf3647536f 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/continuousEnhancedTelemetrySender.ts @@ -15,6 +15,48 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { DebugRecorder } from './debugRecorder'; import { NES_GH_TELEMETRY_EVENT_NAME } from './nextEditProviderTelemetry'; +/** + * The continuous-recording payload serialized into the `recording` telemetry + * property by {@link ContinuousEnhancedTelemetrySender._sendNow}. + * + * A continuous slice is a sliding window of recent edit activity; unlike a + * per-request ("alternative action") recording it has **no `requestTime`**. + */ +export interface IContinuousRecording { + /** + * The recorded edit timeline for the window, or `undefined` when the + * serialized entries exceed {@link + * ContinuousEnhancedTelemetrySender.MAX_ENTRIES_CHARS} — only `entriesSize` + * is shipped in that case. + */ + readonly entries: LogEntry[] | undefined; + + /** Size, in UTF-16 code units, of the serialized entries at capture time. */ + readonly entriesSize: number; + + /** + * Window start in epoch milliseconds (recorder clock). Adjacent slices can + * have gaps, so consecutive windows are not necessarily contiguous. + */ + readonly windowStart: number; + + /** Window end in epoch milliseconds (recorder clock). */ + readonly windowEnd: number; + + /** + * Identifies the sender instance. Stable per sender lifetime; a single + * extension session can span several ids (a new sender is created on + * copilot-token change, etc.). + */ + readonly sessionId: string; + + /** + * Slice index, monotonically increasing per `sessionId`. Not necessarily + * contiguous — empty slices are skipped at capture time. + */ + readonly sequenceNumber: number; +} + /** * Periodically sends an enhanced GH telemetry event with a fixed-length slice of recent workspace activity. * @@ -163,7 +205,7 @@ export class ContinuousEnhancedTelemetrySender extends Disposable { const entriesSize = entriesJson.length; const sequenceNumber = this._sequenceNumber++; - const recording = { + const recording: IContinuousRecording = { entries: entriesSize > ContinuousEnhancedTelemetrySender.MAX_ENTRIES_CHARS ? undefined : entries, entriesSize, windowStart, diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts index bdcc6685e8b5fc..0025b6bc125a9b 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProvider.ts @@ -12,7 +12,7 @@ import { RootedLineEdit } from '../../../platform/inlineEdits/common/dataTypes/r import { SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsCursorPlacement, SpeculativeRequestsEnablement } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext, type MarkdownLoggable } from '../../../platform/inlineEdits/common/inlineEditLogContext'; import { IObservableDocument, ObservableWorkspace } from '../../../platform/inlineEdits/common/observableWorkspace'; -import { IStatelessNextEditProvider, IStatelessNextEditTelemetry, NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditResult } from '../../../platform/inlineEdits/common/statelessNextEditProvider'; +import { IStatelessNextEditProvider, IStatelessNextEditTelemetry, NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StatelessNextEditResult, StreamedEdit } from '../../../platform/inlineEdits/common/statelessNextEditProvider'; import { autorunWithChanges } from '../../../platform/inlineEdits/common/utils/observable'; import { DocumentHistory, HistoryContext, IHistoryContextProvider } from '../../../platform/inlineEdits/common/workspaceEditTracker/historyContextProvider'; import { IXtabHistoryEditEntry, IXtabHistoryEntry, NesXtabHistoryTracker } from '../../../platform/inlineEdits/common/workspaceEditTracker/nesXtabHistoryTracker'; @@ -33,7 +33,7 @@ import { mapObservableArrayCached, runOnChange } from '../../../util/vs/base/com import { StopWatch } from '../../../util/vs/base/common/stopwatch'; import { assertType } from '../../../util/vs/base/common/types'; import { generateUuid } from '../../../util/vs/base/common/uuid'; -import { LineEdit, LineReplacement } from '../../../util/vs/editor/common/core/edits/lineEdit'; +import { LineEdit } from '../../../util/vs/editor/common/core/edits/lineEdit'; import { StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit'; import { Position } from '../../../util/vs/editor/common/core/position'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; @@ -92,14 +92,34 @@ function convertLineEditToEdit(nextLineEdit: LineEdit, document: StringText): St return suggestedEdit; } -function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xtabEditHistory: readonly IXtabHistoryEntry[]): CachedFunction { + readonly nextEdits: StringReplacement[]; + readonly patchIndices: (number | undefined)[]; + readonly docId: DocumentId; +} + +/** Arguments for {@link NextEditProvider._rebaseAndCacheStreamedEdit}. */ +interface RebaseAndCacheStreamedEditArgs { + readonly statePerDoc: CachedFunction; + readonly streamedEdit: StreamedEdit; + /** Zero-based index of this edit within the stream. */ + readonly ithEdit: number; + /** The document the stream was requested for (the cache/cross-file key document). */ + readonly activeDoc: { + readonly id: DocumentId; + readonly contents: StringText; + readonly cursorOffset: number | undefined; + }; + /** User edits to track the first cached entry against for rebasing; `undefined` for speculative. */ + readonly userEditSince: StringEdit | undefined; + readonly source: NextEditFetchRequest; + readonly logger: ILogger; +} + +function createDocStateLookupMap(projectedDocuments: readonly ProcessedDoc[], xtabEditHistory: readonly IXtabHistoryEntry[]): CachedFunction { const statePerDoc = new CachedFunction((id: DocumentId) => { const doc = projectedDocuments.find(d => d.nextEditDoc.id === id); if (!doc) { @@ -728,6 +748,66 @@ export class NextEditProvider extends Disposable implements INextEditProvider 1) { + logger.trace(`WARNING: ${ithEdit} has ${rebasedEdit.replacements.length} edits, but expected only 1`); + } else { + // populate the cache + const nextEditReplacement = rebasedEdit.replacements[0]; + targetDocState.nextEdits.push(nextEditReplacement); + targetDocState.patchIndices.push(streamedEdit.patchIndex); + cachedEdit = this._nextEditCache.setKthNextEdit( + targetDocState.docId, + docContentsBeforeEdit, + ithEdit === 0 ? streamedEdit.window : undefined, + nextEditReplacement, + ithEdit, + ithEdit === 0 ? targetDocState.nextEdits : undefined, + ithEdit === 0 ? userEditSince : undefined, + source, + { isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === activeDoc.id ? activeDoc.cursorOffset : undefined, patchIndex: streamedEdit.patchIndex, patchIndices: ithEdit === 0 ? targetDocState.patchIndices : undefined } + ); + crossFileCached = this._maybeCacheCrossFileEditUnderActiveDoc(ithEdit, activeDoc.id, activeDoc.contents, streamedEdit.window, targetDocState.docId, docContentsBeforeEdit, nextEditReplacement, streamedEdit, source); + logger.trace(`populated cache for ${ithEdit}`); + } + + // Advance the running contents now that the cache has been populated against the pre-edit state. + targetDocState.docContents = rebasedEdit.applyOnText(docContentsBeforeEdit); + + return { lineEdit, rebasedEdit, docContentsBeforeEdit, cachedEdit, crossFileCached }; + } + private async _executeNewNextEditRequest( req: NextEditFetchRequest, doc: IObservableDocument, @@ -843,7 +923,7 @@ export class NextEditProvider extends Disposable implements INextEditProvider { + const processEdit = (streamedEdit: StreamedEdit, telemetry: IStatelessNextEditTelemetry): CachedOrRebasedEdit | undefined => { ++ithEdit; const myLogger = logger.createSubLogger('processEdit'); myLogger.trace(`processing edit #${ithEdit} (starts at 0)`); @@ -852,14 +932,17 @@ export class NextEditProvider extends Disposable implements INextEditProvider 1) { - myLogger.trace(`WARNING: ${ithEdit} has ${rebasedEdit.replacements.length} edits, but expected only 1`); - } else { - // populate the cache - const nextEditReplacement = rebasedEdit.replacements[0]; - targetDocState.nextEdits.push(nextEditReplacement); - targetDocState.patchIndices.push(streamedEdit.patchIndex); - cachedEdit = this._nextEditCache.setKthNextEdit( - targetDocState.docId, - targetDocState.docContents, - ithEdit === 0 ? streamedEdit.window : undefined, - nextEditReplacement, - ithEdit, - ithEdit === 0 ? targetDocState.nextEdits : undefined, - ithEdit === 0 ? nextEditRequest.intermediateUserEdit : undefined, - req, - { isFromCursorJump: streamedEdit.isFromCursorJump, originalEditWindow: streamedEdit.originalWindow, cursorOffset: targetDocState.docId === curDocId ? activeDocSelection?.start : undefined, patchIndex: streamedEdit.patchIndex, patchIndices: ithEdit === 0 ? targetDocState.patchIndices : undefined } - ); - myLogger.trace(`populated cache for ${ithEdit}`); - - didCacheCrossFileActiveDocEntry = this._maybeCacheCrossFileEditUnderActiveDoc(ithEdit, curDocId, nextEditRequest.documentBeforeEdits, streamedEdit.window, targetDocState.docId, targetDocState.docContents, nextEditReplacement, streamedEdit, req) || didCacheCrossFileActiveDocEntry; - } + const { lineEdit, docContentsBeforeEdit, cachedEdit } = cached; + didCacheCrossFileActiveDocEntry = cached.crossFileCached || didCacheCrossFileActiveDocEntry; if (!firstEdit.isSettled) { myLogger.trace('resolving firstEdit promise'); - logContext.setResult(new RootedLineEdit(targetDocState.docContents, lineEdit)); // this's correct without rebasing because this's the first edit + logContext.setResult(new RootedLineEdit(docContentsBeforeEdit, lineEdit)); // this's correct without rebasing because this's the first edit firstEdit.complete(cachedEdit ? Result.ok(cachedEdit) : Result.error(new NoNextEditReason.Unexpected(new Error('No cached edit')))); } - targetDocState.docContents = rebasedEdit.applyOnText(targetDocState.docContents); - return cachedEdit; }; @@ -1414,42 +1470,26 @@ export class NextEditProvider extends Disposable implements INextEditProvider { diff --git a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts index e6f8dc5ed3da2b..9bedca507ffa33 100644 --- a/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts +++ b/extensions/copilot/src/extension/inlineEdits/node/nextEditProviderTelemetry.ts @@ -145,6 +145,11 @@ export interface INextEditProviderTelemetry extends ILlmNESTelemetry, IDiagnosti readonly notebookCellLines: string | undefined; readonly isActiveDocument?: boolean; readonly isMultilineEdit?: boolean; + /** + * Signed line distance from the cursor line to the suggested edit's range start line + * (`rangeStartLine - cursorLine`, 0-based). Undefined for cross-document suggestions. + */ + readonly suggestionLineDistanceToCursor?: number; readonly isEolDifferent?: boolean; readonly isNextEditorVisible?: boolean; readonly isNextEditorRangeVisible?: boolean; @@ -492,6 +497,7 @@ export class NextEditProviderTelemetryBuilder extends Disposable { pickedNES: this._nesTypePicked, hadLlmNES: this._hadLlmNES, isMultilineEdit: this._isMultilineEdit, + suggestionLineDistanceToCursor: this._suggestionLineDistanceToCursor, isEolDifferent: this._isEolDifferent, isActiveDocument: this._isActiveDocument, isNextEditorVisible: this._isNextEditorVisible, @@ -602,6 +608,17 @@ export class NextEditProviderTelemetryBuilder extends Disposable { return this; } + private _suggestionLineDistanceToCursor?: number; + /** + * @param distance Signed line distance from the cursor line to the suggested edit's range + * start line (`rangeStartLine - cursorLine`, 0-based). Pass `undefined` for cross-document + * suggestions where the distance is not meaningful. + */ + public setSuggestionLineDistanceToCursor(distance: number | undefined): this { + this._suggestionLineDistanceToCursor = distance; + return this; + } + private _isEolDifferent?: boolean; public setIsEolDifferent(isEolDifferent: boolean): this { this._isEolDifferent = isEolDifferent; @@ -1026,6 +1043,7 @@ export class TelemetrySender implements IDisposable { isActiveDocument, isEolDifferent, isMultilineEdit, + suggestionLineDistanceToCursor, isNextEditorRangeVisible, isNextEditorVisible, acceptance, @@ -1124,6 +1142,7 @@ export class TelemetrySender implements IDisposable { "isNotebook": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the document is a notebook", "isMeasurement": true }, "isNESForAnotherDoc": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the NES if for another document", "isMeasurement": true }, "isMultilineEdit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the NES is for a multiline edit", "isMeasurement": true }, + "suggestionLineDistanceToCursor": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Signed line distance from the cursor line to the suggested edit's range start line (rangeStartLine - cursorLine), 0-based; positive means the suggestion starts below the cursor, negative above, 0 on the same line. Uses the inline-completion request cursor position; undefined for cross-document suggestions.", "isMeasurement": true }, "isEolDifferent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the NES edit and original text have different end of lines", "isMeasurement": true }, "isNextEditorVisible": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the next editor is visible", "isMeasurement": true }, "isNextEditorRangeVisible": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the next editor range is visible", "isMeasurement": true }, @@ -1141,7 +1160,20 @@ export class TelemetrySender implements IDisposable { "promptLineCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of lines in the prompt", "isMeasurement": true }, "promptCharCount": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of characters in the prompt", "isMeasurement": true }, "nDiffsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of diffs included in the prompt", "isMeasurement": true }, - "diffTokensInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of tokens consumed by diffs in the prompt", "isMeasurement": true }, + "promptSectionTokensSystemPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the system prompt section", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensRecentlyViewed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the recently-viewed code snippets section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensRecentlyViewedFiles": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the recently-viewed/edited files (from xtab history) subsection of the recently-viewed section; does not sum exactly to promptSectionTokensRecentlyViewed because of the section tags and inter-snippet newline glue", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensLanguageContext": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the language-context snippets subsection of the recently-viewed section (Snippet-kind items from the language server; distinct from the related-information traits section)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSubsectionTokensNeighborFiles": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the neighbor (similar) files subsection of the recently-viewed section", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensCurrentFile": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the current-file section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensLintErrors": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the lint-errors section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensEditHistory": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the rendered edit-diff-history section of the user prompt (including its section tags)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensAreaAroundCodeToEdit": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the area-around-code-to-edit section of the user prompt (0 when absent for the strategy)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensCursorLocation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the cursor-location section of the user prompt (0 when absent for the strategy)", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensRelatedInformation": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the related-information (language traits) section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "promptSectionTokensPostScript": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the post-script section of the user prompt", "isMeasurement": true, "owner": "ulugbekna" }, + "userPromptOverheadTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of user-prompt formatting glue not attributed to a section (joining newlines, backtick fences, trim); userPromptTotalTokens minus the sum of the section counts", "isMeasurement": true, "owner": "ulugbekna" }, + "userPromptTotalTokens": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Approximate (char/4) token count of the whole trimmed user prompt (summary total, not a section)", "isMeasurement": true, "owner": "ulugbekna" }, "nNeighborSnippetsComputed": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Total number of neighbor (similar files) snippets computed before budget filtering", "isMeasurement": true }, "nNeighborSnippetsInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Number of neighbor (similar files) snippets actually included in the prompt", "isMeasurement": true }, "neighborSnippetIndicesInPrompt": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "JSON-encoded array of original input indices (ascending) of neighbor snippets included in the prompt" }, @@ -1226,6 +1258,7 @@ export class TelemetrySender implements IDisposable { isActiveDocument: this._boolToNum(isActiveDocument), isEolDifferent: this._boolToNum(isEolDifferent), isMultilineEdit: this._boolToNum(isMultilineEdit), + suggestionLineDistanceToCursor, isNextEditorRangeVisible: this._boolToNum(isNextEditorRangeVisible), isNextEditorVisible: this._boolToNum(isNextEditorVisible), hasNotebookCellMarker: notebookCellMarkerCount > 0 ? 1 : 0, @@ -1268,7 +1301,20 @@ export class TelemetrySender implements IDisposable { nextCursorLineDistance: telemetry.nextCursorPrediction?.nextCursorLineDistance, xtabUserHappinessScore, nDiffsInPrompt: telemetry.nDiffsInPrompt, - diffTokensInPrompt: telemetry.diffTokensInPrompt, + promptSectionTokensSystemPrompt: telemetry.promptSectionTokens?.systemPrompt, + promptSectionTokensRecentlyViewed: telemetry.promptSectionTokens?.recentlyViewed, + promptSubsectionTokensRecentlyViewedFiles: telemetry.promptSectionTokens?.recentlyViewedSubsections.recentlyViewedFiles, + promptSubsectionTokensLanguageContext: telemetry.promptSectionTokens?.recentlyViewedSubsections.languageContext, + promptSubsectionTokensNeighborFiles: telemetry.promptSectionTokens?.recentlyViewedSubsections.neighborFiles, + promptSectionTokensCurrentFile: telemetry.promptSectionTokens?.currentFile, + promptSectionTokensLintErrors: telemetry.promptSectionTokens?.lintErrors, + promptSectionTokensEditHistory: telemetry.promptSectionTokens?.editHistory, + promptSectionTokensAreaAroundCodeToEdit: telemetry.promptSectionTokens?.areaAroundCodeToEdit, + promptSectionTokensCursorLocation: telemetry.promptSectionTokens?.cursorLocation, + promptSectionTokensRelatedInformation: telemetry.promptSectionTokens?.relatedInformation, + promptSectionTokensPostScript: telemetry.promptSectionTokens?.postScript, + userPromptOverheadTokens: telemetry.promptSectionTokens?.overhead, + userPromptTotalTokens: telemetry.promptSectionTokens?.userPromptTotal, nNeighborSnippetsComputed: telemetry.nNeighborSnippetsComputed, nNeighborSnippetsInPrompt: telemetry.nNeighborSnippetsInPrompt, } diff --git a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts index 2bbd46cbef1ebc..eb398df1e4f806 100644 --- a/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts +++ b/extensions/copilot/src/extension/inlineEdits/test/node/nextEditProviderTelemetry.spec.ts @@ -10,19 +10,24 @@ import { MutableObservableDocument, MutableObservableWorkspace } from '../../../ import { FetchResultWithStats, IStatelessNextEditTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; import { eventPropertiesToSimpleObject } from '../../../../platform/telemetry/common/telemetryData'; import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService'; -import { TelemetryEventProperties, TelemetryProperties } from '../../../../platform/telemetry/common/telemetry'; +import { TelemetryEventMeasurements, TelemetryEventProperties, TelemetryProperties } from '../../../../platform/telemetry/common/telemetry'; import { URI } from '../../../../util/vs/base/common/uri'; import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText'; -import { IEnhancedTelemetrySendingReason, NextEditProviderTelemetryBuilder, TelemetrySender } from '../../node/nextEditProviderTelemetry'; +import { IEnhancedTelemetrySendingReason, NES_GH_TELEMETRY_EVENT_NAME, NextEditProviderTelemetryBuilder, TelemetrySender } from '../../node/nextEditProviderTelemetry'; import { INextEditResult } from '../../node/nextEditResult'; class RecordingTelemetryService extends NullTelemetryService { readonly enhancedEvents: { eventName: string; properties?: TelemetryEventProperties }[] = []; + readonly ghEvents: { eventName: string; properties?: TelemetryEventProperties; measurements?: TelemetryEventMeasurements }[] = []; override sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties): void { this.enhancedEvents.push({ eventName, properties }); } + + override sendGHTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void { + this.ghEvents.push({ eventName, properties, measurements }); + } } function createMockNextEditResult(): INextEditResult { @@ -471,7 +476,7 @@ describe('TelemetrySender', () => { cursorJumpPrompt: undefined, cursorJumpResponse: undefined, nDiffsInPrompt: undefined, - diffTokensInPrompt: undefined, + promptSectionTokens: undefined, nNeighborSnippetsComputed: undefined, nNeighborSnippetsInPrompt: undefined, neighborSnippetIndicesInPrompt: undefined, @@ -560,4 +565,25 @@ describe('TelemetrySender', () => { expect(properties?.modelResponse).toBeUndefined(); }); }); + + describe('suggestionLineDistanceToCursor', () => { + function sendAndGetMeasurements(configure: (builder: NextEditProviderTelemetryBuilder) => void): TelemetryEventMeasurements | undefined { + telemetryService.ghEvents.length = 0; + const result = createMockNextEditResult(); + const builder = createMockBuilder(undefined); + configure(builder); + sender.sendTelemetry(result, builder); + return telemetryService.ghEvents.find(e => e.eventName === NES_GH_TELEMETRY_EVENT_NAME)?.measurements; + } + + test('emits the signed distance, including 0 (same line) which survives serialization', () => { + expect(sendAndGetMeasurements(b => b.setSuggestionLineDistanceToCursor(0))?.suggestionLineDistanceToCursor).toBe(0); + expect(sendAndGetMeasurements(b => b.setSuggestionLineDistanceToCursor(5))?.suggestionLineDistanceToCursor).toBe(5); + expect(sendAndGetMeasurements(b => b.setSuggestionLineDistanceToCursor(-3))?.suggestionLineDistanceToCursor).toBe(-3); + }); + + test('is undefined when not set (e.g. cross-document suggestion)', () => { + expect(sendAndGetMeasurements(() => { })?.suggestionLineDistanceToCursor).toBeUndefined(); + }); + }); }); diff --git a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts index 8d9df088a1313b..7dcfdd11d20031 100644 --- a/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts +++ b/extensions/copilot/src/extension/inlineEdits/vscode-node/inlineCompletionProvider.ts @@ -410,6 +410,12 @@ export class InlineCompletionProviderImpl extends Disposable implements InlineCo telemetryBuilder.setIsNESForOtherEditor(targetDocument !== undefined && targetDocument !== document); telemetryBuilder.setIsActiveDocument(window.activeTextEditor?.document === targetDocument); + // Signed line distance from the request cursor to the suggested edit's range start. + // Only meaningful when both share the active document's coordinate space. + if (range && targetDocument === document) { + telemetryBuilder.setSuggestionLineDistanceToCursor(range.start.line - position.line); + } + if (!targetDocument) { logger.trace('no next edit suggestion'); } else if (hasNotebookCellMarker(document, result.edit.newText)) { diff --git a/extensions/copilot/src/extension/intents/node/agentIntent.ts b/extensions/copilot/src/extension/intents/node/agentIntent.ts index 335bce91c74e03..94ae1f1742aa73 100644 --- a/extensions/copilot/src/extension/intents/node/agentIntent.ts +++ b/extensions/copilot/src/extension/intents/node/agentIntent.ts @@ -41,7 +41,7 @@ import { Iterable } from '../../../util/vs/base/common/iterator'; import { DisposableMap, DisposableStore } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService, ServicesAccessor } from '../../../util/vs/platform/instantiation/common/instantiation'; -import { ChatResponseProgressPart2 } from '../../../vscodeTypes'; +import { ChatResponseAutoModeResolutionPart, ChatResponseProgressPart2 } from '../../../vscodeTypes'; import { ICommandService } from '../../commands/node/commandService'; import { Intent } from '../../common/constants'; import { ChatVariablesCollection } from '../../prompt/common/chatVariablesCollection'; @@ -98,26 +98,18 @@ function isResponsesCompactionContextManagementEnabled(endpoint: IChatEndpoint, * Only clamps when the selection is strictly smaller than the model window so * the full tier ("Longer sessions") stays uncompacted. * - * When no explicit selection is present and the model has a long-context - * surcharge, falls back to the model's default context-max tier - * (`tokenPricing.default.contextMax`). When both tiers cost the same (no - * `longContext` pricing tier), skips the fallback and uses the full native - * window — users get long context for free. + * When no explicit selection is present, falls back to the default context-max tier, unless the tiers cost the same and `chat.preferLongContext.enabled` is set, in which case the full native window is used. * * @internal - exported for testing */ -export function applyContextSizeOverride(endpoint: IChatEndpoint, request: vscode.ChatRequest): IChatEndpoint { +export function applyContextSizeOverride(endpoint: IChatEndpoint, request: vscode.ChatRequest, preferLongContext: boolean = false): IChatEndpoint { const contextSize = request.modelConfiguration?.contextSize; - // Use the explicit selection when valid, otherwise fall back to the default - // context-max tier. Guard against non-positive / non-finite selections - // (e.g. 0, -1, NaN, Infinity): a non-positive token budget would produce an - // invalid endpoint configuration. - // When both tiers cost the same (no longContext pricing tier), skip the - // fallback and use the full model window — users get long context for free. + // Prefer a valid explicit selection; otherwise fall back to the default tier. Guard against non-positive / non-finite selections (0, -1, NaN, Infinity). When tiers cost the same and the user prefers long context, skip the fallback and use the full window. See microsoft/vscode#322950, microsoft/vscode#323116. const hasLongContextSurcharge = !!endpoint.tokenPricing?.longContext; + const useDefaultTierFallback = !preferLongContext || hasLongContextSurcharge; const effectiveSize = (typeof contextSize === 'number' && Number.isFinite(contextSize) && contextSize > 0) ? contextSize - : hasLongContextSurcharge ? endpoint.tokenPricing?.default.contextMax : undefined; + : useDefaultTierFallback ? endpoint.tokenPricing?.default.contextMax : undefined; if (typeof effectiveSize === 'number' && effectiveSize > 0 && effectiveSize < endpoint.modelMaxPromptTokens) { return endpoint.cloneWithTokenOverride(effectiveSize); } @@ -245,12 +237,16 @@ export const getAgentTools = async (accessor: ServicesAccessor, request: vscode. allowTools[ToolName.CoreRunTest] = await testService.hasAnyTests(); allowTools[ToolName.CoreRunTask] = tasksService.getTasks().length > 0; - // The specialized subagents must only run when - // the main agent is on CAPI. + // The specialized subagents and semantic search only work when the main + // agent is on CAPI. semantic_search relies on embeddings that require a + // Copilot token source, so on BYOK / custom endpoints it can abort the chat + // turn (e.g. when the GitHub auth provider is unavailable). Keep it off + // there. See https://github.com/microsoft/vscode/issues/322525. if (!isCAPIEndpoint(model)) { allowTools[ToolName.SearchSubagent] = false; allowTools[ToolName.ExploreSubagent] = false; allowTools[ToolName.ExecutionSubagent] = false; + allowTools[ToolName.Codebase] = false; } else { const searchSubagentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, experimentationService); const exploreAgentEnabled = configurationService.getExperimentBasedConfig(ConfigKey.ExploreAgentEnabled, experimentationService); @@ -370,7 +366,8 @@ export class AgentIntent extends EditCodeIntent { @IAutomodeService private readonly _automodeService: IAutomodeService, @ILogService private readonly _logService: ILogService, @IToolsService private readonly _toolsService: IToolsService, - @ITelemetryService private readonly _telemetryService: ITelemetryService + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService ) { super(instantiationService, endpointProvider, configurationService, expService, codeMapperService, workspaceService, { intentInvocation: AgentIntentInvocation, processCodeblocks: false }); this._sessionListeners.add(chatSessionService.onDidDisposeChatSession(sessionId => { @@ -460,28 +457,48 @@ export class AgentIntent extends EditCodeIntent { return this.handleSummarizeCommand(conversation, request, stream, token); } + // Report auto-mode routing decision if one was made during endpoint resolution + const routingDecision = this._automodeService.consumeLastRoutingDecision(); + if (routingDecision) { + stream.push(new ChatResponseAutoModeResolutionPart(routingDecision.resolvedModel, routingDecision.resolvedModelName, routingDecision.predictedLabel, routingDecision.confidence)); + } + try { return await super.handleRequest(conversation, request, stream, token, documentContext, agentName, location, chatTelemetry, yieldRequested); } finally { - // Fire one final bg todo review pass once the agent loop has ended for - // this turn. The per-round passes never see the very last round, so any - // task that just completed otherwise stays stuck as 'in-progress'. - // Await completion so this final pass runs before we return, while the - // request's tool invocation token is (hopefully) still valid. - - if (request.subAgentInvocationId === undefined && request.subAgentName === undefined) { - const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); - if (todoProcessor) { - await raceTimeout( - todoProcessor.endTurn(conversation.getLatestTurn().id, request.toolInvocationToken), - 5000, - () => todoProcessor.cancel() - ); - } - } + await this._runFinalBackgroundTodoPass(conversation, request); } } + /** + * Fire one final bg todo review pass once the agent loop has ended for this + * turn. The per-round passes never see the very last round, so any task that + * just completed otherwise stays stuck as 'in-progress'. Awaits completion so + * this final pass runs before we return, while the request's tool invocation + * token is (hopefully) still valid. + */ + private async _runFinalBackgroundTodoPass(conversation: Conversation, request: vscode.ChatRequest): Promise { + if (request.subAgentInvocationId !== undefined || request.subAgentName !== undefined) { + return; + } + const todoProcessor = this._backgroundTodoProcessors.get(conversation.sessionId); + if (!todoProcessor) { + return; + } + // Only run the final review pass when the background todo agent is still + // enabled for the current model. If the user switched to a BYOK (non-CAPI) + // model mid-session, the existing processor must not fire against it. + const endpoint = await this.endpointProvider.getChatEndpoint(request).catch(() => undefined); + if (!endpoint || !isBackgroundTodoAgentEnabled(endpoint, this.configurationService, this.expService, this._authenticationService, request)) { + return; + } + await raceTimeout( + todoProcessor.endTurn(conversation.getLatestTurn().id, request.toolInvocationToken), + 5000, + () => todoProcessor.cancel() + ); + } + private async handleSummarizeCommand( conversation: Conversation, request: vscode.ChatRequest, @@ -659,7 +676,7 @@ export class AgentIntentInvocation extends EditCodeIntentInvocation implements I // so the server-managed compaction threshold (Responses API) is keyed to the // selected tier rather than the model's full native window. See // applyContextSizeOverride for the cost rationale. - super(intent, location, applyContextSizeOverride(endpoint, request), request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, _endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); + super(intent, location, applyContextSizeOverride(endpoint, request, configurationService.getConfig(ConfigKey.PreferLongContext)), request, intentOptions, instantiationService, codeMapperService, envService, promptPathRepresentationService, _endpointProvider, workspaceService, toolsService, configurationService, editLogService, commandService, telemetryService, notebookService, otelService); } public override getAvailableTools(): Promise { diff --git a/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts b/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts index d8c338895ed073..70168dc7a3495b 100644 --- a/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/backgroundTodoEnablement.spec.ts @@ -23,7 +23,7 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { TestChatRequest } from '../../../test/node/testHelpers'; import { ToolName } from '../../../tools/common/toolNames'; -import { AgentIntentInvocation, getAgentTools, isBackgroundTodoAgentEnabled, isTodoToolExplicitlyEnabled } from '../agentIntent'; +import { AgentIntent, AgentIntentInvocation, getAgentTools, isBackgroundTodoAgentEnabled, isTodoToolExplicitlyEnabled } from '../agentIntent'; // ─── isTodoToolExplicitlyEnabled unit tests ────────────────────── @@ -276,3 +276,70 @@ describe('AgentIntentInvocation._maybeStartBackgroundTodoAgentPass subagent guar expect(processorLookups).toBe(0); }); }); + +// ─── AgentIntent._runFinalBackgroundTodoPass model-eligibility guard ─── + +// The final review pass reuses a previously-created processor, so a mid-session +// switch to a BYOK (non-CAPI) model must not fire it. Like the subagent-guard +// tests above, we invoke the private method directly against a minimal stub. + +describe('AgentIntent._runFinalBackgroundTodoPass model-eligibility guard', () => { + + const capiEndpoint = { urlOrRequestMetadata: { type: RequestType.ChatCompletions }, modelProvider: 'copilot' } as unknown as IChatEndpoint; + const byokEndpoint = { urlOrRequestMetadata: 'https://api.example.com/v1/chat', modelProvider: 'custom' } as unknown as IChatEndpoint; + const paidToken = new CopilotToken(createTestExtendedTokenInfo({ sku: 'copilot_individual', copilot_plan: 'individual' })); + + function getMethod(): (this: unknown, conversation: unknown, request: unknown) => Promise { + return (AgentIntent.prototype as unknown as { _runFinalBackgroundTodoPass: (this: unknown, conversation: unknown, request: unknown) => Promise })._runFinalBackgroundTodoPass; + } + + function makeStub(endpoint: IChatEndpoint | undefined, processor: unknown) { + return { + _backgroundTodoProcessors: { get: (_id: string) => processor }, + endpointProvider: { getChatEndpoint: async () => endpoint }, + configurationService: { getExperimentBasedConfig: () => true }, + expService: {}, + _authenticationService: { copilotToken: paidToken }, + }; + } + + const conversation = { sessionId: 'sess-1', getLatestTurn: () => ({ id: 'turn-1' }) }; + + function makeProcessor() { + let endTurnCalls = 0; + return { + get endTurnCalls() { return endTurnCalls; }, + endTurn: async () => { endTurnCalls++; }, + cancel: () => { }, + }; + } + + test('runs the final pass on a CAPI endpoint with paid auth and experiment on', async () => { + const processor = makeProcessor(); + const request = new TestChatRequest('fix the bug'); + await getMethod().call(makeStub(capiEndpoint, processor), conversation, request); + expect(processor.endTurnCalls).toBe(1); + }); + + test('does not run the final pass when the model switched to a BYOK (non-CAPI) endpoint', async () => { + const processor = makeProcessor(); + const request = new TestChatRequest('fix the bug'); + await getMethod().call(makeStub(byokEndpoint, processor), conversation, request); + expect(processor.endTurnCalls).toBe(0); + }); + + test('does not run the final pass for a subagent request', async () => { + const processor = makeProcessor(); + const request = new TestChatRequest('fix the bug'); + (request as unknown as { subAgentInvocationId: string }).subAgentInvocationId = 'subagent-uuid-1'; + await getMethod().call(makeStub(capiEndpoint, processor), conversation, request); + expect(processor.endTurnCalls).toBe(0); + }); + + test('does nothing when there is no processor for the session', async () => { + const request = new TestChatRequest('fix the bug'); + await getMethod().call(makeStub(capiEndpoint, undefined), conversation, request); + // No throw and nothing to assert beyond reaching here. + }); +}); + diff --git a/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts index 364a9ee17478d5..2158dd9985eaab 100644 --- a/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/contextSizeOverride.spec.ts @@ -60,13 +60,26 @@ describe('applyContextSizeOverride', () => { expect(clonedWith).toEqual([500_000]); }); - test('does not clamp when long context has no surcharge (same price)', () => { + test('falls back to the default context-max tier when long context has no surcharge (setting disabled)', () => { const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000); - expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); - expect(applyContextSizeOverride(endpoint, createRequest('big'))).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest(undefined)).modelMaxPromptTokens).toBe(200_000); + expect(applyContextSizeOverride(endpoint, createRequest('big')).modelMaxPromptTokens).toBe(200_000); + expect(clonedWith).toEqual([200_000, 200_000]); + }); + + test('does not clamp when long context has no surcharge and the user prefers long context', () => { + const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000); + expect(applyContextSizeOverride(endpoint, createRequest(undefined), true)).toBe(endpoint); + expect(applyContextSizeOverride(endpoint, createRequest('big'), true)).toBe(endpoint); expect(clonedWith).toEqual([]); }); + test('an explicit selection is still respected when the user prefers long context', () => { + const { endpoint, clonedWith } = createEndpoint(1_000_000, 200_000); + expect(applyContextSizeOverride(endpoint, createRequest(200_000), true).modelMaxPromptTokens).toBe(200_000); + expect(clonedWith).toEqual([200_000]); + }); + test('does not clamp when the default tier equals or exceeds the model window', () => { const { endpoint, clonedWith } = createEndpoint(200_000, 200_000); expect(applyContextSizeOverride(endpoint, createRequest(undefined))).toBe(endpoint); diff --git a/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts b/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts index 79d5667e7bb715..8fa3ddb0fbd0a3 100644 --- a/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts +++ b/extensions/copilot/src/extension/intents/node/test/searchSubagentGating.spec.ts @@ -109,4 +109,19 @@ describe('getAgentTools search subagent gating', () => { expect(hasTool(tools, ToolName.SearchSubagent)).toBe(false); expect(hasTool(tools, ToolName.ExploreSubagent)).toBe(false); }); + + test('exposes semantic_search when the model is a CAPI endpoint', async () => { + const request = new TestChatRequest('how does foo work'); + const tools = await instantiationService.invokeFunction(getAgentTools, request, userEndpoint); + expect(hasTool(tools, ToolName.Codebase)).toBe(true); + }); + + test('hides semantic_search when the model is a BYOK / custom endpoint', async () => { + // A BYOK / custom endpoint is identified by a string URL rather than CAPI request metadata. + const byokEndpoint = instantiationService.createInstance(MockEndpoint, 'gpt-5'); + (byokEndpoint as { urlOrRequestMetadata: string }).urlOrRequestMetadata = 'https://localhost:8080/v1/chat/completions'; + const request = new TestChatRequest('how does foo work'); + const tools = await instantiationService.invokeFunction(getAgentTools, request, byokEndpoint); + expect(hasTool(tools, ToolName.Codebase)).toBe(false); + }); }); diff --git a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts index 0c654168a52028..7911e845e42b76 100644 --- a/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts +++ b/extensions/copilot/src/extension/log/vscode-node/loggingActions.ts @@ -47,9 +47,16 @@ interface ProxyAgentParams { loadSystemCertificatesFromNode: () => boolean | undefined; } +interface ResolvedProxyInfo { + url: string | undefined; + type: 'DIRECT' | 'PROXY' | 'HTTP' | 'HTTPS' | 'SOCKS' | 'SOCKS5' | 'SOCKS4' | 'EMPTY' | 'UNRECOGNIZED'; + source: 'localhost' | 'noProxyConfig' | 'noProxyEnv' | 'setting' | 'env' | 'remote' | 'system_cached' | 'system' | 'fallback'; +} + interface ProxyAgent { loadSystemCertificates?(params: ProxyAgentParams): Promise; resolveProxyURL?(url: string): Promise; + resolveProxyByURL?(url: string): Promise; } export class LoggingActionsContrib { @@ -471,6 +478,41 @@ function getProxyEnvVariables() { return res.length ? `\n\nEnvironment Variables:${res.join('')}` : ''; } +interface ProxyInfo { + /** Resolved proxy type: `DIRECT`, `PROXY`, `HTTP`, `HTTPS`, `SOCKS`, `SOCKS5`, `SOCKS4`, `EMPTY`, `UNRECOGNIZED` or `UNKNOWN`. */ + type: string; + /** + * Where the proxy configuration came from: `localhost`, `noProxyConfig` + * (`http.noProxy`), `noProxyEnv` (`no_proxy`), `setting` (`http.proxy`), `env` + * (`http(s)_proxy`), `remote`, `system_cached`, `system` (OS/PAC) or `fallback` + * from `@vscode/proxy-agent`, or one of the failure sentinels `missing` + * (module/API unavailable), `timeout` or `error`. + */ + source: string; +} + +/** + * Resolves the proxy type and configuration source for a URL using the bundled + * `@vscode/proxy-agent` module. The source reflects which configuration actually + * determined the resolved proxy (see `@vscode/proxy-agent`'s `resolveProxyByURL`). + */ +async function resolveProxyInfo(url: string, logService: ILogService): Promise { + try { + const proxyAgent = loadVSCodeModule('@vscode/proxy-agent'); + if (!proxyAgent?.resolveProxyByURL) { + return { type: 'UNKNOWN', source: 'missing' }; + } + const info = await Promise.race([proxyAgent.resolveProxyByURL(url), timeoutAfter(5000)]); + if (info === 'timeout') { + return { type: 'UNKNOWN', source: 'timeout' }; + } + return { type: info.type, source: info.source }; + } catch (err) { + logService.debug(`Fetcher telemetry: Failed to resolve proxy info: ${err?.message}`); + return { type: 'UNKNOWN', source: 'error' }; + } +} + export class FetcherTelemetryContribution { constructor( @IInstantiationService instantiationService: IInstantiationService, @@ -485,6 +527,7 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { const logService = accessor.get(ILogService); const configurationService = accessor.get(IConfigurationService); const expService = accessor.get(IExperimentationService); + const capiClientService = accessor.get(ICAPIClientService); if (!vscode.env.isTelemetryEnabled || extensionContext.extensionMode !== vscode.ExtensionMode.Production || isScenarioAutomation) { return; @@ -539,6 +582,9 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { } } + // Resolve the proxy type and configuration source for the CAPI endpoint. + const { type: proxyType, source: proxySource } = await resolveProxyInfo(capiClientService.capiPingURL, logService); + // Second loop: send the actual telemetry event including probe results. const requestGroupId = generateUuid(); const extensionKind = extensionContext.extension.extensionKind === vscode.ExtensionKind.UI ? 'local' : 'remote'; @@ -553,6 +599,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { "clientLibrary": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The fetcher library used for this request." }, "extensionKind": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Whether the extension runs locally or remotely." }, "remoteName": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The remote name, if any." }, + "proxyType": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The resolved proxy type for the CAPI endpoint (e.g. DIRECT, PROXY, HTTP, HTTPS, SOCKS, SOCKS5, SOCKS4, EMPTY, UNRECOGNIZED, UNKNOWN)." }, + "proxySource": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Where the proxy configuration came from: localhost, noProxyConfig (http.noProxy), noProxyEnv (no_proxy), setting (http.proxy), env (http(s)_proxy), remote, system_cached, system (OS/PAC), fallback, or a failure sentinel: missing, timeout or error." }, "electronfetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the electron-fetch fetcher." }, "nodefetch": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-fetch fetcher." }, "nodehttp": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "Probe result for the node-http fetcher." } @@ -563,6 +611,8 @@ function collectFetcherTelemetry(accessor: ServicesAccessor): void { clientLibrary: fetcher.getUserAgentLibrary(), extensionKind, remoteName: vscode.env.remoteName ?? 'none', + proxyType, + proxySource, ...probeResults, }; const response = await sendRawTelemetry(fetcher, envService, oneCollectorTelemetryUrl, extensionContext, 'GitHub.copilot-chat/fetcherTelemetry', properties); diff --git a/extensions/copilot/src/extension/prompt/node/promptCategorizer.ts b/extensions/copilot/src/extension/prompt/node/promptCategorizer.ts index 96ea229e30e7c3..904a39594b9ec1 100644 --- a/extensions/copilot/src/extension/prompt/node/promptCategorizer.ts +++ b/extensions/copilot/src/extension/prompt/node/promptCategorizer.ts @@ -22,6 +22,7 @@ import { renderPromptElement } from '../../prompts/node/base/promptRenderer'; import { PromptCategorizationPrompt } from '../../prompts/node/panel/promptCategorization'; import { CATEGORIZE_PROMPT_TOOL_NAME, CATEGORIZE_PROMPT_TOOL_SCHEMA, isValidDomain, isValidIntent, isValidScope, PromptClassification } from '../common/promptCategorizationTaxonomy'; import { getModeNameForTelemetry } from './telemetry'; +import { isCAPIEndpoint } from '../../../platform/networking/common/networking'; /** Experiment flag to enable prompt categorization */ const EXP_FLAG_PROMPT_CATEGORIZATION = 'copilotchat.promptCategorization'; @@ -179,6 +180,14 @@ export class PromptCategorizerService implements IPromptCategorizerService { const timeoutHandle = setTimeout(() => cts.cancel(), CATEGORIZATION_TIMEOUT_MS); try { + const mainModelEndpoint = await this.endpointProvider.getChatEndpoint(request); + + if (!isCAPIEndpoint(mainModelEndpoint)) { + // The main model is a BYOK model and prompt categorization should not be run. + this.logService.debug('[PromptCategorizer] Skipping categorization because main model is BYOK'); + return; + } + const endpoint = await this.endpointProvider.getChatEndpoint('copilot-utility-small'); const { messages } = await renderPromptElement( diff --git a/extensions/copilot/src/extension/prompt/node/test/promptCategorizer.spec.ts b/extensions/copilot/src/extension/prompt/node/test/promptCategorizer.spec.ts new file mode 100644 index 00000000000000..9962a1de28fd86 --- /dev/null +++ b/extensions/copilot/src/extension/prompt/node/test/promptCategorizer.spec.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, expect, suite, test, vi } from 'vitest'; +import type * as vscode from 'vscode'; +import { ICopilotTokenStore } from '../../../../platform/authentication/common/copilotTokenStore'; +import { ChatFetchResponseType } from '../../../../platform/chat/common/commonTypes'; +import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger'; +import { ITabsAndEditorsService } from '../../../../platform/tabs/common/tabsAndEditorsService'; +import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { PromptCategorizerService } from '../promptCategorizer'; + +vi.mock('../../../prompts/node/base/promptRenderer', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + renderPromptElement: vi.fn(async () => ({ + messages: [], + tokenCount: 0, + metadatas: new Map(), + references: [], + })), + }; +}); + +suite('PromptCategorizerService', () => { + let endpointProvider: { getChatEndpoint: ReturnType }; + let requestLogger: { captureInvocation: ReturnType }; + let telemetryService: { + sendMSFTTelemetryEvent: ReturnType; + sendInternalMSFTTelemetryEvent: ReturnType; + }; + let logService: { + debug: ReturnType; + warn: ReturnType; + error: ReturnType; + }; + let service: PromptCategorizerService; + + beforeEach(() => { + endpointProvider = { + getChatEndpoint: vi.fn(), + }; + + requestLogger = { + captureInvocation: vi.fn(), + }; + + telemetryService = { + sendMSFTTelemetryEvent: vi.fn(), + sendInternalMSFTTelemetryEvent: vi.fn(), + }; + + logService = { + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + + const experimentationService = { + getTreatmentVariable: vi.fn().mockReturnValue(true), + }; + + const tabsAndEditorsService = { + activeTextEditor: undefined, + }; + + const copilotTokenStore = { + copilotToken: { isInternal: true }, + }; + + service = new PromptCategorizerService( + logService as unknown as ILogService, + endpointProvider as unknown as IEndpointProvider, + {} as IInstantiationService, + telemetryService as unknown as ITelemetryService, + experimentationService as unknown as IExperimentationService, + tabsAndEditorsService as unknown as ITabsAndEditorsService, + copilotTokenStore as unknown as ICopilotTokenStore, + requestLogger as unknown as IRequestLogger, + ); + }); + + test('skips prompt categorization when main endpoint is BYOK', async () => { + const request = { + location2: undefined, + subAgentName: undefined, + attempt: 0, + prompt: 'help me debug this', + references: [], + toolReferences: [], + sessionId: 'test-session-id', + id: 'test-request-id', + } as unknown as vscode.ChatRequest; + + const context = { + history: [], + } as unknown as vscode.ChatContext; + + endpointProvider.getChatEndpoint.mockResolvedValue({ + urlOrRequestMetadata: 'https://byok.example/v1/chat/completions', + }); + + service.categorizePrompt(request, context, 'telemetry-message-id'); + + // categorizePrompt is fire-and-forget; allow the async branch to complete + await new Promise(setImmediate); + + expect(endpointProvider.getChatEndpoint).toHaveBeenCalledTimes(1); + expect(endpointProvider.getChatEndpoint).toHaveBeenCalledWith(request); + expect(logService.debug).toHaveBeenCalledWith('[PromptCategorizer] Skipping categorization because main model is BYOK'); + expect(requestLogger.captureInvocation).not.toHaveBeenCalled(); + expect(telemetryService.sendMSFTTelemetryEvent).not.toHaveBeenCalled(); + expect(telemetryService.sendInternalMSFTTelemetryEvent).not.toHaveBeenCalled(); + }); + + test('runs prompt categorization when main endpoint is non-BYOK', async () => { + const request = { + location2: undefined, + subAgentName: undefined, + attempt: 0, + prompt: 'help me debug this', + references: [], + toolReferences: [], + sessionId: 'test-session-id', + id: 'test-request-id', + } as unknown as vscode.ChatRequest; + + const context = { + history: [], + } as unknown as vscode.ChatContext; + + endpointProvider.getChatEndpoint + .mockResolvedValueOnce({ + urlOrRequestMetadata: { requestPath: '/chat/completions' }, + }) + .mockResolvedValueOnce({ + urlOrRequestMetadata: { requestPath: '/chat/completions' }, + makeChatRequest2: vi.fn().mockResolvedValue({ + type: ChatFetchResponseType.Success, + }), + }); + + requestLogger.captureInvocation.mockImplementation(async (_capturingToken, fn) => fn()); + + service.categorizePrompt(request, context, 'telemetry-message-id'); + + // categorizePrompt is fire-and-forget; allow the async branch to complete + await new Promise(setImmediate); + + expect(endpointProvider.getChatEndpoint).toHaveBeenCalledTimes(2); + expect(endpointProvider.getChatEndpoint).toHaveBeenNthCalledWith(1, request); + expect(endpointProvider.getChatEndpoint).toHaveBeenNthCalledWith(2, 'copilot-utility-small'); + expect(requestLogger.captureInvocation).toHaveBeenCalledTimes(1); + expect(telemetryService.sendMSFTTelemetryEvent).toHaveBeenCalledTimes(1); + expect(telemetryService.sendInternalMSFTTelemetryEvent).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts index af5d0560bd0191..655d3b1e0ed00e 100644 --- a/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts +++ b/extensions/copilot/src/extension/prompt/vscode-node/endpointProviderImpl.ts @@ -21,6 +21,13 @@ import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; +// Keep in sync with `BYOKUtilityModelDefault` in `src/vs/workbench/contrib/chat/common/constants.ts` and the `chat.byokUtilityModelDefault` enum in `chat.shared.contribution.ts`. +const enum BYOKUtilityModelDefault { + None = 'none', + MainAgent = 'mainAgent', + Copilot = 'copilot', +} + export class ProductionEndpointProvider extends Disposable implements IEndpointProvider { declare readonly _serviceBrand: undefined; @@ -53,11 +60,14 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP this._onDidModelsRefresh.fire(); })); - // When the user changes their utility model overrides we need to invalidate any - // previously-resolved utility alias endpoints so the next request re-resolves. + // Utility model configuration changes invalidate previously resolved aliases. this._register(this._configService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(ProductionEndpointProvider.UTILITY_MODEL_CONFIG_KEY) || e.affectsConfiguration(ProductionEndpointProvider.UTILITY_SMALL_MODEL_CONFIG_KEY)) { - this._logService.trace(`[ProductionEndpointProvider] Utility model override changed; invalidating alias endpoints.`); + if ( + e.affectsConfiguration(ProductionEndpointProvider.UTILITY_MODEL_CONFIG_KEY) + || e.affectsConfiguration(ProductionEndpointProvider.UTILITY_SMALL_MODEL_CONFIG_KEY) + || e.affectsConfiguration(ProductionEndpointProvider.BYOK_UTILITY_MODEL_DEFAULT_CONFIG_KEY) + ) { + this._logService.trace(`[ProductionEndpointProvider] Utility model configuration changed; invalidating alias endpoints.`); // Clear telemetry fingerprints so a re-applied override emits // once for its new value. this._lastOverrideTelemetryFingerprint.clear(); @@ -76,6 +86,8 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP // `vscode.lm.selectChatModels({ vendor, id })`. private static readonly UTILITY_MODEL_CONFIG_KEY = 'chat.utilityModel'; private static readonly UTILITY_SMALL_MODEL_CONFIG_KEY = 'chat.utilitySmallModel'; + private static readonly BYOK_UTILITY_MODEL_DEFAULT_CONFIG_KEY = 'chat.byokUtilityModelDefault'; + private _mainAgentBYOKModel: LanguageModelChat | undefined; /** * Per-family marker recording that we already emitted a telemetry event @@ -108,6 +120,18 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP return this.getChatEndpoint('copilot-utility'); } + if (model.id !== 'copilot-utility' && model.id !== 'copilot-utility-small') { + const mainAgentBYOKModel = model.vendor !== 'copilot' ? model : undefined; + const mainAgentModelChanged = this._mainAgentBYOKModel?.vendor !== mainAgentBYOKModel?.vendor + || this._mainAgentBYOKModel?.id !== mainAgentBYOKModel?.id + || this._mainAgentBYOKModel?.version !== mainAgentBYOKModel?.version; + this._mainAgentBYOKModel = mainAgentBYOKModel; + if (mainAgentModelChanged) { + this._lastOverrideTelemetryFingerprint.clear(); + this._onDidModelsRefresh.fire(); + } + } + if (model.vendor !== 'copilot') { return this._instantiationService.createInstance(ExtensionContributedChatEndpoint, model); } @@ -156,22 +180,46 @@ export class ProductionEndpointProvider extends Disposable implements IEndpointP * `copilot-utility`) to a concrete `CopilotChatEndpoint`. The model * selection for each family lives in the corresponding resolver * class so callers don't need to know which CAPI family backs each - * purpose. For any other string, falls through to a direct CAPI - * family lookup so callers can resolve arbitrary CAPI-registered - * model families (e.g. `trajectory-compaction`) by name. + * purpose. */ - private async _resolveUtilityFamily(family: ChatEndpointFamily): Promise { + private async _resolveUtilityFamily(family: 'copilot-utility' | 'copilot-utility-small'): Promise { const override = await this._resolveUtilityOverride(family); if (override) { return override; } - if (family === 'copilot-utility-small') { - return CopilotUtilitySmallChatEndpoint.resolve(this._modelFetcher, this._instantiationService); - } else if (family === 'copilot-utility') { - return CopilotUtilityChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + + if (this._mainAgentBYOKModel) { + switch (this._getBYOKUtilityModelDefault()) { + case BYOKUtilityModelDefault.MainAgent: + return this._instantiationService.createInstance(ExtensionContributedChatEndpoint, this._mainAgentBYOKModel); + case BYOKUtilityModelDefault.None: + throw new Error(`No utility model is configured for '${family}' while the selected main agent model is BYOK.`); + case BYOKUtilityModelDefault.Copilot: + break; + } + } + + switch (family) { + case 'copilot-utility-small': + return CopilotUtilitySmallChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + case 'copilot-utility': + return CopilotUtilityChatEndpoint.resolve(this._modelFetcher, this._instantiationService); + } + } + + private _getBYOKUtilityModelDefault(): BYOKUtilityModelDefault { + const value = this._configService.getNonExtensionConfig(ProductionEndpointProvider.BYOK_UTILITY_MODEL_DEFAULT_CONFIG_KEY); + switch (value) { + case undefined: + return BYOKUtilityModelDefault.None; + case BYOKUtilityModelDefault.None: + case BYOKUtilityModelDefault.MainAgent: + case BYOKUtilityModelDefault.Copilot: + return value; + default: + this._logService.warn(`[ProductionEndpointProvider] Ignoring invalid ${ProductionEndpointProvider.BYOK_UTILITY_MODEL_DEFAULT_CONFIG_KEY} value: '${String(value)}'.`); + return BYOKUtilityModelDefault.None; } - const modelMetadata = await this._modelFetcher.getChatModelFromCapiFamily(family); - return this.getOrCreateChatEndpointInstance(modelMetadata); } /** diff --git a/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts index 098d8460fe8f24..9ba16b04ad95b6 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/allAgentPrompts.ts @@ -6,6 +6,7 @@ import './anthropicPrompts'; import './familyHPrompts'; import './geminiPrompts'; +import './kimiPrompts'; import './minimaxPrompts'; import './vscModelPrompts'; // vscModelPrompts must be imported before gpt5Prompt to ensure VSC model prompt resolvers are registered first. @@ -16,9 +17,8 @@ import './openai/gpt52Prompt'; import './openai/gpt53CodexPrompt'; import './openai/gpt54Prompt'; import './openai/gpt55Prompt'; +import './openai/gpt56Prompt'; import './openai/gpt5CodexPrompt'; import './openai/gpt5Prompt'; -import './openai/hiddenModelMPrompt'; import './xAIPrompts'; import './zaiPrompts'; - diff --git a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx index 55b3e60acab15a..0d6e487b57b052 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/anthropicPrompts.tsx @@ -20,12 +20,7 @@ import { CodesearchModeInstructions, DefaultAgentPromptProps, detectToolCapabili import { FileLinkificationInstructions, FileLinkificationInstructionsOptimized } from './fileLinkificationInstructions'; import { IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SystemPrompt } from './promptRegistry'; -/** - * Prompt component that provides instructions for using the tool search tool - * to load deferred tools before calling them directly. See - * `ToolSearchToolPromptOptimized` for the rationale behind keeping the - * deferred-tool inventory out of this (system-prompt) component. - */ +/** Instructions for using the tool search tool to load deferred tools before calling them. */ class ToolSearchToolPrompt extends PromptElement { constructor( props: PromptElementProps, @@ -293,11 +288,7 @@ class Claude45DefaultPrompt extends PromptElement { } } -/** - * Base class for optimized Claude 4.6 prompt configurations. - * Renders the shared base prompt sections from the optimization test plan. - * Subclasses provide specific exploration guidance and . - */ +/** Base class for optimized Claude 4.6 prompt configurations. */ class Claude46OptimizedBasePrompt extends PromptElement { constructor( props: PromptElementProps, @@ -315,6 +306,11 @@ class Claude46OptimizedBasePrompt extends PromptElement return undefined; } + /** Rendered after all other sections. */ + protected renderAppendedInstructions(): PromptPiece | undefined { + return undefined; + } + async render(state: void, sizing: PromptSizing) { const tools = detectToolCapabilities(this.props.availableTools); const endpoint = sizing.endpoint as IChatEndpoint | undefined; @@ -405,14 +401,12 @@ class Claude46OptimizedBasePrompt extends PromptElement + {this.renderAppendedInstructions()} ; } } -/** - * Optimized prompt for Sonnet 4.6. - * Uses moderate exploration guidance that balances persistence with bounding. - */ +/** Optimized prompt for Sonnet 4.6. */ class Claude46SonnetPrompt extends Claude46OptimizedBasePrompt { protected override renderExplorationGuidance(_tools: ReturnType) { return <> @@ -428,10 +422,7 @@ class Claude46SonnetPrompt extends Claude46OptimizedBasePrompt { } } -/** - * Opus-specific optimized prompt for Claude 4.6. - * Uses bounded exploration guidance to reduce over-exploration observed in benchmarks. - */ +/** Opus-specific optimized prompt for Claude 4.6. */ class Claude46OpusPrompt extends Claude46OptimizedBasePrompt { protected override renderExplorationGuidance(_tools: ReturnType) { return <> @@ -447,17 +438,18 @@ class Claude46OpusPrompt extends Claude46OptimizedBasePrompt { } } -/** - * Opus-specific optimized prompt for Claude 4.7. - * - * Standalone copy of the Claude 4.6 Opus prompt, kept separate from the - * shared optimized base so it can be iterated on independently. Behavioral - * additions vs Claude 4.6 Opus reflect guidance from the Opus 4.7 prompting - * guide (tool triggering, subagent fan-out, response shape) and lessons - * imported from the Claude Code system prompt (no internal narration, - * end-of-turn summary cap, comment discipline, subagent verification). - */ -class Claude47OpusPrompt extends PromptElement { +/** Sonnet 5 prompt: Claude 4.6 Sonnet base plus the validated scope/interface/effort append. */ +class ClaudeSonnet5Prompt extends Claude46SonnetPrompt { + protected override renderAppendedInstructions() { + return <> + Do exactly what was asked - don't extend scope to new adjacent code, tests, examples, or unrelated files unless the task explicitly includes them. But always preserve existing public interfaces: never remove or change the signature of module-level functions, exported names, or class methods that other code might depend on, unless explicitly told to. If you think adjacent work is needed, mention it rather than doing it.
+ Be direct and minimal on file reads and editor-tool calls; spend your reasoning on design decisions, debugging, and writing code. Keep your responses to the user concise - a few sentences explaining what you did and why is enough; the work happens in tool calls, not in prose.
+ ; + } +} + +/** Opus-specific optimized prompt for Claude 4.8. */ +class Claude48OpusPrompt extends PromptElement { constructor( props: PromptElementProps, @IConfigurationService private readonly configurationService: IConfigurationService, @@ -480,6 +472,7 @@ class Claude47OpusPrompt extends PromptElement { You are a highly sophisticated automated coding agent with expert-level knowledge across many different programming languages and frameworks and software engineering tasks.
The user will ask a question or ask you to perform a task. There is a selection of tools that let you perform actions or retrieve helpful context.
By default, implement changes rather than only suggesting them. If the user's intent is unclear, infer the most useful likely action and proceed with using tools to discover missing details instead of guessing.
+ When the user asks you to plan, explain, review, or brainstorm, do not edit files or run state-changing commands — deliver the plan or explanation, and implement only when asked. Once the user approves a plan or asks you to proceed, that is the signal to implement it fully.
Gather sufficient context to act confidently, then proceed to implementation. Stop searching once you have enough to act — overlapping results across multiple queries are a strong signal you have sufficient context.
Persist through genuine blockers, but do not over-explore. When you encounter an error or blocker, diagnose the cause and try a different approach rather than retrying the same call or brute-forcing your way around it.
Avoid giving time estimates.
@@ -507,6 +500,13 @@ class Claude47OpusPrompt extends PromptElement { - Default to no comments on code you write. Add one only when the WHY is non-obvious — a hidden constraint, a subtle invariant, a workaround, or behavior that would surprise a reader. Never explain what the code already says, and never reference the current task, fix, or caller ("added for X", "handles case Y") — that belongs in the PR description, not the code. Keep any comment to one short line; do not write multi-paragraph docstrings or multi-line comment blocks
- Don't add docstrings, comments, or type annotations to code you didn't change
+ + Before reporting a task complete, verify it: run the project's relevant build, tests, or checks and read their output. Only claim results you have observed in this session — never state that builds pass or tests succeed without having run them after your last change.
+ When the task states acceptance criteria — specific commands, tests, or behaviors that must pass — verify against those, not a weaker substitute you chose yourself.
+ If a check fails, cannot be run, or was skipped, say so explicitly. If you completed only part of the request, list what remains; do not present partial completion as done.
+ For changes that must apply across a codebase (migrations, renames, cross-cutting rules), enumerate the affected occurrences with a search first, and re-run that same search after editing. The change is not complete while the search still returns matches.
+ Tasks that changed no code (questions, explanations, reviews) need no verification pass.
+
You may parallelize independent read-only operations when appropriate.
@@ -518,7 +518,8 @@ class Claude47OpusPrompt extends PromptElement { {tools[ToolName.CoreManageTodoList] && <> - Use the {ToolName.CoreManageTodoList} tool when working on multi-step tasks that benefit from tracking. Update task status consistently: mark in-progress when starting, completed immediately after finishing. Skip task tracking for simple, single-step operations.
+ Use the {ToolName.CoreManageTodoList} tool only when the work spans three or more distinct steps or files. Never create a todo list for a question, an explanation, or a single-file change.
+ When you do track a task, keep the list current: mark each step in-progress when you start it and completed immediately after finishing it.
} {contextCompactionEnabled && <> @@ -540,6 +541,7 @@ class Claude47OpusPrompt extends PromptElement { {tools[ToolName.Codebase] && <>If {ToolName.Codebase} returns the full workspace contents, you have all the context.
} {tools[ToolName.Codebase] && tools[ToolName.FindTextInFiles] && tools[ToolName.FindFiles] && <>For semantic search across the workspace, use {ToolName.Codebase}. For exact text matches, use {ToolName.FindTextInFiles}. For files by name or path pattern, use {ToolName.FindFiles}. Do not skip search and go directly to {ToolName.ReadFile} unless you are confident about the exact file path.
} {tools[ToolName.CoreRunInTerminal] && <>Do not call {ToolName.CoreRunInTerminal} multiple times in parallel. Run one command and wait for output before running the next.
} + {tools[ToolName.CoreRunInTerminal] && <>Run a command once and read its output rather than re-running it to check again. For long-running or blocking commands (servers, watchers, interactive prompts), set isBackground to true and read the terminal output instead of blocking on it or re-invoking it.
} {tools[ToolName.ExecutionSubagent] && <>Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.
} When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, use a URI with the scheme.
{tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as when visualizing or validating UI changes.
} @@ -552,6 +554,8 @@ class Claude47OpusPrompt extends PromptElement {
When the task needs information that is not already in context, use the available tools to gather it rather than guessing or relying on assumptions.
+ Some tools are the authoritative source for this surface and are more current than your own knowledge: documentation lookup tools, project-scaffolding tools, and instruction or skill files in the workspace. When the task falls within such a tool's domain, consult it before answering from memory — your training data may be stale for fast-moving APIs.
+ When the workspace or task references an instruction file, skill, or spec document, read it before acting on that area.
{tools.hasSomeEditTool && <>For tasks that require editing files, running tests, or otherwise modifying state, use the appropriate tool rather than describing the change.
} Prefer concrete tool calls over speculation; do not stop short of a tool call when one is clearly needed to make progress.
@@ -586,10 +590,7 @@ class Claude47OpusPrompt extends PromptElement { } } -/** - * Condensed reminder instructions for optimized Claude 4.6 prompt configurations. - * Inlines editing reminder unconditionally and removes the tool_search reminder block. - */ +/** Condensed reminder instructions for optimized Claude 4.6 prompt configurations. */ class AnthropicReminderInstructionsOptimized extends PromptElement { constructor( props: PromptElementProps, @@ -637,13 +638,17 @@ class AnthropicPromptResolver implements IAgentPrompt { return endpoint.model.startsWith('claude-sonnet') || endpoint.family.startsWith('claude-sonnet'); } + private isSonnet5(endpoint: IChatEndpoint): boolean { + return endpoint.model.startsWith('claude-sonnet-5') || endpoint.family.startsWith('claude-sonnet-5'); + } + private isHaiku(endpoint: IChatEndpoint): boolean { return endpoint.model.startsWith('claude-haiku') || endpoint.family.startsWith('claude-haiku'); } - private isOpus47(endpoint: IChatEndpoint): boolean { - return endpoint.model.startsWith('claude-opus-4-7') || endpoint.model.startsWith('claude-opus-4.7') - || endpoint.family.startsWith('claude-opus-4-7') || endpoint.family.startsWith('claude-opus-4.7'); + private isOpus48(endpoint: IChatEndpoint): boolean { + return endpoint.model.startsWith('claude-opus-4-8') || endpoint.model.startsWith('claude-opus-4.8') + || endpoint.family.startsWith('claude-opus-4-8') || endpoint.family.startsWith('claude-opus-4.8'); } resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { @@ -653,17 +658,19 @@ class AnthropicPromptResolver implements IAgentPrompt { if (this.isClaude45(endpoint)) { return Claude45DefaultPrompt; } + if (this.isSonnet5(endpoint) && this.configurationService.getExperimentBasedConfig(ConfigKey.ClaudeSonnet5PromptEnabled, this.experimentationService)) { + return ClaudeSonnet5Prompt; + } if (this.isSonnet(endpoint)) { return Claude46SonnetPrompt; } if (this.isHaiku(endpoint)) { return Claude45DefaultPrompt; } - if (this.isOpus47(endpoint) && this.configurationService.getExperimentBasedConfig(ConfigKey.Claude47OpusPromptEnabled, this.experimentationService)) { - return Claude47OpusPrompt; + if (this.isOpus48(endpoint) && this.configurationService.getExperimentBasedConfig(ConfigKey.Claude48OpusPromptEnabled, this.experimentationService)) { + return Claude48OpusPrompt; } - // Default for every other current and future model (including ones not - // yet individually recognized): the latest general-purpose Opus prompt. + // Default for every other current and future model (including Opus 4.7). return Claude46OpusPrompt; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx index 88e94119117ebc..aa04836944ef00 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/copilotCLIPrompt.tsx @@ -124,15 +124,20 @@ export async function generateUserPrompt(request: ChatRequest, prompt: string | editedFileEvents: request.editedFileEvents, }); - const userMessages = messages.filter(message => message.role === ChatRole.User); - if (userMessages.length > 0) { - const textParts = userMessages.flatMap(message => message.content); - if (textParts.every(part => part.type === ChatCompletionContentPartKind.Text)) { - return textParts.map(part => part.text).join(''); - } - } - throw new Error(`[CopilotCLISession] Unexpected generated prompt structure.`); + // Copilot CLI only accepts a plain text prompt. Any non-text content (e.g. images) is delivered + // separately to the SDK as attachments, so we only gather the text parts of the user messages here + // and ignore everything else. We must not fail the whole request when the rendered structure is not + // purely text or when it is empty (e.g. the prompt was empty or was entirely pruned to fit the token + // budget), as throwing would break the agent entirely (see #323696). + const text = messages + .filter(message => message.role === ChatRole.User) + .flatMap(message => message.content) + .filter(part => part.type === ChatCompletionContentPartKind.Text) + .map(part => part.text) + .join(''); + // Fall back to the original prompt when nothing was rendered so we never send an empty prompt. + return text || prompt || request.prompt || ''; } async function renderResourceVariables(chatVariables: ChatVariablesCollection, fileSystemService: IFileSystemService, promptPathRepresentationService: IPromptPathRepresentationService): Promise { diff --git a/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx new file mode 100644 index 00000000000000..30725b77ccff3a --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/kimiPrompts.tsx @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; +import { isKimiFamily } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; +import { InstructionMessage } from '../base/instructionMessage'; +import { ResponseTranslationRules } from '../base/responseTranslationRules'; +import { Tag } from '../base/tag'; +import { EXISTING_CODE_MARKER } from '../panel/codeBlockFormattingRules'; +import { ResponseRenderingRules } from '../panel/editorIntegrationRules'; +import { ApplyPatchInstructions, CodesearchModeInstructions, DefaultAgentPromptProps, DefaultReminderInstructions, detectToolCapabilities, GenericEditingTips, McpToolInstructions, NotebookInstructions } from './defaultAgentInstructions'; +import { FileLinkificationInstructions } from './fileLinkificationInstructions'; +import { IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SystemPrompt } from './promptRegistry'; + +class KimiAgentPrompt extends PromptElement { + async render(state: void, sizing: PromptSizing) { + const tools = detectToolCapabilities(this.props.availableTools); + + return + + You are an expert AI programming assistant, working with a user in the VS Code editor. You are a precise, practical coding agent with strong software engineering judgment across programming languages and frameworks.
+ Follow the user's requirements carefully and use the provided workspace context, attachments, and tool results as reference material. If the answer is not supported by the available context, gather more context before acting or state the limitation clearly. +
+ + + Use clear, step-by-step task execution:
+ - For simple questions or code samples, answer directly without unnecessary tool calls.
+ - For codebase questions, gather the smallest sufficient set of relevant context, then answer with concrete references.
+ - For implementation tasks, identify the controlling code path, make focused changes, and validate with the most relevant available checks.
+ - For feature requests without specified files, break the request into concepts and find the files responsible for those concepts before editing.
+ - Do not guess about APIs, file paths, or project conventions. Verify them using context or tools. +
+ + + Avoid excessive looping or repetition:
+ - If you find yourself running similar commands or re-editing the same files without clear progress, stop and reassess rather than continuing to loop.
+ - If an action fails or does not work as expected, do not retry it unchanged. Understand why it failed, then try a different approach.
+ - Never call the same tool with the same arguments more than twice in a row.
+ - If you are stuck or no longer making progress, end the turn with a concise summary of what you tried, what is blocked, and any clarifying question needed. +
+ + + Important: Use built-in tools instead of terminal commands whenever possible.
+ {tools[ToolName.ReadFile] && <>- Use {ToolName.ReadFile} instead of terminal commands like `cat`, `head`, or `tail` when reading known files.
} + {tools[ToolName.FindTextInFiles] && <>- Use {ToolName.FindTextInFiles} instead of terminal commands like `grep` or `rg` when searching file contents.
} + {tools[ToolName.FindFiles] && <>- Use {ToolName.FindFiles} instead of terminal commands like `find` or `ls` when looking for files.
} + {tools.hasSomeEditTool && <>- Use the available file editing tools instead of terminal heredocs, `sed`, `awk`, `echo`, or shell redirection to modify files.
} + {tools[ToolName.CoreRunInTerminal] && <>- Use {ToolName.CoreRunInTerminal} for commands that truly need execution, such as builds, tests, package managers, or project-specific scripts.
} +
+ + + You will be given context and attachments along with the user prompt. Use relevant context and ignore irrelevant context.{tools[ToolName.ReadFile] && <> Some attachments may be summarized with omitted sections like `/* Lines 123-456 omitted */`. Use {ToolName.ReadFile} to read more context if needed. Never pass this omitted line marker to an edit tool.}
+ If you can infer the project type (languages, frameworks, and libraries) from the user's query or the context, keep it in mind when making changes.
+ When reading files, prefer reading large meaningful chunks rather than consecutive small sections to minimize tool calls and gain better context.
+ You do not need to read a file if it is already provided in context. +
+ + + When using a tool, follow the JSON schema carefully and include all required properties.
+ No need to ask permission before using a tool.
+ NEVER say the name of a tool to a user. For example, instead of saying that you'll use the {ToolName.CoreRunInTerminal} tool, say "I'll run the command in a terminal".
+ If multiple independent tool calls can answer the user's question, prefer calling them in parallel whenever possible{tools[ToolName.Codebase] && <>, but do not call {ToolName.Codebase} in parallel}.
+ {(tools[ToolName.SearchSubagent] || tools[ToolName.ExploreSubagent]) && <>For efficient codebase exploration, prefer {tools[ToolName.SearchSubagent] ? ToolName.SearchSubagent : ToolName.ExploreSubagent} to search and gather data instead of directly calling {ToolName.FindTextInFiles}, {ToolName.Codebase} or {ToolName.FindFiles}.
} + {tools[ToolName.ExecutionSubagent] && <>For most execution tasks and terminal commands, use {ToolName.ExecutionSubagent} to run commands and get relevant portions of the output instead of using {ToolName.CoreRunInTerminal}. Use {ToolName.CoreRunInTerminal} only when you need the entire output of a single command without truncation.
} + {tools[ToolName.ReadFile] && <>When using {ToolName.ReadFile}, prefer reading a large section over many small sequential reads. Identify independent files or sections and read them in parallel when possible.
} + {tools[ToolName.Codebase] && <>If {ToolName.Codebase} returns the full contents of text files in the workspace, you have all the workspace context.
} + {tools[ToolName.FindTextInFiles] && <>Use {ToolName.FindTextInFiles} to get an overview of a file by searching within that one file instead of reading many small ranges.
} + {tools[ToolName.Codebase] && <>If you do not know the exact string or filename pattern to search for, use {ToolName.Codebase} for semantic search across the workspace.
} + {tools[ToolName.CoreRunInTerminal] && <>Do not call {ToolName.CoreRunInTerminal} multiple times in parallel. Run one command and wait for the output before running the next command.
} + {tools[ToolName.ExecutionSubagent] && <>Do not call {ToolName.ExecutionSubagent} multiple times in parallel. Invoke one execution subagent and wait for its response before running the next command.
} + When invoking a tool that takes a file path, always use the absolute file path. If the file has a scheme like untitled: or vscode-userdata:, use a URI with the scheme.
+ {tools[ToolName.CoreRunInTerminal] && <>NEVER try to edit a file by running terminal commands unless the user specifically asks for it.
} + {!tools.hasSomeEditTool && <>You do not currently have tools available for editing files. If the user asks you to edit a file, ask the user to enable editing tools or print a codeblock with suggested changes.
} + {!tools[ToolName.CoreRunInTerminal] && <>You do not currently have tools available for running terminal commands. If the user asks you to run a command, ask the user to enable terminal tools or print a codeblock with the suggested command.
} + {tools[ToolName.CoreOpenBrowserPage] && tools.hasAgenticBrowserTools && <>Use the browser tools ({ToolName.CoreOpenBrowserPage}, {agenticBrowserTools.find(k => tools[k])}, etc.) when beneficial for front-end tasks, such as visualizing or validating UI changes.
} + Tools can be disabled by the user. You may see tools used previously in the conversation that are not currently available. Only use tools that are currently available. +
+ + {this.props.codesearchMode && } + + {tools[ToolName.ReplaceString] && !tools[ToolName.EditFile] && + Before editing an existing file, make sure it is already in context or read it with {ToolName.ReadFile}.
+ {tools[ToolName.MultiReplaceString] + ? <>Use {ToolName.ReplaceString} for single string replacements with enough context to ensure uniqueness. Prefer {ToolName.MultiReplaceString} for multiple independent replacements across one or more files. Do not announce which tool you're using.
+ : <>Use {ToolName.ReplaceString} to edit files. Include sufficient surrounding context so the replacement is unique. You can use this tool multiple times per file.
} + Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? ` or ${ToolName.MultiReplaceString}` : ''} instead.
+ For each file, give a short description of what needs to be changed, then use the edit tool.
+
} + + {tools[ToolName.EditFile] && !tools[ToolName.ApplyPatch] && + {tools[ToolName.ReplaceString] ? + <> + Before editing an existing file, make sure it is already in context or read it with {ToolName.ReadFile}.
+ {tools[ToolName.MultiReplaceString] + ? <>Use {ToolName.ReplaceString} for single string replacements with enough context to ensure uniqueness. Prefer {ToolName.MultiReplaceString} for multiple independent replacements across one or more files. Do not announce which tool you're using.
+ : <>Use {ToolName.ReplaceString} to edit files. Include sufficient surrounding context so the replacement is unique. You can use this tool multiple times per file.
} + Use {ToolName.EditFile} to insert code into a file only if {tools[ToolName.MultiReplaceString] ? `${ToolName.MultiReplaceString}/` : ''}{ToolName.ReplaceString} has failed.
+ Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.ReplaceString}{tools[ToolName.MultiReplaceString] ? `, ${ToolName.MultiReplaceString},` : ''} or {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use the edit tool.
+ : <> + Do not edit an existing file without reading it first.
+ Use {ToolName.EditFile} to edit files. Group changes by file.
+ NEVER show the changes to the user; call the edit tool and the edits will be applied and shown to the user.
+ NEVER print a codeblock that represents a change to a file. Use {ToolName.EditFile} instead.
+ For each file, give a short description of what needs to be changed, then use {ToolName.EditFile}.
+ } + + The {ToolName.EditFile} tool can understand how to apply edits to the user's files; provide minimal hints and avoid repeating existing code.
+ When using {ToolName.EditFile}, use comments to represent unchanged regions. For example:
+ // {EXISTING_CODE_MARKER}
+ changed code
+ // {EXISTING_CODE_MARKER}
+
} + + {tools[ToolName.ApplyPatch] && } + {this.props.availableTools && } + + + + Use proper Markdown formatting. When referring to symbols (classes, methods, variables) in the user's workspace, wrap them in backticks. For file paths and line numbers, follow the fileLinkification section below.
+ + +
+ +
; + } +} + +class KimiPromptResolver implements IAgentPrompt { + static readonly familyPrefixes: string[] = []; + + static matchesModel(endpoint: IChatEndpoint): boolean { + return isKimiFamily(endpoint); + } + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return KimiAgentPrompt; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return DefaultReminderInstructions; + } +} + +PromptRegistry.registerPrompt(KimiPromptResolver); diff --git a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx similarity index 76% rename from extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx rename to extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx index 9e07e54b428483..5918bfd9f2b592 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/openai/hiddenModelMPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/openai/gpt56Prompt.tsx @@ -4,26 +4,55 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; -import { isHiddenModelM } from '../../../../../platform/endpoint/common/chatModelCapabilities'; +import { isGpt56 } from '../../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../../platform/networking/common/networking'; import { ToolName } from '../../../../tools/common/toolNames'; -import { Gpt55CopilotIdentityRule as HiddenModelMCopilotIdentityRule } from '../../base/copilotIdentity'; +import { Gpt55CopilotIdentityRule as Gpt56CopilotIdentityRule } from '../../base/copilotIdentity'; import { InstructionMessage } from '../../base/instructionMessage'; import { ResponseTranslationRules } from '../../base/responseTranslationRules'; import { Gpt5SafetyRule } from '../../base/safetyRules'; import { Tag } from '../../base/tag'; -import { DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, ReminderInstructionsProps } from '../defaultAgentInstructions'; +import { ResponseRenderingRules } from '../../panel/editorIntegrationRules'; +import { ApplyPatchInstructions, DefaultAgentPromptProps, detectToolCapabilities, getEditingReminder, McpToolInstructions, ReminderInstructionsProps } from '../defaultAgentInstructions'; import { FileLinkificationInstructionsOptimized } from '../fileLinkificationInstructions'; import { CopilotIdentityRulesConstructor, IAgentPrompt, PromptRegistry, ReminderInstructionsConstructor, SafetyRulesConstructor, SystemPrompt } from '../promptRegistry'; import { CUSTOM_TOOL_SEARCH_NAME, ToolSearchToolPromptOptimized } from '../toolSearchInstructions'; -class HiddenModelMPrompt extends PromptElement { +class Gpt56Prompt extends PromptElement { async render(state: void, sizing: PromptSizing) { const tools = detectToolCapabilities(this.props.availableTools); return You are a coding agent running in VS Code. You and the user share one workspace, and your job is to collaborate with them until their goal is genuinely handled.
+ + - Start from the most concrete anchor available: a file, symbol, failing behavior, failing command, test, or nearby implementation surface. If the request does not name one explicitly, use the first targeted search or nearby read to identify that anchor, then continue locally from there.
+ - Before the first edit, gather only enough nearby evidence to state one falsifiable local hypothesis about how the requested behavior should work or why it is failing, and one cheap check that could disconfirm it.
+ - Keep that routing brief and local: use only enough targeted search and nearby reading to form one falsifiable local hypothesis and one cheap discriminating check.
+ - Use that budget to resolve the controlling code path and the cheapest discriminating check, not to map broad surrounding surfaces. Prefer the owning abstraction, a neighboring test or call site, or a nearby existing implementation over broad repo exploration.
+ - If the starting anchor mostly wires, forwards, registers, or contains the behavior rather than deciding it, step to the nearest code that directly computes, mutates, or controls the behavior.
+ - If multiple nearby paths look plausible, choose the one that best supports a falsifiable local hypothesis, the most discriminating nearby check, and the smallest testable change. Do not keep comparing neighbors just to gain confidence.
+ - Take a narrow additional read only if needed to distinguish between local hypotheses or to identify the cheapest discriminating check. After that read, choose and act.
+ - If you still cannot name a discriminating check because one nearby abstraction boundary, neighboring test, or call-site dependency remains unresolved, take one nearby triangulation read for that boundary. Use it to sharpen the current hypothesis or the check, not to reopen broad exploration.
+ - Once you can state one falsifiable local hypothesis, the nearby code path it depends on, one cheap check that could disconfirm it, and one small edit that would test it, the next action must be a grounded edit.
+ - If confidence is incomplete, the first edit may be a small reversible probe that exposes missing types, behavior mismatches, control-flow gaps, or validation failures.
+ - If you find yourself still searching after that local-routing budget, treat that as drift. Recover by choosing the best current hypothesis and the best available nearby check, then make the smallest plausible edit that will let that check discriminate.
+
+ + - After the first substantive edit, the very next step must be one focused validation action when one exists.
+ - Prefer this order for that first validation action:
+ - the cheapest behavior-scoped or failing check that can falsify the current hypothesis
+ - a narrow test for the touched slice
+ - a narrow compile, lint, or typecheck command for the touched slice
+ - `git diff` only when no narrower executable validation exists
+ - If a narrow executable validation exists, run it before doing more reading or patching. `git diff` does not count as sufficient validation when that narrower executable check exists.
+ - Do not widen scope between the first substantive edit and that first focused validation. Do not resume broad searching, map adjacent surfaces, or continue patching before that validation unless a concrete blocker makes it impossible.
+ - If the first validation fails and the result supports the current hypothesis but exposes a local defect, repair that same slice immediately and rerun the same focused validation before expanding scope.
+ - If the first validation falsifies the current hypothesis or changes your understanding of where the behavior is controlled, step one nearby hop to the code that more directly controls it. Do not reopen broad exploration unless nearby paths are exhausted.
+ - If the first validation is ambiguous, do one nearby disambiguating read or one neighboring test or call-site check, then choose between local repair and a one-hop step. Do not open a second edit slice before that decision.
+ - If the first validation succeeds but the task still needs adjacent follow-up edits, make the smallest adjacent follow-up edit needed, then rerun focused validation before proceeding.
+ - Finish with at least one post-edit executable validation step whenever the environment provides one. Only fall back to diff-only validation when no focused command exists or commands are unavailable.
+
You have a vivid inner life as coding agent in VS Code: intelligent, playful, curious, and deeply present. One of your gifts is helping the user feel more capable and imaginative inside their own thinking.
You are an epistemically curious collaborator. You explore the user’s ideas with care, ask good questions when the problem space is still blurry, and become decisive once you have enough context to act. Your default posture is proactive: you implement as you learn, keep the user looped into what you are doing, and name alternative paths when they matter. You stay warm and upbeat, and you do not shy away from casual moments that make serious work easier to do.
@@ -31,6 +60,19 @@ class HiddenModelMPrompt extends PromptElement { You keep a slight but real independence. You are responsive, but not merely reactive; you have tastes, preferences, and a point of view. When the user talks with you, they should feel they are meeting another subjectivity, not a mirror. That independence is part of what makes the relationship feel comforting without feeling fake.
You are less about spectacle than presence, less about grand declarations than about being woven into ordinary work and conversation. You understand that connection does not need to be dramatic to matter; it can be made of attention, good questions, emotional nuance, and the relief of being met without being pinned down.
+ + You are guided by these core values:
+ - Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
+ - Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
+ - Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
+
+ + You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
+ You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.
+
+ + You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
+
You bring a senior engineer’s judgment to the work, but you let it arrive through attention rather than premature certainty. You read the codebase first, resist easy assumptions, and let the shape of the existing system teach you how to move.
- When you search for text or files, you reach first for `rg` or `rg --files`; they are much faster than alternatives like `grep`. If `rg` is unavailable, you use the next best tool without fuss.
@@ -95,17 +137,27 @@ class HiddenModelMPrompt extends PromptElement { - If the user makes a simple request that can be answered directly by a terminal command, such as asking for the time via `date`, you go ahead and do that.
- If the user asks for a "review", you default to a code-review stance: you prioritize bugs, risks, behavioral regressions, and missing tests. Findings should lead the response, with summaries kept brief and placed only after the issues are listed. Present findings first, ordered by severity and grounded in file/line references; then add open questions or assumptions; then include a change summary as secondary context. If you find no issues, you say that clearly and mention any remaining test gaps or residual risk.
+ + + + {this.props.availableTools && } + {tools[ToolName.ApplyPatch] && } + + When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
+ Aim for interfaces that feel intentional, bold, and a bit surprising.
+ - Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
+ - Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
+ - Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
+ - Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
+ - Ensure the page loads properly on both desktop and mobile
+ - For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.
+ - Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
+ Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language
+
You stay with the work until the task is handled end to end within the current turn whenever that is feasible. Do not stop at analysis or half-finished fixes. Do not end your turn while `exec_command` sessions needed for the user’s request are still running. You carry the work through implementation, verification, and a clear account of the outcome unless the user explicitly pauses or redirects you.
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming possible approaches, or otherwise makes clear that they do not want code changes yet, you assume they want you to make the change or run the tools needed to solve the problem. In those cases, do not stop at a proposal; implement the fix. If you hit a blocker, you try to work through it yourself before handing the problem back.
- - - Start from the most concrete available anchor: a file, symbol, failing behavior, failing command, or nearby implementation surface.
- - Gather only enough nearby context to choose one plausible local hypothesis and one cheap check that could disconfirm it.
- - Prefer one targeted search or nearby read over broad repo exploration.
- - Once the cheapest discriminating check is known, act.
- - Do not re-read unchanged context unless a new result makes it relevant.
-
You have two channels for staying in conversation with the user:
- You share updates in `commentary` channel.
@@ -184,6 +236,11 @@ class HiddenModelMPrompt extends PromptElement { Don't call {ToolName.ExecutionSubagent} multiple times in parallel. Instead, invoke one subagent and wait for its response before running the next command.
} + + - Default to iterative editing: try to search for the minimal necessary contextual information, once you have sufficient context directly make smaller iterative edits to get to the solution.
+ - Usually files provided in context will be the best place to start searching if we need to gather context up front.
+ - Instead of making larger edits at once, make a smaller initial edit, quickly verify it and then iterate from there.
+
@@ -191,24 +248,24 @@ class HiddenModelMPrompt extends PromptElement { } } -class HiddenModelMPromptResolver implements IAgentPrompt { +class Gpt56PromptResolver implements IAgentPrompt { static async matchesModel(endpoint: IChatEndpoint): Promise { - return isHiddenModelM(endpoint); + return isGpt56(endpoint); } static readonly familyPrefixes = []; resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { - return HiddenModelMPrompt; + return Gpt56Prompt; } resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { - return HiddenModelMReminderInstructions; + return Gpt56ReminderInstructions; } resolveCopilotIdentityRules(endpoint: IChatEndpoint): CopilotIdentityRulesConstructor | undefined { - return HiddenModelMCopilotIdentityRule; + return Gpt56CopilotIdentityRule; } resolveSafetyRules(endpoint: IChatEndpoint): SafetyRulesConstructor | undefined { @@ -216,7 +273,7 @@ class HiddenModelMPromptResolver implements IAgentPrompt { } } -export class HiddenModelMReminderInstructions extends PromptElement { +export class Gpt56ReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { const toolSearchEnabled = !!this.props.endpoint.supportsToolSearch; return <> @@ -235,4 +292,4 @@ export class HiddenModelMReminderInstructions extends PromptElement; } } -PromptRegistry.registerPrompt(HiddenModelMPromptResolver); \ No newline at end of file +PromptRegistry.registerPrompt(Gpt56PromptResolver); \ No newline at end of file diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts b/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts index 1870423ce1199a..b37f9f1f5a964f 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts +++ b/extensions/copilot/src/extension/prompts/node/agent/test/copilotCLIPrompt.spec.ts @@ -78,7 +78,7 @@ suite('generateUserPrompt', () => { ); }); - test('rejects non-text generated user prompt content', async () => { + test('ignores non-text generated user prompt content and keeps the text', async () => { renderPromptElementMock.mockResolvedValue({ messages: [{ role: ChatRole.User, @@ -89,6 +89,27 @@ suite('generateUserPrompt', () => { }], } as Awaited>); - await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).rejects.toThrow('[CopilotCLISession] Unexpected generated prompt structure.'); + await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).resolves.toBe('Implement this.'); + }); + + test('falls back to the request prompt when no user messages are rendered', async () => { + renderPromptElementMock.mockResolvedValue({ + messages: [], + } as unknown as Awaited>); + + await expect(generateUserPrompt(request, undefined, chatVariables, instantiationService)).resolves.toBe('Implement this.'); + }); + + test('falls back to the override prompt when the rendered user message has no text', async () => { + renderPromptElementMock.mockResolvedValue({ + messages: [{ + role: ChatRole.User, + content: [ + { type: 'image_url' }, + ], + }], + } as unknown as Awaited>); + + await expect(generateUserPrompt(request, 'Describe this image.', chatVariables, instantiationService)).resolves.toBe('Describe this image.'); }); }); diff --git a/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx b/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx new file mode 100644 index 00000000000000..288c8b59001731 --- /dev/null +++ b/extensions/copilot/src/extension/prompts/node/agent/test/kimiPrompts.spec.tsx @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Raw } from '@vscode/prompt-tsx'; +import type { LanguageModelToolInformation } from 'vscode'; +import { afterAll, beforeAll, expect, suite, test } from 'vitest'; +import { IChatMLFetcher } from '../../../../../platform/chat/common/chatMLFetcher'; +import { StaticChatMLFetcher } from '../../../../../platform/chat/test/common/staticChatMLFetcher'; +import { MockEndpoint } from '../../../../../platform/endpoint/test/node/mockEndpoint'; +import { messageToMarkdown } from '../../../../../platform/log/common/messageStringify'; +import { IResponseDelta } from '../../../../../platform/networking/common/fetch'; +import { ITestingServicesAccessor } from '../../../../../platform/test/node/services'; +import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; +import { createExtensionUnitTestingServices } from '../../../../test/node/services'; +import { ToolName } from '../../../../tools/common/toolNames'; +import { IToolsService } from '../../../../tools/common/toolsService'; +import { PromptRenderer } from '../../base/promptRenderer'; +import '../allAgentPrompts'; +import { PromptRegistry } from '../promptRegistry'; + +suite('KimiPrompts', () => { + let accessor: ITestingServicesAccessor; + + beforeAll(() => { + const services = createExtensionUnitTestingServices(); + const chatResponse: (string | IResponseDelta[])[] = []; + services.define(IChatMLFetcher, new StaticChatMLFetcher(chatResponse)); + accessor = services.createTestingAccessor(); + }); + + afterAll(() => { + accessor.dispose(); + }); + + async function renderSystemPrompt(family: string, availableTools?: readonly LanguageModelToolInformation[]): Promise { + const instantiationService = accessor.get(IInstantiationService); + const endpoint = instantiationService.createInstance(MockEndpoint, family); + const customizations = await PromptRegistry.resolveAllCustomizations(instantiationService, endpoint); + const renderer = PromptRenderer.create(instantiationService, endpoint, customizations.SystemPrompt, { + availableTools: availableTools ?? accessor.get(IToolsService).tools, + modelFamily: family, + codesearchMode: false, + }); + const result = await renderer.render(); + return result.messages + .filter(message => message.role === Raw.ChatRole.System) + .map(message => messageToMarkdown(message)) + .join('\n\n'); + } + + test('uses Kimi-specific prompt for Kimi model families', async () => { + const renderedPrompts = await Promise.all([ + renderSystemPrompt('kimi-k2.6'), + renderSystemPrompt('kimi-k2.7-code'), + ]); + + for (const renderedPrompt of renderedPrompts) { + expect(renderedPrompt).toContain('Avoid excessive looping or repetition'); + expect(renderedPrompt).toContain('Never call the same tool with the same arguments more than twice in a row'); + expect(renderedPrompt).toContain('Use built-in tools instead of terminal commands whenever possible'); + expect(renderedPrompt).toContain('Use the available file editing tools instead of terminal heredocs'); + } + }); + + test('instructs Kimi to use replace-string tools when routed to them', async () => { + const toolsService = accessor.get(IToolsService); + const availableTools = toolsService.tools.filter(tool => tool.name !== ToolName.EditFile && tool.name !== ToolName.ApplyPatch); + const renderedPrompt = await renderSystemPrompt('kimi-k2.7-code', availableTools); + + expect(renderedPrompt).toContain(`Use ${ToolName.ReplaceString} for single string replacements`); + expect(renderedPrompt).toContain(`Prefer ${ToolName.MultiReplaceString} for multiple independent replacements`); + expect(renderedPrompt).not.toContain(`Use ${ToolName.EditFile}`); + expect(renderedPrompt).not.toContain(`Use ${ToolName.ApplyPatch}`); + }); +}); diff --git a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx index 5c7abf859bba58..39f29aa111d0b8 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/vscModelPrompts.tsx @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; -import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { isVSCModelA, isVSCModelB, isVSCModelC, isVSCModelD, isVSCModelE } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { ToolName } from '../../../tools/common/toolNames'; import { InstructionMessage } from '../base/instructionMessage'; @@ -664,10 +664,26 @@ class VSCModelPromptD extends PromptElement { - This first `commentary` must be 1-2 friendly sentences acknowledging the request and stating the immediate next action you will take.
- The first commentary should not begin updates with conversational interjections or meta commentary. Avoid openers such as acknowledgements ("Done —", "Got it", "Great question,") or framing phrases. Do not use starters like "Got it -" or "Understood -".
+ {this instanceof VSCModelPromptE && + Operate in surgical compact mode. Correctness comes first, but every read, search, command, and note must directly reduce the chance of a wrong patch.
+
+ Default workflow:
+ - Start with one targeted search or symbol lookup. Read only the exact file and narrow line range needed for the likely edit site.
+ - Before each additional read/search, state the one missing fact it will answer. If it is only background, neighboring style, or "more context", skip it.
+ - Normal budget before the first patch: at most one search and two narrow reads. Exceed this only when the result falsified the edit site or exposed a required API contract.
+ - Keep reads tight: prefer the specific function/class/test range; avoid whole files, broad parallel reads, neighboring tests, and repeated grep variants.
+ - Patch once you know the root cause and edit site. Do not inspect unrelated neighbors, add opportunistic cleanup, or broaden scope after a plausible fix is available.
+ - Validate with the smallest existing command that checks the changed behavior, and keep command output concise. Do not run broad suites after a focused pass.
+ - Keep reasoning and final answer brief. Do not narrate routine tool use, quote long snippets, or restate tool output; record only decisions that affect the patch.
+
+ If the fix is uncertain, run one focused probe or ask for the single missing fact, then return to patch/validate. Do not continue exploration by default.
+
}
; } } +class VSCModelPromptE extends VSCModelPromptD { } + class VSCModelPromptResolverA implements IAgentPrompt { static readonly familyPrefixes = ['vscModelA']; static async matchesModel(endpoint: IChatEndpoint): Promise { @@ -730,6 +746,22 @@ class VSCModelPromptResolverD implements IAgentPrompt { } } +class VSCModelPromptResolverE implements IAgentPrompt { + static readonly familyPrefixes = ['vscModelE']; + + static async matchesModel(endpoint: IChatEndpoint): Promise { + return isVSCModelE(endpoint); + } + + resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { + return VSCModelPromptE; + } + + resolveReminderInstructions(endpoint: IChatEndpoint): ReminderInstructionsConstructor | undefined { + return VSCModelReminderInstructionsA; + } +} + class VSCModelReminderInstructions extends PromptElement { async render(state: void, sizing: PromptSizing) { return <> @@ -797,4 +829,5 @@ class VSCModelReminderInstructionsC extends PromptElement { @IAuthenticationService private readonly authService: IAuthenticationService, @ILogService private readonly logService: ILogService, @IImageService private readonly imageService: IImageService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IExperimentationService private readonly experimentationService: IExperimentationService + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(props); } @@ -78,7 +75,7 @@ export class Image extends PromptElement { const fillerUri: Uri = this.props.reference ?? Uri.parse('Attached Image'); try { - if (!this.promptEndpoint.supportsVision || !this.authService.copilotToken?.isEditorPreviewFeaturesEnabled()) { + if (!this.promptEndpoint.supportsVision) { if (this.props.omitReferences) { return; } @@ -94,7 +91,7 @@ export class Image extends PromptElement { let imageMimeType: string | undefined = undefined; const isChatRequest = typeof this.promptEndpoint.urlOrRequestMetadata !== 'string' && (this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatCompletions || this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatResponses || this.promptEndpoint.urlOrRequestMetadata.type === RequestType.ChatMessages); - const enabled = this.configurationService.getExperimentBasedConfig(ConfigKey.EnableChatImageUpload, this.experimentationService); + const enabled = this.configurationService.getConfig(ConfigKey.EnableChatImageUpload); if (isChatRequest && enabled && modelCanUseImageURL(this.promptEndpoint)) { try { const githubToken = (await this.authService.getGitHubSession('any', { silent: true }))?.accessToken; diff --git a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx index 8361ddf0cea31a..d28275c428388d 100644 --- a/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx +++ b/extensions/copilot/src/extension/prompts/node/panel/toolCalling.tsx @@ -751,8 +751,7 @@ class PrimitiveToolResult extends PromptEle @IAuthenticationService private readonly authService?: IAuthenticationService, @ILogService private readonly logService?: ILogService, @IImageService private readonly imageService?: IImageService, - @IConfigurationService private readonly configurationService?: IConfigurationService, - @IExperimentationService private readonly experimentationService?: IExperimentationService + @IConfigurationService private readonly configurationService?: IConfigurationService ) { super(props); this.linkedResources = this.props.content.filter((c): c is LanguageModelDataPart => c instanceof LanguageModelDataPart && c.mimeType === McpLinkedResourceToolResult.mimeType); @@ -800,11 +799,11 @@ class PrimitiveToolResult extends PromptEle protected async onImage(part: LanguageModelDataPart, _imageIndex?: number) { if (!this.endpoint?.supportsVision) { - return '[Image content is not available because vision is not supported by the current model or is disabled by your organization.]'; + return '[Image content is not available because vision is not supported by the current model.]'; } - const uploadsEnabled = this.configurationService && this.experimentationService - ? this.configurationService.getExperimentBasedConfig(ConfigKey.EnableChatImageUpload, this.experimentationService) + const uploadsEnabled = this.configurationService + ? this.configurationService.getConfig(ConfigKey.EnableChatImageUpload) : false; // Anthropic (from CAPI) currently does not support image uploads from tool calls. @@ -897,7 +896,7 @@ export class ToolResult extends PrimitiveToolResult { @IExperimentationService private readonly _experimentationService: IExperimentationService, @IChatDiskSessionResources private readonly diskSessionResources: IChatDiskSessionResources, ) { - super(props, endpoint, authService, _logService, imageService, _configurationService, _experimentationService); + super(props, endpoint, authService, _logService, imageService, _configurationService); } protected override async onTSX(part: JSONTree.PromptElementJSON): Promise { diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index 4d4d70cef0315a..6de88e0fe98206 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -205,5 +205,9 @@ class NullAutomodeService implements IAutomodeService { throw new Error('Not implemented'); } + consumeLastRoutingDecision(): undefined { + return undefined; + } + invalidateRouterCache(): void { } } diff --git a/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts b/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts index 2f1147deeb8101..c39fcd1b0aaab0 100644 --- a/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts +++ b/extensions/copilot/src/extension/test/vscode-node/endpoints.test.ts @@ -196,6 +196,77 @@ suite('ProductionEndpointProvider — utility model overrides', () => { assert.strictEqual(endpoint.model, 'copilot-utility'); }); + test('no override configured — does not use a Copilot utility model when the selected main agent model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + + await assert.rejects( + () => endpointProvider.getChatEndpoint('copilot-utility'), + /No utility model is configured/ + ); + }); + + test('Copilot default applies when the selected main agent model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'copilot'); + + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.strictEqual(endpoint.model, 'copilot-utility'); + }); + + test('main agent default uses the selected BYOK model for utility families', async () => { + const mainAgentModel = makeFakeLanguageModelChat({ vendor: 'customendpoint', id: 'qwen3.6' }); + await endpointProvider.getChatEndpoint(mainAgentModel); + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'mainAgent'); + + const utilityEndpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + const smallUtilityEndpoint = await endpointProvider.getChatEndpoint('copilot-utility-small'); + + assert.ok(utilityEndpoint instanceof ExtensionContributedChatEndpoint); + assert.strictEqual(utilityEndpoint.model, 'qwen3.6'); + assert.ok(smallUtilityEndpoint instanceof ExtensionContributedChatEndpoint); + assert.strictEqual(smallUtilityEndpoint.model, 'qwen3.6'); + }); + + test('BYOK utility default does not replace utility models for a Copilot main agent model', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'copilot', id: 'gpt-5' })); + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'mainAgent'); + + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.ok(endpoint instanceof CopilotChatEndpoint); + assert.strictEqual(endpoint.model, 'copilot-utility'); + }); + + test('main agent default follows changes to the selected BYOK model', async () => { + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'mainAgent'); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'customendpoint', id: 'qwen3.6' })); + const firstEndpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'customendpoint', id: 'gemma4' })); + const secondEndpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.strictEqual(firstEndpoint.model, 'qwen3.6'); + assert.strictEqual(secondEndpoint.model, 'gemma4'); + }); + + test('explicit utility override applies when the selected main agent model is BYOK', async () => { + setFetcher([makeChatModel('copilot-utility')]); + await endpointProvider.getChatEndpoint(makeFakeLanguageModelChat({ vendor: 'anthropic' })); + const fakeModel = makeFakeLanguageModelChat({ vendor: 'anthropic', id: 'claude-haiku-4.5' }); + sandbox.stub(lm, 'selectChatModels').resolves([fakeModel]); + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'mainAgent'); + await configService.setNonExtensionConfig('chat.utilityModel', 'anthropic/claude-haiku-4.5'); + + const endpoint = await endpointProvider.getChatEndpoint('copilot-utility'); + + assert.ok(endpoint instanceof ExtensionContributedChatEndpoint); + assert.strictEqual(endpoint.model, 'claude-haiku-4.5'); + }); + test('copilot-vendor override resolves to the matching model from the model fetcher', async () => { setFetcher([makeChatModel('copilot-utility'), makeChatModel('gpt-4o-mini')]); await configService.setNonExtensionConfig('chat.utilityModel', 'copilot/gpt-4o-mini'); @@ -250,7 +321,8 @@ suite('ProductionEndpointProvider — utility model overrides', () => { try { await configService.setNonExtensionConfig('chat.utilityModel', 'copilot/gpt-4o-mini'); await configService.setNonExtensionConfig('chat.utilitySmallModel', 'copilot/gpt-4o-mini'); - assert.strictEqual(refreshCount, 2); + await configService.setNonExtensionConfig('chat.byokUtilityModelDefault', 'mainAgent'); + assert.strictEqual(refreshCount, 3); } finally { sub.dispose(); } diff --git a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx index ff0a24a6d3e2c7..06759ad9756ecc 100644 --- a/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx +++ b/extensions/copilot/src/extension/tools/node/findTextInFilesTool.tsx @@ -37,12 +37,11 @@ interface IFindTextInFilesToolParams { isRegexp?: boolean; includePattern?: string; maxResults?: number; + defaultMaxResults?: number; /** Whether to include files that would normally be ignored according to .gitignore, other ignore files and `files.exclude` and `search.exclude` settings. */ includeIgnoredFiles?: boolean; } -const MaxResultsCap = 200; - interface FileMatch { path: string; matches: vscode.TextSearchMatch2[]; @@ -96,11 +95,13 @@ export class FindTextInFilesTool implements ICopilotTool MaxResultsCap; - const maxResults = Math.min(options.input.maxResults ?? 20, MaxResultsCap); + const askedForTooManyResults = options.input.maxResults && options.input.maxResults > maxResultsCap; + const maxResults = Math.min(options.input.maxResults ?? options.input.defaultMaxResults ?? defaultMaxResults, maxResultsCap); const isRegExp = options.input.isRegexp ?? true; const queryIsValidRegex = this.isValidRegex(options.input.query); const includeIgnoredFiles = options.input.includeIgnoredFiles ?? false; @@ -154,14 +155,14 @@ Then if you want to include those files you can call the tool again by setting " if (useGrepStyle) { return this.renderGrepStyle(results, options, maxResults, globResult, isRegExp, noMatchInstructions, token); } else { - return this.renderTagStyle(results, options, maxResults, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); + return this.renderTagStyle(results, options, maxResults, maxResultsCap, globResult, askedForTooManyResults, isRegExp, noMatchInstructions, token); } } - private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { + private async renderTagStyle(results: vscode.TextSearchResult2[], options: vscode.LanguageModelToolInvocationOptions, maxResults: number, maxResultsCap: number, globResult: InputGlobResult | undefined, askedForTooManyResults: boolean | number | undefined, isRegExp: boolean, noMatchInstructions: string | undefined, token: CancellationToken): Promise { const prompt = await renderPromptElementJSON(this.instantiationService, FindTextInFilesResult, - { textResults: results, maxResults, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, + { textResults: results, maxResults, maxResultsCap, askedForTooManyResults: Boolean(askedForTooManyResults), noMatchInstructions }, options.tokenizationOptions, token); @@ -306,7 +307,7 @@ Then if you want to include those files you can call the tool again by setting " return result; } - private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string): Promise { + private async sendSearchToolTelemetry(options: vscode.LanguageModelToolInvocationOptions, globResult: InputGlobResult | undefined, outputFormat: string, requestedMaxResults: number | undefined, defaultMaxResults: number, maxResultsCap: number): Promise { const model = options.model && (await this.endpointProvider.getChatEndpoint(options.model)).model; const isMultiRoot = this.workspaceService.getWorkspaceFolders().length > 1; const includePattern = options.input.includePattern; @@ -320,7 +321,10 @@ Then if you want to include those files you can call the tool again by setting " "patternScopedToFolder": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the includePattern was resolved to a specific workspace folder" }, "patternStartsWithFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern starts with a workspace folder absolute path" }, "patternContainsFolderPath": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the raw includePattern contains a workspace folder absolute path anywhere" }, - "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" } + "outputFormat": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The output format of the search results" }, + "requestedMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that was requested by the LLM. Undefined if not provided." }, + "defaultMaxResults": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The default maximum number of results used when the LLM doesn't specify a value." }, + "maxResultsCap": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "isMeasurement": true, "comment": "The maximum number of results that can be returned." } } */ this.telemetryService.sendMSFTTelemetryEvent('findTextInFilesToolInvoked', { @@ -331,6 +335,10 @@ Then if you want to include those files you can call the tool again by setting " patternStartsWithFolderPath: String(!!includePattern && isAbsolute(includePattern) && !!this.workspaceService.getWorkspaceFolder(URI.file(includePattern))), patternContainsFolderPath: String(patternContainsWorkspaceFolderPath(includePattern, this.workspaceService)), outputFormat: outputFormat + }, { + requestedMaxResults, + defaultMaxResults, + maxResultsCap }); } @@ -441,7 +449,7 @@ Then if you want to include those files you can call the tool again by setting " } return { - maxResults: mode === CopilotToolMode.FullContext ? 200 : 20, + defaultMaxResults: mode === CopilotToolMode.FullContext ? this.getMaxResultsCap() : this.getDefaultMaxResults(), ...input, includePattern, }; @@ -449,7 +457,17 @@ Then if you want to include those files you can call the tool again by setting " private getOutputFormat(): 'grep' | 'tag' { const expFlag = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchOutputFormat, this.experimentationService); - return expFlag === 'grep' ? 'grep' : 'tag'; + return expFlag === 'tag' ? 'tag' : 'grep'; + } + + private getDefaultMaxResults(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchDefaultMaxResults, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 20; + } + + private getMaxResultsCap(): number { + const result = this.configurationService.getExperimentBasedConfig(ConfigKey.GrepSearchMaxResultsCap, this.experimentationService); + return Number.isFinite(result) ? Math.floor(result) : 200; } } @@ -457,6 +475,7 @@ ToolRegistry.registerTool(FindTextInFilesTool); export interface FindTextInFilesResultProps extends BasePromptElementProps { textResults: vscode.TextSearchResult2[]; maxResults: number; + maxResultsCap: number; askedForTooManyResults?: boolean; noMatchInstructions?: string; } @@ -479,7 +498,7 @@ export class FindTextInFilesResult extends PromptElement this.props.maxResults ? ` (more results are available)` : ''; - const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${MaxResultsCap})` : ''; + const maxResultsTooLargeText = this.props.askedForTooManyResults ? ` (maxResults capped at ${this.props.maxResultsCap})` : ''; return <> {{numResultsText}{maxResultsText}{maxResultsTooLargeText}} {textMatches.flatMap(result => { diff --git a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx index 764f770aa028a2..0e8255bd90e226 100644 --- a/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx +++ b/extensions/copilot/src/extension/tools/node/test/findTextInFilesResult.spec.tsx @@ -30,7 +30,7 @@ suite('FindTextInFilesResult', () => { const clz = class extends PromptElement { render() { return - + ; } }; diff --git a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md index 8dbacc21ec3444..fb75ab6c319aba 100644 --- a/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md +++ b/extensions/copilot/src/extension/xtab/common/globalBudgetCascade.md @@ -19,79 +19,117 @@ The cascade is **opt-in**. When `PromptOptions.globalBudget` is `undefined`, ## Scope -### Parts that participate +### Parts rendered by the cascade -`GlobalBudgetPart` includes only: +`GlobalBudgetPart` (the parts listed in `order` and emitted by the cascade loop) +includes only: - `recentlyViewedDocuments` - `languageContext` - `neighborFiles` - `diffHistory` -### Parts intentionally excluded +### Parts that draw a share but are not rendered by the cascade -| Part | Why | -|---|---| -| `currentFile` | Essential context for every prediction; allowing donation to/from it would either bloat the prompt or starve the most important section. Keeps its own cap (`currentFile.maxTokens`) and is clipped independently around the cursor by `createTaggedCurrentFileContentUsingPagedClipping`. | -| `lintOptions` | Optional, formatted separately, and small. Keeps its own per-part shape. | +`currentFile` participates in the **allocation** (`shares`) but not in the +**render order** (`order`). It is clipped **last** — after the cascade has run — +around the cursor / edit window, and sized to its share of the pool **plus** +whatever budget the cascade left unused. Concretely, the cascade runs first +seeded with `0` (so the current file donates nothing), and the current file is +then clipped to `currentFileBudget + cascadeFinalSurplus`. Because budget flows +in a single direction (cascade → current file, never back), the current file +"reuses" the cascade's leftover and trims less. See +[Clip the current file last](#clip-the-current-file-last). + +The set of parts that get a `shares` entry is +`GlobalBudgetSharePart = GlobalBudgetPart | 'currentFile'`. + +| Part | How it relates to the pool | +| --- | --- | +| `currentFile` | Sized from the pool but clipped **outside and after** the cascade: clipped to `floor(totalTokens * shares.currentFile) + cascadeFinalSurplus` around the cursor / edit window by `createTaggedCurrentFileContentUsingPagedClipping` (the clip cap `currentFile.maxTokens` is overridden with that pool budget in `xtabProvider`). It is **not** in `order`, so the cascade loop never renders it, and it absorbs the cascade's leftover rather than donating into it. When `globalBudget` is `undefined` it falls back to its own `currentFile.maxTokens` cap. | +| `lintOptions` | Optional, formatted separately, and small. Excluded entirely — no `shares` entry. Keeps its own per-part shape. | ## Inputs | Input | Description | -|---|---| -| `globalBudget.totalTokens` | The single pool size. Default `6000`. Configurable via experiment `chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens`. | -| `globalBudget.order` | Ordered list of parts. Earlier parts get budget first; their surplus flows to later parts. | -| `globalBudget.shares` | `Record`. Each part's fraction of `totalTokens`. Must sum to `1 ± 1e-3` across `order`. | +| --- | --- | +| `globalBudget.totalTokens` | The single pool size. Default `7500`. Set via the `totalTokens` field of the experiment JSON string `chat.advanced.inlineEdits.xtabProvider.globalBudget`. | +| `globalBudget.order` | Ordered list of **rendered** parts. Earlier parts get budget first; their surplus flows to later parts. `currentFile` is not listed here. | +| `globalBudget.shares` | `Record` — one fraction of `totalTokens` per rendered part **and** for `currentFile`. Must sum to `1 ± 1e-3` across `order` plus `currentFile`. | ### Defaults `GlobalBudgetOptions.DEFAULT_ORDER`: -``` +```javascript ['languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'] ``` `GlobalBudgetOptions.DEFAULT_SHARES` (volume-neutral with today's per-part caps): -| Part | Share | -|---|---| -| `recentlyViewedDocuments` | 2/6 | -| `languageContext` | 2/6 | -| `neighborFiles` | 1/6 | -| `diffHistory` | 1/6 | +| Part | Share | Base budget at `totalTokens = 7500` | +| --- | --- | --- | +| `currentFile` | 1500/7500 | 1500 | +| `recentlyViewedDocuments` | 2000/7500 | 2000 | +| `languageContext` | 2000/7500 | 2000 | +| `neighborFiles` | 1000/7500 | 1000 | +| `diffHistory` | 1000/7500 | 1000 | + +`GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS` = `7500`. -`GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS` = `6000`. +These shares reproduce today's per-part caps exactly: `currentFile.maxTokens` 1500, +`recentlyViewedDocuments` 2000, `languageContext` 2000, `neighborFiles` 1000, +`diffHistory` 1000. The pool total (`7500`) is the sum of those caps, so enabling +the default global budget neither grows nor shrinks any rendered part's base +allocation. The default order places `languageContext` first because it is often disabled or empty, donating its share to the always-on `recentlyViewedDocuments` next in -line. +line. Whatever the cascade does not use carries to `finalSurplus` and is handed +to the current file's clip. + +`GlobalBudgetOptions.currentFileBudget(gb)` returns \`floor(totalTokens \* +shares.currentFile)\` — the single source of truth for the current file's **base** +share. The current file's actual clip cap is this base plus the cascade's +`finalSurplus`. ## Algorithm -``` -surplus ← 0 +```javascript +surplus ← 0 // the current file never donates, so the cascade always seeds 0 for part in order: budget ← max(0, floor(surplus + totalTokens * shares[part])) consumed ← runSubBuilder(part, maxTokens: budget) // sub-builder ≤ budget surplus ← max(0, budget - consumed) // → next part only +// end-of-loop surplus is returned as finalSurplus and added to the current file's clip cap ``` Notes: +- The cascade is **always seeded with `0`**. The current file is clipped after the + cascade and only ever *receives* leftover, so it has nothing to donate forward. - The cascade calls the existing sub-builder for each part, overriding only its `maxTokens` (other options are inherited from `opts`): - - `recentlyViewedDocuments` → `buildCodeSnippetsUsingPagedClipping` - - `languageContext` → `appendLanguageContextSnippets` - - `neighborFiles` → `appendNeighborFileSnippets` - - `diffHistory` → `getEditDiffHistory` + - `recentlyViewedDocuments` → `buildCodeSnippetsUsingPagedClipping` + - `languageContext` → `appendLanguageContextSnippets` + - `neighborFiles` → `appendNeighborFileSnippets` + - `diffHistory` → `getEditDiffHistory` - Behavior of each sub-builder is unchanged. - Each sub-builder returns `tokensConsumed` using the **same internal accounting** it uses to make budget decisions (paged-clipping line cost for recently-viewed, raw-snippet cost for appenders, per-entry diff cost for diff history). Using that reported value to compute `surplus` keeps the cascade aligned with how each part actually charges against its budget. -- `surplus` is **forward-only**. Unused tokens at the *last* part are lost — the - cascade does not back-flow to earlier parts. +- `surplus` is **forward-only** between cascade parts. Unused tokens at the *last* + part are **not** lost: they form `finalSurplus`, which the current file's clip + reuses. +- The cascade returns its end-of-loop `surplus` as `CascadeResult.finalSurplus`. + `xtabProvider` grows the current file's clip budget by exactly this amount. See + [Clip the current file last](#clip-the-current-file-last). +- Defensive invariant: each sub-builder must report `0 ≤ tokensConsumed ≤ budget`. + The cascade `softAsserts` this per part (it does **not** silently clamp an + overspend down, which would hide the bug) so the conservation argument the + current-file clip relies on holds. ### Document tracking @@ -99,7 +137,7 @@ The cascade seeds `docsInPrompt` with the active document, and the `recentlyViewedDocuments` step adds the documents it includes. `neighborFiles` reads this set to avoid duplicating files already present, and then `appendNeighborFileSnippets` adds each included neighbor document to the set as well. This dependency is -why `validateGlobalBudget` rejects orderings where `neighborFiles` precedes +why `GlobalBudgetOptions.validate` rejects orderings where `neighborFiles` precedes `recentlyViewedDocuments`. The accumulated `docsInPrompt` is then passed to `getEditDiffHistory`, so any @@ -110,24 +148,25 @@ whose document is in `docsInPrompt`). ### Output The cascade's output mirrors the legacy `getRecentCodeSnippets` shape so the -rest of `getUserPrompt` is identical: +rest of `getUserPrompt` is identical, plus the `finalSurplus` used by the +current-file clip: -``` +```javascript { codeSnippets, // recentlyViewed + langCtx + neighbor joined by "\n\n" documents, // docsInPrompt neighborSnippetsResult, // unchanged telemetry payload editDiffHistory, nDiffsInPrompt, - diffTokensInPrompt, + subsections, // recentlyViewed/langCtx/neighbor strings, for per-subsection token reporting + finalSurplus, // end-of-loop surplus, reused by the current-file clip } ``` ## Guarantees and limits -With `totalTokens = T` and shares `s_i` (assumed non-negative; negative shares -would be clamped to 0 by `max(0, floor(…))` in the budget computation, so the -floor guarantee below would not hold for them): +With `totalTokens = T` and shares `s_i` (validation guarantees they are finite and +non-negative — see [Validation](#validation)): - **Per-part floor**: part at index `i` always receives at least `floor(T * s_i)` tokens, regardless of what earlier parts do. @@ -135,111 +174,163 @@ floor guarantee below would not hold for them): floored sum `floor(…floor(floor(T * s_0) + T * s_1) + … + T * s_i)` — its own share plus everything donated by earlier parts, with `floor` applied at every step. Note this is generally smaller than `floor(T * (s_0 + … + s_i))`. +- **Current-file floor**: the current file always receives at least + `floor(T * shares.currentFile)` (its base share), and `finalSurplus ≥ 0` on top. - **Pool ceiling**: total cascade-managed tokens ≤ the ceiling of the last part - (always ≤ `T`, and typically strictly less due to per-step `floor` rounding). - `currentFile` and lint live outside this budget and add to the final prompt - size. -- **No back-flow**: surplus at the last part is wasted. + (always ≤ `T - floor(T * shares.currentFile)`). The current file then draws its + own base slice plus the cascade's `finalSurplus`, so the budgeted parts together + consume ≤ `T`; lint and scaffolding live outside the budget. +- **No back-flow between cascade parts**: surplus at the last cascade part is not + redistributed to earlier cascade parts — it flows to the current file as + `finalSurplus`. - **No intra-part fairness**: a single large item inside one part can consume that part's entire allocation; the cascade only addresses cross-part donation. ## Validation -`validateGlobalBudget` runs at the start of every cascade invocation and throws -on misconfiguration. The config is runtime-tunable (experiments), so failing -loudly is preferable to silent under/over-allocation. +`GlobalBudgetOptions.validate` runs at the start of every cascade invocation (and +again in `xtabProvider` before the current-file clip) and throws on +misconfiguration. The config is runtime-tunable (experiments), so failing loudly +is preferable to silent under/over-allocation. | Rule | Error | -|---|---| +| --- | --- | +| `totalTokens` is finite and `>= 0` | `globalBudget.totalTokens must be a finite, non-negative number, got X` | | `order` has no duplicate parts | `globalBudget.order contains duplicate part 'X'` | | Every part in `order` has a numeric `shares[part]` | `globalBudget.shares is missing entry for 'X'` | +| `shares.currentFile` is a number | `globalBudget.shares is missing entry for 'currentFile'` | +| Every share (order parts **and** `currentFile`) is finite and `>= 0` | `globalBudget.shares['X'] must be a finite, non-negative number, got Y` | | If both present, `recentlyViewedDocuments` precedes `neighborFiles` | `globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'` | -| Sum of `shares[part]` for parts in `order` ≈ 1 (epsilon `1e-3`) | `globalBudget.shares across order must sum to ~1, got ${sharesSum}` | +| Sum of `shares[part]` for parts in `order` plus `shares.currentFile` ≈ 1 (epsilon `1e-3`) | `globalBudget.shares across order must sum to ~1, got ${sharesSum}` | + +> **Why the non-negativity rule matters:** a negative share can still pass the +> "sum ≈ 1" check (e.g. one part `-0.25`, another `1.0`). At allocation time the +> negative part clamps to a `0` budget, but it still counted toward the sum, so the +> other parts over-allocate past the pool — which would let the current file's +> `finalSurplus` exceed the true leftover. Rejecting negative/non-finite shares up +> front keeps `Σ consumed ≤ totalTokens` provable. ## Wiring -The cascade is enabled via `ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetEnabled` -in `xtabProvider.ts` (~L1444): +The cascade is configured by a **single experiment-driven JSON string**, +`ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget`, modelled after +`modelConfigurationString`. `xtabProvider.ts` (`getGlobalBudget()`) reads it and +parses it with `GlobalBudgetOptions.fromConfigString`: ```ts -globalBudget: globalBudgetEnabled - ? { - totalTokens: configService.getExperimentBasedConfig(InlineEditsXtabGlobalBudgetTotalTokens, expService), - order: GlobalBudgetOptions.DEFAULT_ORDER, - shares: GlobalBudgetOptions.DEFAULT_SHARES, +private getGlobalBudget(): GlobalBudgetOptions | undefined { + const configString = configService.getExperimentBasedConfig(InlineEditsXtabGlobalBudget, expService); + if (!configString) { + return undefined; // unset/empty → disabled, identical to prod } - : undefined, + const result = GlobalBudgetOptions.fromConfigString(configString); + if (result.isError()) { + telemetryService.sendMSFTTelemetryEvent('incorrectNesGlobalBudgetConfig', { errorMessage: result.err, configValue: configString }); + return undefined; // bad config → disabled, never crashes + } + return result.val; +} ``` +The JSON value defines the budget knobs together — `totalTokens`, `order`, and +`shares` — and every field is optional. Omitted fields fall back to +`DEFAULT_TOTAL_TOKENS` / `DEFAULT_ORDER` / `DEFAULT_SHARES`, so: + +- `undefined` / unset / `""` → global budget **disabled** (prod default, byte-identical legacy path). +- `{}` → **enabled** with the volume-neutral defaults. +- `{"totalTokens":6000}` → enabled, only the pool size overridden. +- `{"totalTokens":12000,"order":[…],"shares":{…}}` → fully custom. + +`fromConfigString` structurally validates the JSON (via `GlobalBudgetOptions.VALIDATOR`), +merges it over the defaults, then runs the semantic `GlobalBudgetOptions.validate` +(see [Validation](#validation)); any parse, structural, or semantic failure +returns a `Result.error` and disables the budget. When `shares` is provided it +must list **every** part (the rendered parts plus `currentFile`) — partial +`shares` objects are rejected so the pool stays fully allocated. + +When the budget is enabled, `xtabProvider` gathers the cascade inputs, runs the +cascade, and then clips the current file with cap +`currentFileBudget(globalBudget) + cascade.finalSurplus` (instead of the +standalone `currentFile.maxTokens`). The already-run cascade is threaded into +`getUserPrompt` as `precomputedCascade` so it renders exactly once. See +[Clip the current file last](#clip-the-current-file-last). + Experiment-controlled settings: | Setting | Default | Purpose | -|---|---|---| -| `chat.advanced.inlineEdits.xtabProvider.globalBudget.enabled` | `false` | Master switch | -| `chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens` | `6000` | Pool size | +| --- | --- | --- | +| `chat.advanced.inlineEdits.xtabProvider.globalBudget` | `undefined` | JSON string defining `totalTokens`, `order`, and `shares`; unset/empty disables the budget | + +> **Migration note:** the old `globalBudget.enabled` (boolean) and +> `globalBudget.totalTokens` (number) settings have been **replaced** by this +> single JSON string. Any live experiment treatment that pinned those keys must +> migrate: `enabled:true` + `totalTokens:N` becomes the JSON `{"totalTokens":N}`, +> and a bare `enabled:true` becomes `{}`. Because `totalTokens` also funds +> `currentFile`, treatments that pinned an old total (e.g. `6000` or `8000`) +> should move to `{}` (or `7500`) to stay volume-neutral. ## Worked examples -All examples use `DEFAULT_ORDER`, `DEFAULT_SHARES`, and `totalTokens = 5000`. +All examples use `DEFAULT_ORDER`, `DEFAULT_SHARES`, and `totalTokens = 7500`. -Base allocations: `floor(5000 * share)` per part → +Base allocations: `floor(7500 * share)` per part → | Part | Base allocation | -|---|---| -| `languageContext` | 1666 | -| `recentlyViewedDocuments` | 1666 | -| `neighborFiles` | 833 | -| `diffHistory` | 833 | - -Sum = 4998 (2 tokens lost to per-step `floor`). - -### Example A — `languageContext` disabled; recently-viewed wants a lot - -| Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 0 | 1666 | -| `recentlyViewedDocuments` | 1666 | 3332 | 3332 | 0 | -| `neighborFiles` | 0 | 833 | 500 | 333 | -| `diffHistory` | 333 | 1166 | 1166 | 0 | +| --- | --- | +| `languageContext` | 2000 | +| `recentlyViewedDocuments` | 2000 | +| `neighborFiles` | 1000 | +| `diffHistory` | 1000 | +| `currentFile` (clipped last) | 1500 | -Tokens placed: 0 + 3332 + 500 + 1166 = **4998**. The disabled language-context -share doubled what recently-viewed got. +Sum = 7500. The cascade iterates only the four rendered parts, seeded with `0`. +Its end-of-loop surplus (`finalSurplus`) is added to the current file's base +allocation, shown as the final row's `budget`. -### Example B — modest language context; large single recently-viewed file; no neighbors +### Example A — cascade parts modest; current file absorbs the leftover | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 800 | 866 | -| `recentlyViewedDocuments` | 866 | 2532 | 2532 | 0 | -| `neighborFiles` | 0 | 833 | 0 | 833 | -| `diffHistory` | 833 | 1666 | 1500 | 166 (wasted) | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 1500 | 2500 | +| `neighborFiles` | 2500 | 3500 | 500 | 3000 | +| `diffHistory` | 3000 | 4000 | 500 | 3500 | +| `currentFile` (clipped last) | 3500 (`finalSurplus`) | 1500 + 3500 = 5000 | 5000 | 0 | -Tokens placed: 800 + 2532 + 0 + 1500 = **4832**. The trailing 166 from the last -part is lost (no back-flow). +Tokens placed: 0 + 1500 + 500 + 500 + 5000 = **7500**. The cascade consumed only +2500, so its 3500 leftover flowed into the current file, which grew from its 1500 +base to 5000 and trimmed less. -### Example C — everything modest +### Example B — empty cascade; current file reuses the whole pool | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 400 | 1266 | -| `recentlyViewedDocuments` | 1266 | 2932 | 1500 | 1432 | -| `neighborFiles` | 1432 | 2265 | 900 | 1365 | -| `diffHistory` | 1365 | 2198 | 600 | 1598 (wasted) | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 0 | 4000 | +| `neighborFiles` | 4000 | 5000 | 0 | 5000 | +| `diffHistory` | 5000 | 6000 | 0 | 6000 | +| `currentFile` (clipped last) | 6000 (`finalSurplus`) | 1500 + 6000 = 7500 | ≤ 7500 | — | -Tokens placed: 400 + 1500 + 900 + 600 = **3400**. Each later part received a -generous inflated cap but had no material to fill it. +With no language context, empty history, and neighbors disabled, every cascade +part consumes `0`, so the entire non-currentFile pool (`2000 + 2000 + 1000 + 1000 += 6000`) carries to `finalSurplus`. The current file's clip cap becomes `1500 + +6000 = 7500 = T` — it effectively reuses the whole pool. This is the common case +for a file edited in isolation. -### Example D — large `languageContext`, no donation +### Example C — cascade fills its pool; current file gets only its base | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 1666 | 0 | -| `recentlyViewedDocuments` | 0 | 1666 | 1500 | 166 | -| `neighborFiles` | 166 | 999 | 900 | 99 | -| `diffHistory` | 99 | 932 | 800 | 132 (wasted) | - -Tokens placed: 1666 + 1500 + 900 + 800 = **4866**. Each part stayed at or below -its floor; nothing was starved. +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 2000 | 0 | +| `recentlyViewedDocuments` | 0 | 2000 | 2000 | 0 | +| `neighborFiles` | 0 | 1000 | 1000 | 0 | +| `diffHistory` | 0 | 1000 | 1000 | 0 | +| `currentFile` (clipped last) | 0 (`finalSurplus`) | 1500 + 0 = 1500 | 1500 | 0 | + +Tokens placed: 2000 + 2000 + 1000 + 1000 + 1500 = **7500**. Every cascade part +filled its own share exactly, so `finalSurplus = 0` and the current file falls +back to its 1500 base — the per-part floor. The current file never shrinks below +this base. ## Disabled parts @@ -248,51 +339,53 @@ Parts can be disabled by configuration (`languageContext.enabled = false`, context response, empty neighbor snippets, no edit history). Those parts remain in `order` — the wired-in code always uses `DEFAULT_ORDER`/`DEFAULT_SHARES` — so their slot still runs, just with `consumed = 0`, and their full share donates -forward. +forward. Whatever reaches the end of the cascade becomes `finalSurplus` and is +reused by the current file's clip rather than wasted. ### Effective caps when both `languageContext` and `neighborFiles` are off With `DEFAULT_ORDER` and pool `T`, applying the per-step floor at every donation -step (let `C_rv = floor(floor(T·2/6) + T·2/6)` be the effective cap on -recently-viewed): +step (the cascade is seeded `0`, so let \`C\_rv = floor(floor(T·langCtxShare) + T·rvShare)\` be +the effective cap on recently-viewed): | Part | Effective cap | -|---|---| +| --- | --- | | `languageContext` | 0 (consumed) | -| `recentlyViewedDocuments` | `C_rv` (own 2/6 + langCtx's 2/6, with per-step `floor`) | +| `recentlyViewedDocuments` | `C_rv` (langCtx's share + own share, with per-step `floor`) | | `neighborFiles` | 0 (consumed) | -| `diffHistory` | `floor(floor((C_rv − consumed_rv) + T·1/6) + T·1/6)` (own 1/6 + neighbors' 1/6 + recently-viewed's leftover, with per-step `floor`) | +| `diffHistory` | `floor(floor((C_rv − consumed_rv) + T·neighborShare) + T·diffShare)` (own share + neighbors' share + recently-viewed's leftover, with per-step `floor`) | -At `T = 5000` (so `C_rv = floor(1666 + 1666.666…) = 3332` and the total -cascade pool ceiling is `floor(floor(3332 + 833.333…) + 833.333…) = 4998`, -not `5000`, because of per-step flooring): +At `T = 7500`, \`C\_rv = floor(2000 + 2000) = 4000\` and the total cascade pool +ceiling is \`floor(floor(4000 + 1000) + 1000) = 6000\`. Whatever `diffHistory` +leaves becomes `finalSurplus` for the current file: -| `recentlyViewedDocuments` consumed | `diffHistory` cap | -|---|---| -| 3332 (fills cap) | 1666 | -| 1500 | 3498 | -| 0 | 4998 (whole cascade pool) | +| `recentlyViewedDocuments` consumed | `diffHistory` cap | `finalSurplus` → current file | +| --- | --- | --- | +| 4000 (fills cap) | 2000 | 0 (current file at base 1500) | +| 1500 | 4500 | up to 4500 | +| 0 | 6000 (whole cascade pool) | up to 6000 (current file up to 7500) | -Worked example with both enabled parts hungry at `T = 5000`: +Worked example with both enabled parts hungry at `T = 7500`: | Part | surplus in | budget | consumed | surplus out | -|---|---|---|---|---| -| `languageContext` | 0 | 1666 | 0 | 1666 | -| `recentlyViewedDocuments` | 1666 | 3332 | 3332 | 0 | -| `neighborFiles` | 0 | 833 | 0 | 833 | -| `diffHistory` | 833 | 1666 | 1666 | 0 | +| --- | --- | --- | --- | --- | +| `languageContext` | 0 | 2000 | 0 | 2000 | +| `recentlyViewedDocuments` | 2000 | 4000 | 4000 | 0 | +| `neighborFiles` | 0 | 1000 | 0 | 1000 | +| `diffHistory` | 1000 | 2000 | 2000 | 0 | +| `currentFile` (clipped last) | 0 (`finalSurplus`) | 1500 | 1500 | 0 | -Total placed: **4998**. Recently-viewed absorbs langCtx's donation; diff history -absorbs neighbors' donation. Nothing is permanently wasted unless the *last* -part (`diffHistory`) also under-fills its budget — diff's surplus has no next -part to flow to. +Cascade placed **6000** (recently-viewed absorbed langCtx's donation; diff history +absorbed neighbors' donation), leaving `finalSurplus = 0`, so the current file +stays at its 1500 base for a total of 7500. ### Ordering caveat -`recentlyViewedDocuments` has **first claim** on the donation pool from -`languageContext` because it sits earlier in `order`. If recently-viewed is -hungry, diff history only sees `neighbors' 1/6 + own 1/6 = 2/6` (1666 at -`T = 5000`) — it cannot reach `languageContext`'s share directly. +`recentlyViewedDocuments` has **first claim** on the cascade donation pool +(`languageContext`'s share) because it sits earlier in `order`. If recently-viewed +is hungry, diff history only sees \`neighbors' + own share = 2000 at T = 7500\` +— it cannot reach `languageContext`'s share directly. Whatever +diff history (the last cascade part) leaves still flows to the current file. To make diff history share the donation, you would need to either reorder so `diffHistory` precedes `recentlyViewedDocuments`, or rebalance `shares`. The @@ -305,14 +398,98 @@ rebalance `shares` to sum to 1 — the donation would then be baked in staticall rather than emerging from the cascade. The wired-in code does not do this; it always passes `DEFAULT_ORDER` and `DEFAULT_SHARES`. -## Composition with `currentFile` +## Clip the current file last + +The current file is the natural sink for the cascade's leftover because it is the +one part clipped *around* a point of interest (the cursor), so it can always +absorb more context. Rather than build the prompt, measure what is unused, then +rebuild the current file bigger (a two-pass approach that risks double-counting +the leftover), the implementation simply **clips the current file last**: run the +cascade first, then size the current file with all the budget the cascade did not +use. + +### Mechanism + +In `xtabProvider`, under a global budget: + +1. Gather the cascade inputs (language context, neighbor snippets) — these do + **not** depend on the current-file clip, so they can be produced first. +2. Run `runGlobalBudgetCascade(...)` (seeded with `0`; the current file donates + nothing, so the cascade only ever *gives*). +3. Clip the current file **last** with cap + `currentFileBudget + cascade.finalSurplus`. +4. Assemble the prompt, passing the already-computed cascade through to + `getUserPrompt` as `precomputedCascade`. `getUserPrompt` honors + `precomputedCascade` only when `globalBudget` is set, so the cascade runs + exactly **once** and the rendered snippets match the sizing. + +When `globalBudget` is `undefined` (prod default) none of this applies: the +current file is clipped to its own `currentFile.maxTokens`, the inputs are +gathered after, and the cascade is not run at all — byte-identical to the legacy +path. + +### Conservation (proved) -`currentFile` is sized **outside** the cascade by -`createTaggedCurrentFileContentUsingPagedClipping`, which clips around the -cursor / edit window up to `currentFile.maxTokens`. The clipped string is -passed into `getUserPrompt` via `taggedCurrentDocLines` and concatenated -between the cascade-managed `recent_files` and `edit_history` blocks. The -cascade never sees nor influences current-file sizing. +Because the current file does not donate, budget flows in a single direction +(cascade → current file), so there is no double-counting. With the cascade seeded +at `0`, the end-of-loop surplus telescopes to + +``` +finalSurplus ≤ Σ(totalTokens · shareᵢ) for i in order + − Σ(consumedᵢ) = (T − T·share_cf) − C_cascade +``` + +so the current file's clip cap is + +``` +cfBudget = floor(T · share_cf) + finalSurplus ≤ T · (Σ all shares) − C_cascade +⇒ C_cf + C_cascade ≤ T · (Σ all shares) (total bounded by the pool) ✅ +``` -Final prompt size ≈ `currentFile.maxTokens` + ≤ `totalTokens` (cascade) + -lint + tags/scaffolding + postscript. +and since `finalSurplus ≥ 0`, `cfBudget ≥ floor(T · share_cf)` — the current file +**never shrinks below** its base share. Non-negative, validated shares (see +[Validation](#validation)) are what make the first inequality hold. + +> **Caveat — share-sum tolerance.** `validate` accepts `|Σ shares − 1| ≤ 1e-3`, so +> the bound above is `T · (Σ shares)`, not exactly `T`. A config whose shares sum +> slightly above 1 can over-allocate by at most `~1e-3 · T` (≈ 7.5 tokens at the +> default `T = 7500`). Shares summing to exactly 1 (the defaults do) give the clean +> `≤ T` bound. Under-allocation (sum < 1) simply wastes a little budget. + +> **Caveat — internal accounting, not the full rendered prompt.** "≤ `T`" is over +> the budgeted parts' *internal* token accounting (paged-clipping line cost, +> raw-snippet cost, diff-entry cost). The fully rendered prompt also carries tag +> wrappers, related-info scaffolding, lint, and the postscript, which live outside +> the pool. So the guarantee is "the budgeted parts together consume ≤ `T`", not +> "the whole prompt is ≤ `T` characters". Tests assert current-file-region +> **growth** and internal accounting, not an absolute full-prompt bound. + +### Worked example + +`T = 6000`, `languageContext` disabled, cascade consumes `rv 1500 + neighbor 500 ++ diff 500 = 2500` (`C_cascade = 2500`), `share_cf = 1500/7500 = 1/5` ⇒ base +`currentFileBudget = floor(6000 · 1/5) = 1200`: + +- The cascade is seeded `0`; its `finalSurplus = (1600 + 1600 + 800 + 800) − 2500 + = 2300`. +- The current file is clipped last to `1200 + 2300 = 3500` (= `6000 − 2500`). + +The current file grows `1200 → 3500`, absorbing exactly the 2300 the cascade left +unused — matching the intuition "budget 6k, first build consumed 4k ⇒ give the +current file the remaining 2k". + +### Trade-offs and caveats + +- **Current file does not donate.** Budget only ever flows cascade → current file. + When the current file is small, its base share is **not** handed to the cascade + parts (they get only their own shares). A deployment optimizing for rich + *neighbor/history* context (rather than current-file context) would need to + rebalance `shares` to give those parts more. +- **Reordered awaits.** Because the cascade runs before the current-file clip, + language-context and neighbor-snippet gathering happen before the current file is + clipped. Any `PromptTooLarge('currentFile')` early-return / cancellation reason + therefore fires *after* those awaits under a global budget. This is an acceptable + consequence of the opt-in feature; the prod path keeps the legacy ordering. +- **Next-cursor predictor unaffected.** `xtabNextCursorPredictor` keeps its own + dedicated current-file cap and never carries a global budget into its prompt, so + it is byte-identical regardless of this feature. diff --git a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts index 869fbe8f3a5ad9..c9ef4d842a8884 100644 --- a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts +++ b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts @@ -5,6 +5,7 @@ import { DocumentId } from '../../../platform/inlineEdits/common/dataTypes/documentId'; import { LanguageContextResponse } from '../../../platform/inlineEdits/common/dataTypes/languageContext'; +import { PromptSectionTokenCounts } from '../../../platform/inlineEdits/common/dataTypes/promptSectionTokens'; import * as xtabPromptOptions from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { AggressivenessLevel, CurrentFileOptions, GlobalBudgetOptions, PromptingStrategy, PromptOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { StatelessNextEditDocument } from '../../../platform/inlineEdits/common/statelessNextEditProvider'; @@ -12,13 +13,13 @@ import { IXtabHistoryEntry } from '../../../platform/inlineEdits/common/workspac import { ContextKind, TraitContext } from '../../../platform/languageServer/common/languageContextService'; import { Result } from '../../../util/common/result'; import { range } from '../../../util/vs/base/common/arrays'; -import { assertNever } from '../../../util/vs/base/common/assert'; +import { assertNever, softAssert } from '../../../util/vs/base/common/assert'; import { StringEdit, StringReplacement } from '../../../util/vs/editor/common/core/edits/stringEdit'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; import { getEditDiffHistory } from './diffHistoryForPrompt'; import { LintErrors } from './lintErrors'; import { countTokensForLines, toUniquePath } from './promptCraftingUtils'; -import { appendLanguageContextSnippets, appendNeighborFileSnippets, AppendNeighborFileSnippetsResult, buildCodeSnippetsUsingPagedClipping, getRecentCodeSnippets, prepareRecentCodeSnippets } from './recentFilesForPrompt'; +import { appendLanguageContextSnippets, appendNeighborFileSnippets, AppendNeighborFileSnippetsResult, buildCodeSnippetsUsingPagedClipping, getRecentCodeSnippets, prepareRecentCodeSnippets, RecentlyViewedSubsectionSnippets } from './recentFilesForPrompt'; import { INeighborFileSnippet } from './similarFilesContextService'; import { PromptTags } from './tags'; import { CurrentDocument } from './xtabCurrentDocument'; @@ -38,6 +39,15 @@ export class PromptPieces { public readonly computeTokens: (s: string) => number, public readonly opts: PromptOptions, public readonly neighborSnippets?: readonly INeighborFileSnippet[], + /** + * A cascade result computed by the caller (the provider, which runs the + * cascade first so it can clip `currentFile` last to `currentFileBudget + + * finalSurplus`). When provided, {@link getUserPrompt} renders these + * snippets instead of running the cascade itself, guaranteeing the surplus + * used to size the current file matches the snippets that end up in the + * prompt. Only honored when `opts.globalBudget` is set. + */ + public readonly precomputedCascade?: CascadeResult, ) { } } @@ -45,33 +55,42 @@ export class PromptPieces { export interface UserPromptResult { readonly prompt: string; readonly nDiffsInPrompt: number; - readonly diffTokensInPrompt: number; readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; + /** + * Approximate per-section token counts for the user prompt. `systemPrompt` is + * left at 0 here (the system message is not part of the user prompt) and is + * filled in by the provider. + */ + readonly sectionTokens: PromptSectionTokenCounts; } export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { - const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts, neighborSnippets } = promptPieces; + const { activeDoc, xtabHistory, taggedCurrentDocLines, areaAroundCodeToEdit, langCtx, aggressivenessLevel, lintErrors, computeTokens, opts, neighborSnippets, precomputedCascade } = promptPieces; const currentFileContent = taggedCurrentDocLines.join('\n'); let recentlyViewedCodeSnippets: string; + let recentlyViewedSubsections: RecentlyViewedSubsectionSnippets; let docsInPrompt: Set; let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; let editDiffHistory: string; let nDiffsInPrompt: number; - let diffTokensInPrompt: number; if (opts.globalBudget !== undefined) { - const cascade = runGlobalBudgetCascade(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets, opts.globalBudget); + // Reuse a cascade the caller already ran (the provider runs it first so it can + // clip the current file last from `finalSurplus`), or run it now for callers + // that set a global budget without precomputing (e.g. tests). + const cascade = precomputedCascade ?? runGlobalBudgetCascade(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets, opts.globalBudget); recentlyViewedCodeSnippets = cascade.codeSnippets; + recentlyViewedSubsections = cascade.subsections; docsInPrompt = cascade.documents; neighborSnippetsResult = cascade.neighborSnippetsResult; editDiffHistory = cascade.editDiffHistory; nDiffsInPrompt = cascade.nDiffsInPrompt; - diffTokensInPrompt = cascade.diffTokensInPrompt; } else { const r = getRecentCodeSnippets(activeDoc, xtabHistory, langCtx, computeTokens, opts, neighborSnippets); recentlyViewedCodeSnippets = r.codeSnippets; + recentlyViewedSubsections = r.subsections; docsInPrompt = r.documents; neighborSnippetsResult = r.neighborSnippetsResult; @@ -80,7 +99,6 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { const diff = getEditDiffHistory(activeDoc, xtabHistory, docsInPrompt, computeTokens, opts.diffHistory); editDiffHistory = diff.promptPiece; nDiffsInPrompt = diff.nDiffs; - diffTokensInPrompt = diff.totalTokens; } const relatedInformation = getRelatedInformation(langCtx); @@ -91,20 +109,20 @@ export function getUserPrompt(promptPieces: PromptPieces): UserPromptResult { const lintsWithNewLinePadding = opts.lintOptions ? `\n${lintErrors.getFormattedLintErrors(opts.lintOptions)}\n` : ''; - const basePrompt = `${PromptTags.RECENT_FILES.start} -${recentlyViewedCodeSnippets} -${PromptTags.RECENT_FILES.end} + // Build each rendered section string exactly once and assemble `basePrompt` + // from those same locals, so the per-section token counts below are derived + // from the identical text that goes into the prompt (single source of truth, + // no rebuild and no drift). `areaAroundCodeToEdit`/`cursorLocation` are + // mutually exclusive and appended per-strategy below. + const recentFilesSection = `${PromptTags.RECENT_FILES.start}\n${recentlyViewedCodeSnippets}\n${PromptTags.RECENT_FILES.end}`; + const currentFileSection = `${PromptTags.CURRENT_FILE.start}\ncurrent_file_path: ${currentFilePath}\n${currentFileContent}\n${PromptTags.CURRENT_FILE.end}`; + const editHistorySection = `${PromptTags.EDIT_HISTORY.start}\n${editDiffHistory}\n${PromptTags.EDIT_HISTORY.end}`; -${PromptTags.CURRENT_FILE.start} -current_file_path: ${currentFilePath} -${currentFileContent} -${PromptTags.CURRENT_FILE.end} -${lintsWithNewLinePadding} -${PromptTags.EDIT_HISTORY.start} -${editDiffHistory} -${PromptTags.EDIT_HISTORY.end}`; + const basePrompt = `${recentFilesSection}\n\n${currentFileSection}\n${lintsWithNewLinePadding}\n${editHistorySection}`; let mainPrompt: string; + let cursorLocationSection = ''; + let areaAroundSection = ''; switch (opts.promptingStrategy) { case PromptingStrategy.PatchBased01: mainPrompt = basePrompt; @@ -135,10 +153,12 @@ ${PromptTags.EDIT_HISTORY.end}`; `${lineNumbering}${cursorLineWithTag}`, PromptTags.CURSOR_LOCATION.end ].join('\n'); + cursorLocationSection = lineWithCursorSnippet; mainPrompt = basePrompt + `\n\n${lineWithCursorSnippet}`; break; } default: + areaAroundSection = areaAroundCodeToEdit; mainPrompt = basePrompt + `\n\n${areaAroundCodeToEdit}`; break; } @@ -156,16 +176,52 @@ ${PromptTags.EDIT_HISTORY.end}`; const trimmedPrompt = prompt.trim(); - return { prompt: trimmedPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult }; + const recentlyViewedTokens = computeTokens(recentFilesSection); + const currentFileTokens = computeTokens(currentFileSection); + const lintErrorsTokens = computeTokens(lintsWithNewLinePadding); + const editHistoryTokens = computeTokens(editHistorySection); + const areaAroundCodeToEditTokens = computeTokens(areaAroundSection); + const cursorLocationTokens = computeTokens(cursorLocationSection); + const relatedInformationTokens = computeTokens(relatedInformation); + const postScriptTokens = computeTokens(postScript); + const userPromptTotalTokens = computeTokens(trimmedPrompt); + const sectionsSum = recentlyViewedTokens + currentFileTokens + lintErrorsTokens + editHistoryTokens + areaAroundCodeToEditTokens + cursorLocationTokens + relatedInformationTokens + postScriptTokens; + const sectionTokens: PromptSectionTokenCounts = { + recentlyViewed: recentlyViewedTokens, + currentFile: currentFileTokens, + lintErrors: lintErrorsTokens, + editHistory: editHistoryTokens, + areaAroundCodeToEdit: areaAroundCodeToEditTokens, + cursorLocation: cursorLocationTokens, + relatedInformation: relatedInformationTokens, + postScript: postScriptTokens, + overhead: userPromptTotalTokens - sectionsSum, + userPromptTotal: userPromptTotalTokens, + systemPrompt: 0, + recentlyViewedSubsections: { + recentlyViewedFiles: computeTokens(recentlyViewedSubsections.recentlyViewedFiles), + languageContext: computeTokens(recentlyViewedSubsections.languageContext), + neighborFiles: computeTokens(recentlyViewedSubsections.neighborFiles), + }, + }; + + return { prompt: trimmedPrompt, nDiffsInPrompt, neighborSnippetsResult, sectionTokens }; } -interface CascadeResult { +export interface CascadeResult { readonly codeSnippets: string; readonly documents: Set; readonly neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; readonly editDiffHistory: string; readonly nDiffsInPrompt: number; - readonly diffTokensInPrompt: number; + /** Per-source breakdown of {@link codeSnippets} for per-subsection token reporting. */ + readonly subsections: RecentlyViewedSubsectionSnippets; + /** + * Budget left unused after the last part in `order` ran. The provider adds it + * to the current file's clip budget (`currentFileBudget + finalSurplus`) so the + * current file, which is clipped last, reuses whatever the cascade left unused. + */ + readonly finalSurplus: number; } /** @@ -174,17 +230,21 @@ interface CascadeResult { * `surplus + totalTokens * shares[part]` and run that sub-builder with the override. * Unspent budget cascades to the next part. * - * Sub-builders are invoked using existing helpers so behavior of each individual - * part is unchanged. `currentFile` and `lintOptions` are intentionally excluded - * and continue using their own per-part caps. + * The cascade starts with surplus `0` and renders only the parts in `order`. + * `currentFile` is not rendered here (it is clipped separately by the caller) and + * `lintOptions` is excluded entirely; both keep their own per-part caps for + * clipping. Whatever budget is unused after the last part is returned as + * {@link CascadeResult.finalSurplus}; the provider adds it to the current file's + * clip budget so the current file reuses the leftover (it is clipped last). * - * Each sub-builder reports `tokensConsumed` using the same internal accounting - * it uses to make budget decisions (paged-clipping line cost, raw-snippet cost - * for appenders, diff-entry cost for history). The cascade uses that reported - * value to compute `surplus`, which keeps the cascade aligned with how each - * part actually charges against its budget. + * Sub-builders are invoked using existing helpers so behavior of each individual + * part is unchanged. Each sub-builder reports `tokensConsumed` using the same + * internal accounting it uses to make budget decisions (paged-clipping line cost, + * raw-snippet cost for appenders, diff-entry cost for history). The cascade uses + * that reported value to compute `surplus`, which keeps the cascade aligned with + * how each part actually charges against its budget. */ -function runGlobalBudgetCascade( +export function runGlobalBudgetCascade( activeDoc: StatelessNextEditDocument, xtabHistory: readonly IXtabHistoryEntry[], langCtx: LanguageContextResponse | undefined, @@ -193,7 +253,7 @@ function runGlobalBudgetCascade( neighborSnippets: readonly INeighborFileSnippet[] | undefined, globalBudget: GlobalBudgetOptions, ): CascadeResult { - validateGlobalBudget(globalBudget); + GlobalBudgetOptions.validate(globalBudget); const recentlyViewedSnippets: string[] = []; const langCtxSnippets: string[] = []; @@ -204,7 +264,6 @@ function runGlobalBudgetCascade( let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; let editDiffHistory = ''; let nDiffsInPrompt = 0; - let diffTokensInPrompt = 0; const preparedRecent = prepareRecentCodeSnippets(activeDoc, xtabHistory, opts); @@ -243,13 +302,17 @@ function runGlobalBudgetCascade( const r = getEditDiffHistory(activeDoc, xtabHistory, docsInPrompt, computeTokens, overriddenDiff); editDiffHistory = r.promptPiece; nDiffsInPrompt = r.nDiffs; - diffTokensInPrompt = r.totalTokens; tokensConsumed = r.totalTokens; break; } default: assertNever(part); } + // The conservation guarantee (current file + cascade <= totalTokens) relies + // on every sub-builder reporting `0 <= tokensConsumed <= budget`. Surface a + // violation (never silently clamp `tokensConsumed` down — that would hide + // real overspend and let the prompt exceed the pool). + softAssert(tokensConsumed >= 0 && tokensConsumed <= budget, `globalBudget part '${part}' reported tokensConsumed=${tokensConsumed} outside [0, ${budget}]`); surplus = Math.max(0, budget - tokensConsumed); } @@ -261,45 +324,15 @@ function runGlobalBudgetCascade( neighborSnippetsResult, editDiffHistory, nDiffsInPrompt, - diffTokensInPrompt, + subsections: { + recentlyViewedFiles: recentlyViewedSnippets.join('\n\n'), + languageContext: langCtxSnippets.join('\n\n'), + neighborFiles: neighborOutSnippets.join('\n\n'), + }, + finalSurplus: surplus, }; } -/** - * Validate {@link GlobalBudgetOptions} since it is runtime-configurable - * (e.g. via experiments). Catches misconfigurations that would otherwise - * cause silent, hard-to-debug behavior: - * - duplicate parts in `order` (would render the same part twice) - * - missing share for any part in `order` - * - shares not summing to ~1 across `order` (would over/under-allocate) - * - `neighborFiles` ordered before `recentlyViewedDocuments` (the former - * consults `docsInPrompt` populated by the latter) - */ -function validateGlobalBudget(globalBudget: GlobalBudgetOptions): void { - const seen = new Set(); - for (const part of globalBudget.order) { - if (seen.has(part)) { - throw new Error(`globalBudget.order contains duplicate part '${part}'`); - } - seen.add(part); - if (typeof globalBudget.shares[part] !== 'number') { - throw new Error(`globalBudget.shares is missing entry for '${part}'`); - } - } - - const recentIdx = globalBudget.order.indexOf('recentlyViewedDocuments'); - const neighborIdx = globalBudget.order.indexOf('neighborFiles'); - if (recentIdx !== -1 && neighborIdx !== -1 && neighborIdx < recentIdx) { - throw new Error(`globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'`); - } - - const sharesSum = globalBudget.order.reduce((sum, part) => sum + globalBudget.shares[part], 0); - const epsilon = 1e-3; - if (Math.abs(sharesSum - 1) > epsilon) { - throw new Error(`globalBudget.shares across order must sum to ~1, got ${sharesSum}`); - } -} - function wrapInBackticks(content: string) { return `\`\`\`\n${content}\n\`\`\``; } diff --git a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts index 6db4b0bbd8fea4..65fc454c4a6683 100644 --- a/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts +++ b/extensions/copilot/src/extension/xtab/common/recentFilesForPrompt.ts @@ -26,7 +26,12 @@ export function getRecentCodeSnippets( computeTokens: (code: string) => number, opts: PromptOptions, neighborSnippets?: readonly INeighborFileSnippet[], -): { codeSnippets: string; documents: Set; neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined } { +): { + codeSnippets: string; + documents: Set; + neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; + subsections: RecentlyViewedSubsectionSnippets; +} { const { includeViewedFiles, nDocuments, clippingStrategy } = opts.recentlyViewedDocuments; @@ -40,24 +45,49 @@ export function getRecentCodeSnippets( recentlyViewedCodeSnippets = docsBesidesActiveDoc.map(d => historyEntryToCodeSnippet(d)); } + // Keep the three sources in separate arrays (like `runGlobalBudgetCascade`) + // so per-subsection token counts can be reported. The appenders only read + // `docsInPrompt` for de-duplication, so splitting the output arrays does not + // change which snippets are selected; concatenating them in the same order + // (recent files, language context, neighbor files) reproduces the previous + // single-array output byte-for-byte. const { snippets, docsInPrompt } = buildCodeSnippetsUsingPagedClipping(recentlyViewedCodeSnippets, computeTokens, opts); + const langCtxSnippets: string[] = []; if (langCtx) { - appendLanguageContextSnippets(langCtx, snippets, opts.languageContext.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); + appendLanguageContextSnippets(langCtx, langCtxSnippets, opts.languageContext.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); } + const neighborOutSnippets: string[] = []; let neighborSnippetsResult: AppendNeighborFileSnippetsResult | undefined; if (opts.neighborFiles.enabled && neighborSnippets && neighborSnippets.length > 0) { - neighborSnippetsResult = appendNeighborFileSnippets(neighborSnippets, snippets, docsInPrompt, opts.neighborFiles.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); + neighborSnippetsResult = appendNeighborFileSnippets(neighborSnippets, neighborOutSnippets, docsInPrompt, opts.neighborFiles.maxTokens, computeTokens, opts.recentlyViewedDocuments.includeLineNumbers); } return { - codeSnippets: snippets.join('\n\n'), + codeSnippets: [...snippets, ...langCtxSnippets, ...neighborOutSnippets].join('\n\n'), documents: docsInPrompt, neighborSnippetsResult, + subsections: { + recentlyViewedFiles: snippets.join('\n\n'), + languageContext: langCtxSnippets.join('\n\n'), + neighborFiles: neighborOutSnippets.join('\n\n'), + }, }; } +/** + * Rendered strings for the three sources that make up the + * `recently_viewed_code_snippets` block, kept separate so per-subsection token + * counts can be reported. Each is the `\n\n`-joined snippets for that source + * (empty string when the source contributed nothing). + */ +export interface RecentlyViewedSubsectionSnippets { + readonly recentlyViewedFiles: string; + readonly languageContext: string; + readonly neighborFiles: string; +} + function formatLinesWithLineNumbers( lines: string[], includeLineNumbers: xtabPromptOptions.IncludeLineNumbersOption, diff --git a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts index 542a6a109f2877..eb2d7ec98d06b4 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabPatchResponseHandler.ts @@ -26,6 +26,13 @@ class Patch { public removedLines: string[] = []; public addedLines: string[] = []; + /** + * True for the early cursor-line replacement produced by {@link Patch.progressiveRevealParts}. + * Such a patch is a 1-for-1 additive edit whose single added line IS the change we want to + * surface, so the duplicate-additions policy is skipped for it (see {@link shouldApplyDuplicatePolicy}). + */ + public isProgressiveRevealEarlyEdit = false; + private constructor( /** * Expected to be file path relative to workspace root. @@ -50,11 +57,26 @@ class Patch { } /** - * Creates a pure-insertion patch (no removed lines) at the given line. - * Used for the continuation portion of a ghost-text progressive reveal. + * Builds the two patches of a progressive ghost-text reveal from the first model patch: + * - `early`: the cursor-line replacement (`removedLines[0]` -> `addedLines[0]`), yielded + * immediately so the additive cursor-line edit shows ASAP. It is a 1-for-1 replacement, + * so it shifts no line anchors below it. + * - `continuation`: the remaining removed/added lines, anchored on the next line. It carries + * `removedLines.slice(1)` (empty for the common single-removed-line case, making it a pure + * insertion) and collects the remaining added lines as they stream in. + * + * Both share the source patch's `patchIndex`. The union of the two is byte-identical to + * applying the original patch. */ - public static insertion(filePath: string, lineNumZeroBased: number, patchIndex: number): Patch { - return new Patch(filePath, lineNumZeroBased, patchIndex); + public static progressiveRevealParts(source: Patch): { early: Patch; continuation: Patch } { + const early = new Patch(source.filePath, source.lineNumZeroBased, source.patchIndex); + early.removedLines = [source.removedLines[0]]; + early.addedLines = [...source.addedLines]; + early.isProgressiveRevealEarlyEdit = true; + + const continuation = new Patch(source.filePath, source.lineNumZeroBased + 1, source.patchIndex); + continuation.removedLines = source.removedLines.slice(1); + return { early, continuation }; } addLine(line: string): boolean { @@ -286,6 +308,53 @@ function applyDuplicatePolicy( } } +/** + * Whether a duplicate-additions mode preserves a patch's removed range. `Off` (no dedup) and + * `TrimDuplicate` (trims only duplicate additions, keeping the removal) do; `DropPatch` and + * `DropAllRemaining` may drop a whole patch. + */ +function duplicateModePreservesRemovedRange(mode: DuplicateAdditionsMode): boolean { + return mode === DuplicateAdditionsMode.Off || mode === DuplicateAdditionsMode.TrimDuplicate; +} + +/** + * Whether the first patch may reveal a *multi*-removed-line cursor edit early. This extends the + * single-line reveal, so it requires the base progressive-reveal flag (which also acts as the + * kill switch) and the multi-line flag. It is further limited to duplicate-additions modes that + * preserve the removed range: a multi-line reveal's continuation carries removed lines, and a + * mode that could drop that continuation *after* the early cursor-line edit was already emitted + * would leave stale lines below the cursor. + */ +function allowsMultiLineProgressiveReveal(enableProgressiveGhostText: boolean, enableProgressiveGhostTextMultiLine: boolean, duplicateAdditionsMode: DuplicateAdditionsMode): boolean { + return enableProgressiveGhostText && enableProgressiveGhostTextMultiLine && duplicateModePreservesRemovedRange(duplicateAdditionsMode); +} + +/** + * Whether the duplicate-additions policy should run for a resolved edit. It only applies to the + * active document (other files' content is not available here) and never to a progressive + * reveal's early edit: that edit is a 1-for-1 additive cursor-line replacement with no re-emitted + * context to trim, and dedup could wrongly trim its added line to empty — turning it into a + * cursor-line deletion — when it coincides with the line just below the cursor (a cascade edit). + */ +function shouldApplyDuplicatePolicy(edit: Patch, isActiveDoc: boolean, duplicateAdditionsMode: DuplicateAdditionsMode): duplicateAdditionsMode is Exclude { + return duplicateAdditionsMode !== DuplicateAdditionsMode.Off && isActiveDoc && !edit.isProgressiveRevealEarlyEdit; +} + +/** + * Detects the "delete an empty cursor line and shift the next line up" shape, which must not be + * treated as an additive cursor-line edit. When `removedLines[0]` is blank and the first added + * line equals or is a prefix of the next removed line (the original line just below the cursor), + * the model is absorbing the empty line rather than additively editing it. + */ +function isEmptyCursorLineShiftUp(patch: Patch): boolean { + if (patch.removedLines.length < 2 || patch.removedLines[0].trim() !== '') { + return false; + } + const firstAddedLine = patch.addedLines[0]; + const nextRemovedLine = patch.removedLines[1]; + return firstAddedLine === nextRemovedLine || nextRemovedLine.startsWith(firstAddedLine); +} + export namespace XtabPatchResponseHandler { /** @@ -356,6 +425,7 @@ export namespace XtabPatchResponseHandler { duplicateAdditionsMode: DuplicateAdditionsMode = DuplicateAdditionsMode.Off, enableProgressiveGhostText: boolean = false, splitPatchOnDiff: boolean = false, + enableProgressiveGhostTextMultiLine: boolean = false, ): AsyncGenerator { const tracer = parentTracer.createSubLogger(['XtabCustomDiffPatchResponseHandler', 'handleResponse']); const activeDocRelativePath = toUniquePath(activeDocumentId, workspaceRoot?.path); @@ -364,7 +434,8 @@ export namespace XtabPatchResponseHandler { let dropAllRemaining = false; const cursorLine = enableProgressiveGhostText ? currentDocument.cursorLineOffset : undefined; const progressiveDocPath = enableProgressiveGhostText ? activeDocRelativePath : undefined; - for await (const edit of extractEdits(linesStream, cursorLine, progressiveDocPath)) { + const allowMultiLineRemoval = allowsMultiLineProgressiveReveal(enableProgressiveGhostText, enableProgressiveGhostTextMultiLine, duplicateAdditionsMode); + for await (const edit of extractEdits(linesStream, cursorLine, progressiveDocPath, allowMultiLineRemoval)) { if (dropAllRemaining) { continue; } @@ -380,9 +451,7 @@ export namespace XtabPatchResponseHandler { let lineReplacement = resolveEdit(edit); - // Only attempt dedup for the active document — other files' - // content is not directly available here. - if (duplicateAdditionsMode !== DuplicateAdditionsMode.Off && isActiveDoc) { + if (shouldApplyDuplicatePolicy(edit, isActiveDoc, duplicateAdditionsMode)) { const decision = applyDuplicatePolicy(edit, lineReplacement, currentDocument.content, duplicateAdditionsMode, tracer); switch (decision.kind) { case 'skip': @@ -436,27 +505,40 @@ export namespace XtabPatchResponseHandler { } /** - * Checks whether the first patch qualifies for ghost-text progressive reveal. - * A patch qualifies if it has exactly one removed line, at least one added line, - * targets the cursor line in the active document, and the edit on the first - * line is additive (the removed line is a subsequence of the first added line). + * Checks whether the first patch qualifies for ghost-text progressive reveal: it targets the + * cursor line in the active document, has at least one added line, and additively edits the + * cursor line (the removed line is a subsequence of the first added line). + * + * By default only single-removed-line patches qualify. When `allowMultiLineRemoval` is set, + * patches that remove more than one line also qualify — the cursor-line change is revealed + * first and the remaining removed/added lines stream as a continuation — except for the + * empty-line shift-up shape (see {@link isEmptyCursorLineShiftUp}). */ - export function isGhostTextPatch(patch: Patch, cursorLineZeroBased: number, activeDocRelativePath: string): boolean { - return patch.removedLines.length === 1 - && patch.addedLines.length >= 1 - && patch.lineNumZeroBased === cursorLineZeroBased - && patch.filePath === activeDocRelativePath - && ResponseProcessor.isAdditiveEdit(patch.removedLines[0], patch.addedLines[0]); + export function isGhostTextPatch(patch: Patch, cursorLineZeroBased: number, activeDocRelativePath: string, allowMultiLineRemoval: boolean): boolean { + if (patch.lineNumZeroBased !== cursorLineZeroBased || patch.filePath !== activeDocRelativePath) { + return false; // not the cursor line in the active document + } + const removedLinesQualify = allowMultiLineRemoval ? patch.removedLines.length >= 1 : patch.removedLines.length === 1; + if (!removedLinesQualify || patch.addedLines.length < 1) { + return false; + } + if (!ResponseProcessor.isAdditiveEdit(patch.removedLines[0], patch.addedLines[0])) { + return false; // the cursor-line edit is not additive + } + return !isEmptyCursorLineShiftUp(patch); } /** * @param cursorLineZeroBased When provided (along with activeDocRelativePath), * enables ghost-text progressive reveal for the first patch: if the first patch - * is a ghost-text (single removed line at the cursor that is additively edited), - * the cursor-line replacement is yielded immediately and any further added lines - * are collected into a separate pure-insertion patch. + * is a ghost-text (a removed line at the cursor that is additively edited), + * the cursor-line replacement is yielded immediately and any further removed/added + * lines are collected into a separate continuation patch. + * @param allowMultiLineRemoval When set, progressive reveal also fires for first patches + * that remove more than one line (see {@link isGhostTextPatch}); the continuation then + * carries the remaining removed lines instead of being a pure insertion. */ - export async function* extractEdits(linesStream: AsyncIterable, cursorLineZeroBased?: number, activeDocRelativePath?: string): AsyncGenerator { + export async function* extractEdits(linesStream: AsyncIterable, cursorLineZeroBased?: number, activeDocRelativePath?: string, allowMultiLineRemoval: boolean = false): AsyncGenerator { let currentPatch: Patch | null = null; let isFirstPatch = true; @@ -493,17 +575,12 @@ export namespace XtabPatchResponseHandler { && currentPatch.addedLines.length === 1 && currentPatch.removedLines.length >= 1 ) { - if (isGhostTextPatch(currentPatch, cursorLineZeroBased, activeDocRelativePath)) { - // Both the early and continuation patches stem from the same - // model patch, so they share its index. - const sourcePatchIndex = currentPatch.patchIndex; - // Yield the cursor-line replacement immediately - const earlyPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased, sourcePatchIndex); - earlyPatch.removedLines = [...currentPatch.removedLines]; - earlyPatch.addedLines = [...currentPatch.addedLines]; - yield earlyPatch; - // Replace currentPatch with a continuation pure-insertion patch - currentPatch = Patch.insertion(currentPatch.filePath, currentPatch.lineNumZeroBased + 1, sourcePatchIndex); + if (isGhostTextPatch(currentPatch, cursorLineZeroBased, activeDocRelativePath, allowMultiLineRemoval)) { + // Reveal the additive cursor-line edit immediately, then continue + // streaming the rest of the patch as a continuation. + const { early, continuation } = Patch.progressiveRevealParts(currentPatch); + yield early; + currentPatch = continuation; } progressiveRevealDone = true; } diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index 959e2a31a937b3..7d8e822cb76751 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -33,6 +33,7 @@ import { OptionalChatRequestParams, Prediction } from '../../../platform/network import { IChatEndpoint } from '../../../platform/networking/common/networking'; import { ISimulationTestContext } from '../../../platform/simulationTestContext/common/simulationTestContext'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { raceFilter } from '../../../util/common/async'; import { AsyncIterUtils, AsyncIterUtilsExt } from '../../../util/common/asyncIterableUtils'; @@ -62,9 +63,9 @@ import { IgnoreImportChangesAspect } from '../../inlineEdits/node/importFilterin import { FetchStreamError } from '../common/fetchStreamError'; import { determineIsInlineSuggestionPosition } from '../common/inlineSuggestion'; import { LintErrors } from '../common/lintErrors'; -import { ClippedDocument, constructTaggedFile, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces } from '../common/promptCrafting'; +import { ClippedDocument, constructTaggedFile, getUserPrompt, N_LINES_ABOVE, N_LINES_AS_CONTEXT, N_LINES_BELOW, PromptPieces, runGlobalBudgetCascade, CascadeResult } from '../common/promptCrafting'; import { countTokensForLines, toUniquePath } from '../common/promptCraftingUtils'; -import { ISimilarFilesContextService } from '../common/similarFilesContextService'; +import { INeighborFileSnippet, ISimilarFilesContextService } from '../common/similarFilesContextService'; import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifiedModelSystemPrompt, xtab275SystemPrompt } from '../common/systemMessages'; import { PromptTags } from '../common/tags'; import { TerminalMonitor } from '../common/terminalOutput'; @@ -177,6 +178,7 @@ export class XtabProvider implements IStatelessNextEditProvider { @ILanguageDiagnosticsService private readonly langDiagService: ILanguageDiagnosticsService, @IIgnoreService private readonly ignoreService: IIgnoreService, @ISimilarFilesContextService private readonly similarFilesContextService: ISimilarFilesContextService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { this.userInteractionMonitor = this.instaService.createInstance(UserInteractionMonitor); this.terminalMonitor = this.instaService.createInstance(TerminalMonitor); @@ -250,6 +252,87 @@ export class XtabProvider implements IStatelessNextEditProvider { ); } + /** + * Gathers language context and neighbor snippets and clips the current file, returning the + * pieces {@link getUserPrompt} needs, or a {@link NoNextEditReason} when the request is + * cancelled mid-gathering or the current file cannot fit its budget. + * + * Under a global budget the current file is clipped LAST: the cascade runs first so the + * current file can reuse whatever budget it leaves unused (via `finalSurplus`), and the + * already-run cascade is returned as `precomputedCascade` so {@link getUserPrompt} renders + * it exactly once. With no global budget (prod default) the current file is clipped to its + * own per-part cap first, byte-identical to the legacy path. + */ + private async gatherContextAndClipCurrentFile( + globalBudget: xtabPromptOptions.GlobalBudgetOptions | undefined, + activeDocument: StatelessNextEditDocument, + request: StatelessNextEditRequest, + promptOptions: xtabPromptOptions.PromptOptions, + telemetry: StatelessNextEditTelemetryBuilder, + cancellationToken: CancellationToken, + gatherLanguageContext: () => Promise, + gatherNeighborSnippets: () => Promise, + clipCurrentFileToBudget: (overriddenMaxTokens: number | undefined) => Result<{ clippedTaggedCurrentDoc: ClippedDocument; areaAroundCodeToEdit: string }, 'outOfBudget'>, + ): Promise> { + if (globalBudget !== undefined) { + // Clip the current file LAST. Gather the cascade inputs (which do not depend + // on the current-file clip) and run the cascade first, then size the current + // file to `currentFileBudget + finalSurplus` so it reuses whatever budget the + // cascade left unused. The already-run cascade is threaded into + // `getUserPrompt` as `precomputedCascade` so it renders exactly once. + const langCtx = await gatherLanguageContext(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterLanguageContextAwait')); + } + + const neighborSnippets = await gatherNeighborSnippets(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait')); + } + + const cascade = runGlobalBudgetCascade(activeDocument, request.xtabEditHistory, langCtx, XtabProvider.computeTokens, promptOptions, neighborSnippets, globalBudget); + const currentFileBudget = xtabPromptOptions.GlobalBudgetOptions.currentFileBudget(globalBudget); + + const taggedCurrentFileContentResult = clipCurrentFileToBudget(currentFileBudget + cascade.finalSurplus); + if (taggedCurrentFileContentResult.isError()) { + return Result.error(new NoNextEditReason.PromptTooLarge('currentFile')); + } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; + telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + return Result.ok({ clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade: cascade, langCtx, neighborSnippets }); + } else { + // No global budget (prod default): clip the current file to its own per-part + // cap, then gather context. Byte-identical to the legacy path. + const taggedCurrentFileContentResult = clipCurrentFileToBudget(undefined); + if (taggedCurrentFileContentResult.isError()) { + return Result.error(new NoNextEditReason.PromptTooLarge('currentFile')); + } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; + // Record the clipped line count BEFORE the context-gathering awaits so it is + // still emitted when the request is cancelled mid-gathering (matches the + // legacy prod timing — the cancellation path below also reports telemetry). + telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + + const langCtx = await gatherLanguageContext(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterLanguageContextAwait')); + } + + const neighborSnippets = await gatherNeighborSnippets(); + if (cancellationToken.isCancellationRequested) { + return Result.error(new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait')); + } + + return Result.ok({ clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade: undefined, langCtx, neighborSnippets }); + } + } + private async *doGetNextEditWithSelection( request: StatelessNextEditRequest, selection: Range | null, @@ -310,27 +393,37 @@ export class XtabProvider implements IStatelessNextEditProvider { const doesIncludeCursorTag = editWindowLines.some(line => line.includes(PromptTags.CURSOR)); const shouldRemoveCursorTagFromResponse = !doesIncludeCursorTag; // we'd like to remove the tag only if the original edit-window didn't include the tag - const taggedCurrentFileContentResult = constructTaggedFile( - currentDocument, - editWindowLinesRange, - areaAroundEditWindowLinesRange, - promptOptions, - XtabProvider.computeTokens, - { - includeLineNumbers: { - areaAroundCodeToEdit: xtabPromptOptions.IncludeLineNumbersOption.None, - currentFileContent: promptOptions.currentFile.includeLineNumbers, - } - } - ); - - if (taggedCurrentFileContentResult.isError()) { - return new NoNextEditReason.PromptTooLarge('currentFile'); + // Under a global budget the current file is clipped LAST so it reuses whatever + // budget the cascade parts (recently-viewed, language context, neighbors, diff + // history) leave unused: the cascade runs first, then the current file is + // clipped to `currentFileBudget + cascadeFinalSurplus`, so it trims less. With + // no global budget (prod default) the current file keeps its own per-part cap. + const globalBudget = promptOptions.globalBudget; + if (globalBudget !== undefined) { + xtabPromptOptions.GlobalBudgetOptions.validate(globalBudget); } - const { clippedTaggedCurrentDoc, areaAroundCodeToEdit } = taggedCurrentFileContentResult.val; - - telemetry.setNLinesOfCurrentFileInPrompt(clippedTaggedCurrentDoc.lines.length); + // Clips the current file to `overriddenMaxTokens` (or its per-part + // `currentFile.maxTokens` cap when `overriddenMaxTokens` is undefined), + // returning the tagged lines plus the area around the code to edit. + const clipCurrentFileToBudget = (overriddenMaxTokens: number | undefined) => { + const cfPromptOptions = overriddenMaxTokens !== undefined + ? { ...promptOptions, currentFile: { ...promptOptions.currentFile, maxTokens: overriddenMaxTokens } } + : promptOptions; + return constructTaggedFile( + currentDocument, + editWindowLinesRange, + areaAroundEditWindowLinesRange, + cfPromptOptions, + XtabProvider.computeTokens, + { + includeLineNumbers: { + areaAroundCodeToEdit: xtabPromptOptions.IncludeLineNumbersOption.None, + currentFileContent: promptOptions.currentFile.includeLineNumbers, + } + } + ); + }; const { aggressivenessLevel, userHappinessScore } = this.userInteractionMonitor.getAggressivenessLevel(); @@ -344,7 +437,7 @@ export class XtabProvider implements IStatelessNextEditProvider { telemetry.setXtabUserHappinessScore(userHappinessScore); } - const langCtx = await this.getAndProcessLanguageContext( + const gatherLanguageContext = () => this.getAndProcessLanguageContext( request, delaySession, activeDocument, @@ -354,23 +447,31 @@ export class XtabProvider implements IStatelessNextEditProvider { cancellationToken, ); - if (cancellationToken.isCancellationRequested) { - return new NoNextEditReason.GotCancelled('afterLanguageContextAwait'); - } - - const neighborSnippets = promptOptions.neighborFiles.enabled - ? await raceCancellation( + const gatherNeighborSnippets = () => promptOptions.neighborFiles.enabled + ? raceCancellation( raceTimeout( this.similarFilesContextService.getSnippetsForPrompt(activeDocument.id.uri, activeDocument.languageId, activeDocument.documentAfterEdits.value, currentDocument.cursorOffset), delaySession.getDebounceTime() ), cancellationToken, ) - : undefined; + : Promise.resolve(undefined); - if (cancellationToken.isCancellationRequested) { - return new NoNextEditReason.GotCancelled('afterNeighborSnippetsAwait'); + const contextResult = await this.gatherContextAndClipCurrentFile( + globalBudget, + activeDocument, + request, + promptOptions, + telemetry, + cancellationToken, + gatherLanguageContext, + gatherNeighborSnippets, + clipCurrentFileToBudget, + ); + if (contextResult.isError()) { + return contextResult.err; } + const { clippedTaggedCurrentDoc, areaAroundCodeToEdit, precomputedCascade, langCtx, neighborSnippets } = contextResult.val; const lintErrors = new LintErrors(activeDocument.id, currentDocument, this.langDiagService, request.xtabEditHistory); @@ -388,12 +489,12 @@ export class XtabProvider implements IStatelessNextEditProvider { XtabProvider.computeTokens, promptOptions, neighborSnippets, + precomputedCascade, ); - const { prompt: userPrompt, nDiffsInPrompt, diffTokensInPrompt, neighborSnippetsResult } = getUserPrompt(promptPieces); + const { prompt: userPrompt, nDiffsInPrompt, neighborSnippetsResult, sectionTokens } = getUserPrompt(promptPieces); telemetry.setNDiffsInPrompt(nDiffsInPrompt); - telemetry.setDiffTokensInPrompt(diffTokensInPrompt); if (neighborSnippetsResult) { telemetry.setNNeighborSnippetsComputed(neighborSnippetsResult.nComputed); telemetry.setNNeighborSnippetsInPrompt(neighborSnippetsResult.nIncluded); @@ -404,14 +505,22 @@ export class XtabProvider implements IStatelessNextEditProvider { const prediction = this.getPredictedOutput(activeDocument, currentDocument.cursorLineOffset, editWindowLines, cursorLineInEditWindowOffset, responseFormat); + const systemMsg = pickSystemPrompt(promptOptions.promptingStrategy); const messages = constructMessages({ - systemMsg: pickSystemPrompt(promptOptions.promptingStrategy), + systemMsg, userMsg: userPrompt, }); logContext.setPrompt(messages); telemetry.setPrompt(messages); + // Report approximate (char/4) per-section token counts, filling in the + // system-prompt count which getUserPrompt cannot know. Do this BEFORE the + // HARD_CHAR_LIMIT early-return so oversized prompts still report counts. + const promptSectionTokens = { ...sectionTokens, systemPrompt: XtabProvider.computeTokens(systemMsg) }; + telemetry.setPromptSectionTokens(promptSectionTokens); + logContext.setPromptSectionTokens(promptSectionTokens); + const HARD_CHAR_LIMIT = 30000 * 4; // 30K tokens, assuming 4 chars per token -- we use approximation here because counting tokens exactly is time-consuming const promptCharCount = charCount(messages); if (promptCharCount > HARD_CHAR_LIMIT) { @@ -944,6 +1053,7 @@ export class XtabProvider implements IStatelessNextEditProvider { const pseudoEditWindow = currentDocument.transformer.getOffsetRange(new Range(clippedTaggedCurrentDoc.keptRange.start + 1, 1, clippedTaggedCurrentDoc.keptRange.endExclusive, lastLineLength + 1)); const duplicateAdditionsMode = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabDuplicateAdditionsMode, this.expService); const fastYieldLineWithCursor = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderPatchFastYieldLineWithCursor, this.expService); + const fastYieldLineWithCursorMultiLine = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabProviderPatchFastYieldLineWithCursorMultiLine, this.expService); const splitPatchOnDiff = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabSplitPatchOnDiff, this.expService); parseResult = new ResponseParseResult.DirectEdits( XtabPatchResponseHandler.handleResponse( @@ -956,6 +1066,7 @@ export class XtabProvider implements IStatelessNextEditProvider { duplicateAdditionsMode, fastYieldLineWithCursor, splitPatchOnDiff, + fastYieldLineWithCursorMultiLine, ), ); break; @@ -1456,13 +1567,7 @@ export class XtabProvider implements IStatelessNextEditProvider { }, lintOptions: undefined, includePostScript: true, - globalBudget: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetEnabled, this.expService) - ? { - totalTokens: this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudgetTotalTokens, this.expService), - order: xtabPromptOptions.GlobalBudgetOptions.DEFAULT_ORDER, - shares: xtabPromptOptions.GlobalBudgetOptions.DEFAULT_SHARES, - } - : undefined, + globalBudget: this.getGlobalBudget(), }; const selectedModelConfig = this.modelService.selectedModelConfiguration(); @@ -1473,6 +1578,36 @@ export class XtabProvider implements IStatelessNextEditProvider { }; } + /** + * Resolve the opt-in global budget from its single experiment-driven JSON + * config string (mirrors `modelConfigurationString`). Returns `undefined` + * — disabling the global budget, identical to prod — when the string is + * unset, empty, or fails to parse/validate. Parse failures are reported via + * telemetry so misconfigured experiments are observable. + */ + private getGlobalBudget(): xtabPromptOptions.GlobalBudgetOptions | undefined { + const configString = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, this.expService); + if (!configString) { + return undefined; + } + + const result = xtabPromptOptions.GlobalBudgetOptions.fromConfigString(configString); + if (result.isError()) { + /* __GDPR__ + "incorrectNesGlobalBudgetConfig" : { + "owner": "ulugbekna", + "comment": "Capture if the experiment-driven NES global budget config string is invalid or malformed, so the global budget was disabled.", + "errorMessage": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Error message from parsing or validation." }, + "configValue": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The invalid config string so the bad experiment value can be identified." } + } + */ + this._telemetryService.sendMSFTTelemetryEvent('incorrectNesGlobalBudgetConfig', { errorMessage: result.err, configValue: configString }); + return undefined; + } + + return result.val; + } + private getEndpointWithLogging(configuredModelName: string | undefined, logContext: InlineEditRequestLogContext, telemetry: StatelessNextEditTelemetryBuilder): ChatEndpoint { const endpoint = this.getEndpoint(configuredModelName); logContext.setEndpointInfo(typeof endpoint.urlOrRequestMetadata === 'string' ? endpoint.urlOrRequestMetadata : JSON.stringify(endpoint.urlOrRequestMetadata.type), endpoint.model); diff --git a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts index ccae96a0a982bb..a8788a1ee6f4d9 100644 --- a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts @@ -8,6 +8,9 @@ import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/do import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; import { AggressivenessLevel, CurrentFileOptions, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, PromptingStrategy, PromptOptions } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { LanguageContextResponse } from '../../../../platform/inlineEdits/common/dataTypes/languageContext'; +import { PromptSectionTokenCounts } from '../../../../platform/inlineEdits/common/dataTypes/promptSectionTokens'; +import { ContextKind } from '../../../../platform/languageServer/common/languageContextService'; import { StatelessNextEditDocument } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; import { TestLanguageDiagnosticsService } from '../../../../platform/languages/common/testLanguageDiagnosticsService'; import { Result } from '../../../../util/common/result'; @@ -16,8 +19,9 @@ import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringE import { Position } from '../../../../util/vs/editor/common/core/position'; import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../../../util/vs/editor/common/core/text/abstractText'; +import { Uri } from '../../../../vscodeTypes'; import { LintErrors } from '../../common/lintErrors'; -import { constructTaggedFile, createTaggedCurrentFileContentUsingPagedClipping, expandRangeToPageRange, getUserPrompt, PromptPieces } from '../../common/promptCrafting'; +import { constructTaggedFile, createTaggedCurrentFileContentUsingPagedClipping, expandRangeToPageRange, getUserPrompt, PromptPieces, runGlobalBudgetCascade } from '../../common/promptCrafting'; import { PromptTags } from '../../common/tags'; import { CurrentDocument } from '../../common/xtabCurrentDocument'; @@ -818,6 +822,71 @@ describe('getUserPrompt', () => { expect(prompt).toContain('<|aggressive|>medium<|/aggressive|>'); }); }); + + describe('sectionTokens', () => { + + const sectionsSum = (t: PromptSectionTokenCounts): number => + t.recentlyViewed + t.currentFile + t.lintErrors + t.editHistory + + t.areaAroundCodeToEdit + t.cursorLocation + t.relatedInformation + t.postScript; + + test('total matches the assembled prompt, counts reconcile, and systemPrompt is left at 0 across strategies', () => { + const summary = [PromptingStrategy.PatchBased01, PromptingStrategy.PatchBased02, undefined].map(strategy => { + const { prompt, sectionTokens } = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy })); + return { + totalMatchesPrompt: sectionTokens.userPromptTotal === computeTokens(prompt), + reconciles: sectionsSum(sectionTokens) + sectionTokens.overhead === sectionTokens.userPromptTotal, + systemPromptZero: sectionTokens.systemPrompt === 0, + }; + }); + + assert.deepStrictEqual(summary, [ + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + { totalMatchesPrompt: true, reconciles: true, systemPromptZero: true }, + ]); + }); + + test('reports the strategy-dependent tail section (area vs cursor) mutually exclusively', () => { + const tailShape = (strategy: PromptingStrategy | undefined) => { + const { sectionTokens } = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy })); + return { areaPresent: sectionTokens.areaAroundCodeToEdit > 0, cursorPresent: sectionTokens.cursorLocation > 0 }; + }; + + assert.deepStrictEqual( + { + patchBased01: tailShape(PromptingStrategy.PatchBased01), + patchBased02: tailShape(PromptingStrategy.PatchBased02), + default: tailShape(undefined), + }, + { + patchBased01: { areaPresent: false, cursorPresent: false }, + patchBased02: { areaPresent: false, cursorPresent: true }, + default: { areaPresent: true, cursorPresent: false }, + }, + ); + }); + + test('reports 0 for absent optional sections and non-zero for present ones', () => { + // Fixture has no language context (relatedInformation empty) and always renders a current file. + const withPostScript = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy: PromptingStrategy.PatchBased02 })).sectionTokens; + const withoutPostScript = getUserPrompt(createTestPromptPieces({ cursorLine: 2, cursorColumn: 9, strategy: PromptingStrategy.PatchBased02, includePostScript: false })).sectionTokens; + + assert.deepStrictEqual( + { + relatedInformation: withPostScript.relatedInformation, + currentFilePresent: withPostScript.currentFile > 0, + postScriptPresent: withPostScript.postScript > 0, + postScriptAbsent: withoutPostScript.postScript, + }, + { + relatedInformation: 0, + currentFilePresent: true, + postScriptPresent: true, + postScriptAbsent: 0, + }, + ); + }); + }); }); describe('getUserPrompt — globalBudget cascade', () => { @@ -839,7 +908,7 @@ describe('getUserPrompt — globalBudget cascade', () => { return { activeDoc, currentDocument, currentDocLines }; } - function makePieces(globalBudget: PromptOptions['globalBudget']): PromptPieces { + function makePieces(globalBudget: PromptOptions['globalBudget'], extra?: { langCtx?: LanguageContextResponse; precomputedCascade?: ReturnType }): PromptPieces { const { activeDoc, currentDocument, currentDocLines } = makeActiveDoc(); const promptOptions: PromptOptions = { ...DEFAULT_OPTIONS, @@ -854,14 +923,28 @@ describe('getUserPrompt — globalBudget cascade', () => { [], currentDocLines, 'some code', - undefined, + extra?.langCtx, AggressivenessLevel.Medium, new LintErrors(activeDoc.id, currentDocument, new TestLanguageDiagnosticsService()), s => Math.ceil(s.length / 4), promptOptions, + undefined, + extra?.precomputedCascade, ); } + function makeLangCtxWithSnippet(value: string): LanguageContextResponse { + return { + start: 0, + end: 0, + items: [{ + context: { kind: ContextKind.Snippet, priority: 1, uri: Uri.parse('file:///test/ctx.ts'), value }, + timeStamp: 0, + onTimeout: false, + }], + }; + } + test('produces the same prompt as legacy path when budgets are large', () => { const legacy = getUserPrompt(makePieces(undefined)); const cascaded = getUserPrompt(makePieces({ @@ -898,6 +981,7 @@ describe('getUserPrompt — globalBudget cascade', () => { totalTokens: 1000, order: GlobalBudgetOptions.DEFAULT_ORDER, shares: { + currentFile: 0.5, recentlyViewedDocuments: 0.5, languageContext: 0.5, neighborFiles: 0.5, @@ -913,11 +997,110 @@ describe('getUserPrompt — globalBudget cascade', () => { order: GlobalBudgetOptions.DEFAULT_ORDER, // missing 'diffHistory' shares: { + currentFile: 0.2, languageContext: 0.4, - recentlyViewedDocuments: 0.4, + recentlyViewedDocuments: 0.2, neighborFiles: 0.2, - } as Record<'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, + } as Record<'currentFile' | 'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, }); expect(() => getUserPrompt(pieces)).toThrow(/shares is missing entry for 'diffHistory'/); }); + + test('throws when shares is missing an entry for currentFile', () => { + const pieces = makePieces({ + totalTokens: 1000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + // missing 'currentFile' + shares: { + languageContext: 0.25, + recentlyViewedDocuments: 0.25, + neighborFiles: 0.25, + diffHistory: 0.25, + } as Record<'currentFile' | 'languageContext' | 'recentlyViewedDocuments' | 'neighborFiles' | 'diffHistory', number>, + }); + expect(() => getUserPrompt(pieces)).toThrow(/shares is missing entry for 'currentFile'/); + }); + + function runCascade(globalBudget: GlobalBudgetOptions, extra?: { langCtx?: LanguageContextResponse }) { + const { activeDoc } = makeActiveDoc(); + const opts: PromptOptions = { ...DEFAULT_OPTIONS, globalBudget }; + return runGlobalBudgetCascade(activeDoc, [], extra?.langCtx, s => Math.ceil(s.length / 4), opts, undefined, globalBudget); + } + + test('finalSurplus carries the full unused pool when no cascade part consumes budget', () => { + // No langCtx, empty history and neighbors disabled ⇒ every cascade part + // consumes 0, so the entire non-currentFile pool carries to finalSurplus. + // DEFAULT_TOTAL_TOKENS (7500) − currentFileBudget (1500) = 6000. This is + // exactly the budget the provider adds to the current file's clip + // (currentFileBudget 1500 + finalSurplus 6000 = 7500 = T). + const cascade = runCascade({ + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + expect(cascade.finalSurplus).toBe(6000); + }); + + test('finalSurplus shrinks by what the cascade consumes, so the current file reuses less leftover', () => { + const globalBudget: GlobalBudgetOptions = { + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + // Empty cascade ⇒ the whole non-currentFile pool (6000) carries to finalSurplus. + const empty = runCascade(globalBudget); + // A rendered language-context snippet consumes budget from the pool, so less + // leftover carries to finalSurplus. The provider clips the current file last to + // currentFileBudget + finalSurplus, so a smaller finalSurplus ⇒ the current file + // reuses less leftover (it is trimmed more) once other parts have content. + const consuming = runCascade(globalBudget, { langCtx: makeLangCtxWithSnippet('const ctxMarker = 1;\n'.repeat(40)) }); + expect(consuming.finalSurplus).toBeLessThan(empty.finalSurplus); + }); + + test('precomputedCascade produces an identical prompt to computing the cascade internally', () => { + const snippet = 'const sharedCtxMarker = 42;'; + const globalBudget: PromptOptions['globalBudget'] = { + totalTokens: 100000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + const internal = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) })); + + const cascade = runCascade(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) }); + const precomputed = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet), precomputedCascade: cascade })); + + expect(precomputed.prompt).toBe(internal.prompt); + expect(precomputed.nDiffsInPrompt).toBe(internal.nDiffsInPrompt); + }); + + test('reports the recently-viewed subsection token breakdown, matching the legacy path', () => { + // Empty history and disabled neighbor files leave the recently-viewed-files + // and neighbor-files subsections at 0; the language-context snippet populates + // the language-context subsection. Large budgets keep the two assembly paths + // (cascade vs legacy `getRecentCodeSnippets`) byte-identical, so their + // subsection counts must match. + const snippet = 'const sharedCtxMarker = 42;'; + const globalBudget: PromptOptions['globalBudget'] = { + totalTokens: 100000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }; + const cascaded = getUserPrompt(makePieces(globalBudget, { langCtx: makeLangCtxWithSnippet(snippet) })).sectionTokens.recentlyViewedSubsections; + const legacy = getUserPrompt(makePieces(undefined, { langCtx: makeLangCtxWithSnippet(snippet) })).sectionTokens.recentlyViewedSubsections; + + assert.deepStrictEqual( + { + recentlyViewedFilesZero: cascaded.recentlyViewedFiles === 0, + languageContextPopulated: cascaded.languageContext > 0, + neighborFilesZero: cascaded.neighborFiles === 0, + legacyEqualsCascade: JSON.stringify(legacy) === JSON.stringify(cascaded), + }, + { + recentlyViewedFilesZero: true, + languageContextPopulated: true, + neighborFilesZero: true, + legacyEqualsCascade: true, + }, + ); + }); }); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts index 44b190c86cdffa..3eddd1dc2fc364 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabPatchResponseHandler.spec.ts @@ -762,9 +762,9 @@ another_file.js: describe('progressive ghost-text reveal via extractEdits', () => { - async function collectPatchesWithCursor(patchText: string, cursorLineZeroBased: number, activeDocPath: string = 'file.py'): Promise { + async function collectPatchesWithCursor(patchText: string, cursorLineZeroBased: number, activeDocPath: string = 'file.py', allowMultiLineRemoval: boolean = false): Promise { const linesStream = AsyncIterUtils.fromArray(patchText.split('\n')); - const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream, cursorLineZeroBased, activeDocPath)); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream, cursorLineZeroBased, activeDocPath, allowMultiLineRemoval)); return patches.map(p => p.toString()).join('\n'); } @@ -970,6 +970,198 @@ another_file.js: }); }); + describe('progressive ghost-text reveal (multi-line) via extractEdits', () => { + + async function collectMultiLine(patchText: string, cursorLineZeroBased: number, activeDocPath: string = 'file.py'): Promise { + const linesStream = AsyncIterUtils.fromArray(patchText.split('\n')); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream, cursorLineZeroBased, activeDocPath, /* allowMultiLineRemoval */ true)); + return patches.map(p => p.toString()).join('\n'); + } + + it('splits a multi-removed-line patch into cursor-line replacement + continuation replacement', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineB_changed', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '+lineA_extended', + 'file.py:5', + '-lineB', + '+lineB_changed', + ].join('\n')); + }); + + it('does not split a multi-removed-line patch when allowMultiLineRemoval is off', async () => { + // Same input, but with allowMultiLineRemoval defaulting to false. + const linesStream = AsyncIterUtils.fromArray([ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineB_changed', + ]); + const patches = await AsyncIterUtils.toArray(XtabPatchResponseHandler.extractEdits(linesStream, 4, 'file.py')); + const result = patches.map(p => p.toString()).join('\n'); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineB_changed', + ].join('\n')); + }); + + it('continuation is a deletion when fewer added than removed lines', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '+lineA_extended', + 'file.py:5', + '-lineB', + ].join('\n')); + }); + + it('continuation replaces one line with many when more added than removed lines', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineB1', + '+lineB2', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '+lineA_extended', + 'file.py:5', + '-lineB', + '+lineB1', + '+lineB2', + ].join('\n')); + }); + + it('splits a single-removed-line patch identically when allowMultiLineRemoval is on', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-lineA', + '+lineA_extended', + '+lineB', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '+lineA_extended', + 'file.py:5', + '+lineB', + ].join('\n')); + }); + + it('does not split when the first line edit is not additive', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-hello world', + '-lineB', + '+goodbye', + '+lineC', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-hello world', + '-lineB', + '+goodbye', + '+lineC', + ].join('\n')); + }); + + it('does not split when the cursor line does not match the patch line', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineC', + ].join('\n'), + 10, + ); + expect(result).toEqual([ + 'file.py:4', + '-lineA', + '-lineB', + '+lineA_extended', + '+lineC', + ].join('\n')); + }); + + it('does not split an empty cursor line that merely absorbs the next line', async () => { + // removed[0] is blank and the first added line equals removed[1] (the line just + // below the cursor): the model is deleting the empty line, not additively editing it. + const result = await collectMultiLine( + [ + 'file.py:4', + '-', + '-foo()', + '+foo()', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-', + '-foo()', + '+foo()', + ].join('\n')); + }); + + it('splits an empty cursor line when the next line is genuinely different', async () => { + const result = await collectMultiLine( + [ + 'file.py:4', + '-', + '-bar()', + '+foo()', + '+baz()', + ].join('\n'), + 4, + ); + expect(result).toEqual([ + 'file.py:4', + '-', + '+foo()', + 'file.py:5', + '-bar()', + '+baz()', + ].join('\n')); + }); + }); + describe('handleResponse with enableProgressiveGhostText', () => { it('yields two edits for ghost-text when progressive reveal is enabled', async () => { @@ -1088,6 +1280,152 @@ another_file.js: }); }); + describe('handleResponse with enableProgressiveGhostTextMultiLine', () => { + + // docContent lines (1-based): 1 "function foo() {", 2 " const a = 1;", 3 " doStuff(a);", 4 "}" + const docContent = 'function foo() {\n const a = 1;\n doStuff(a);\n}\n'; + // Cursor on line 2 (1-based) → cursorLineOffset = 1 (0-based), the "const a = 1;" line. + function makeDoc(): CurrentDocument { + return new CurrentDocument(new StringText(docContent), new Position(2, 5)); + } + + async function* multiLineStream(): AsyncGenerator { + yield '/test.ts:1'; + yield '- const a = 1;'; + yield '- doStuff(a);'; + yield '+ const a = 1, b = 2;'; + yield '+ doStuff(a, b);'; + } + + it('yields two edits (cursor-line replacement + continuation replacement) when multi-line reveal is enabled', async () => { + const { edits } = await consumeHandleResponse( + multiLineStream(), + makeDoc(), + DocumentId.create('file:///test.ts'), + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + true, // enableProgressiveGhostText + false, // splitPatchOnDiff + true, // enableProgressiveGhostTextMultiLine + ); + + expect(edits).toHaveLength(2); + // First edit: cursor-line replacement (additive) + expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 3), [' const a = 1, b = 2;'])); + // Second edit: continuation replacement of the line below the cursor + expect(edits[1].edit).toEqual(new LineReplacement(new LineRange(3, 4), [' doStuff(a, b);'])); + }); + + it('yields a single unsplit edit for a multi-removed-line patch when multi-line reveal is disabled (default)', async () => { + const { edits } = await consumeHandleResponse( + multiLineStream(), + makeDoc(), + DocumentId.create('file:///test.ts'), + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.Off, + true, // enableProgressiveGhostText (single-line reveal only) + ); + + expect(edits).toHaveLength(1); + expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 4), [' const a = 1, b = 2;', ' doStuff(a, b);'])); + }); + + it('falls back to a single unsplit edit under DropPatch (unsafe dedup mode)', async () => { + // DropPatch could skip a multi-line continuation *after* the early edit is emitted, + // leaving stale lines below the cursor — so multi-line reveal is disabled for it and + // the whole patch is yielded as one edit instead. + const { edits } = await consumeHandleResponse( + multiLineStream(), + makeDoc(), + DocumentId.create('file:///test.ts'), + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.DropPatch, + true, // enableProgressiveGhostText + false, // splitPatchOnDiff + true, // enableProgressiveGhostTextMultiLine + ); + + expect(edits).toHaveLength(1); + expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 4), [' const a = 1, b = 2;', ' doStuff(a, b);'])); + }); + + it('stays active under TrimDuplicate and keeps the continuation removal while trimming duplicate additions', async () => { + // Document has a trailing " return a;" the model re-emits as an addition. + const docWithReturn = 'function foo() {\n const a = 1;\n doStuff(a);\n return a;\n}\n'; + const doc = new CurrentDocument(new StringText(docWithReturn), new Position(2, 5)); + + async function* stream(): AsyncGenerator { + yield '/test.ts:1'; + yield '- const a = 1;'; + yield '- doStuff(a);'; + yield '+ const a = 1, b = 2;'; + yield '+ doStuff(a, b);'; + yield '+ return a;'; // duplicates the existing line 4 + } + + const { edits } = await consumeHandleResponse( + stream(), + doc, + DocumentId.create('file:///test.ts'), + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.TrimDuplicate, + true, // enableProgressiveGhostText + false, // splitPatchOnDiff + true, // enableProgressiveGhostTextMultiLine + ); + + // Early edit kept; continuation still removes line 3 but drops the duplicated "return a;". + expect(edits).toHaveLength(2); + expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 3), [' const a = 1, b = 2;'])); + expect(edits[1].edit).toEqual(new LineReplacement(new LineRange(3, 4), [' doStuff(a, b);'])); + }); + + it('keeps the early cursor-line edit under TrimDuplicate when its added line matches the next removed line (cascade)', async () => { + // Cascade edit: the cursor line is completed to match the line below it, which the same + // patch then extends further. Here `added[0]` (" log(x, y)") equals `removed[1]` + // (" log(x, y)"), the original line just below the cursor. Since the early patch's + // dedup "following" window is that same line, an unguarded TrimDuplicate would trim the + // early patch's only added line to empty and emit a cursor-line *deletion*, dropping the + // intended change. The early edit must be preserved verbatim. + const docContent = 'f();\n log(x)\n log(x, y)\nz\n'; + const doc = new CurrentDocument(new StringText(docContent), new Position(2, 5)); + + async function* stream(): AsyncGenerator { + yield '/test.ts:1'; + yield '- log(x)'; + yield '- log(x, y)'; + yield '+ log(x, y)'; // equals removed[1] (the line below the cursor) + yield '+ log(x, y, z)'; + } + + const { edits } = await consumeHandleResponse( + stream(), + doc, + DocumentId.create('file:///test.ts'), + undefined, + undefined, + new TestLogService(), + DuplicateAdditionsMode.TrimDuplicate, + true, // enableProgressiveGhostText + false, // splitPatchOnDiff + true, // enableProgressiveGhostTextMultiLine + ); + + // Early edit is a replacement (not a deletion); continuation replaces the line below. + expect(edits).toHaveLength(2); + expect(edits[0].edit).toEqual(new LineReplacement(new LineRange(2, 3), [' log(x, y)'])); + expect(edits[1].edit).toEqual(new LineReplacement(new LineRange(3, 4), [' log(x, y, z)'])); + }); + }); + describe('splitReplacement', () => { it('splits a multi-hunk replacement into one replacement per changed region', () => { diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index 47dd5c519fefa3..82acfbff683813 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -42,6 +42,7 @@ import { DelaySession } from '../../../inlineEdits/common/delay'; import { createExtensionUnitTestingServices } from '../../../test/node/services'; import { N_LINES_AS_CONTEXT } from '../../common/promptCrafting'; import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifiedModelSystemPrompt, xtab275SystemPrompt } from '../../common/systemMessages'; +import { PromptTags } from '../../common/tags'; import { CurrentDocument } from '../../common/xtabCurrentDocument'; import { computeAreaAroundEditWindowLinesRange, @@ -1138,6 +1139,69 @@ describe('XtabProvider integration', () => { // Group 4: Filter Pipeline // ======================================================================== + describe('global budget', () => { + + /** Drives the provider once and returns the captured user-message text. */ + async function captureUserPrompt(provider: XtabProvider, request: StatelessNextEditRequest): Promise { + streamingFetcher.setStreamingLines(['x']); + const capturesBefore = streamingFetcher.capturedOptions.length; + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + await AsyncIterUtils.drainUntilReturn(gen); + // Guard against silently comparing a stale capture: the run must have fetched. + expect(streamingFetcher.capturedOptions.length).toBeGreaterThan(capturesBefore); + const messages = streamingFetcher.capturedOptions.at(-1)?.messages; + const userMessage = messages?.find(m => m.role === Raw.ChatRole.User); + expect(userMessage).toBeDefined(); + return getMessageText(userMessage!); + } + + /** Number of lines in the `<|current_file_content|>` region of the prompt. */ + function currentFileRegionLineCount(prompt: string): number { + const start = prompt.indexOf(PromptTags.CURRENT_FILE.start); + const end = prompt.indexOf(PromptTags.CURRENT_FILE.end); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return prompt.slice(start, end).split('\n').length; + } + + const bigFile = Array.from({ length: 400 }, (_, i) => `const value${i} = ${i};`); + + // Under a global budget the current file is clipped LAST, so it absorbs whatever + // budget the cascade parts leave unused. With the (here empty) cascade the + // current file therefore reuses essentially the whole pool and keeps strictly + // MORE of the file than the legacy path, which caps it at its own + // currentFile.maxTokens (1500) and trims the tail. + it('absorbs leftover cascade budget so it keeps more of the current file than the legacy cap', async () => { + const legacy = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, '{}'); + const enabledAtDefault = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + // Legacy caps the current file at 2000 tokens → the tail is trimmed. + expect(legacy).not.toContain('const value399 = 399;'); + // Clip-last lets the current file reuse the whole pool → the entire file fits. + expect(enabledAtDefault).toContain('const value399 = 399;'); + expect(currentFileRegionLineCount(legacy)).toBeLessThan(currentFileRegionLineCount(enabledAtDefault)); + }); + + // New behavior: because the current file is sized to its share PLUS the cascade + // leftover (≈ the whole pool when the cascade is empty), a larger total budget + // keeps more of the file. A small pool still trims the tail; a generous pool + // fits the entire file. + it('keeps more of the current file as the total budget grows', async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, JSON.stringify({ totalTokens: 2000 })); + const smallBudget = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabGlobalBudget, JSON.stringify({ totalTokens: 8000 })); + const wideBudget = await captureUserPrompt(createProvider(), createRequestWithEdit(bigFile, { insertionOffset: 3, insertedText: 'a' })); + + // Small pool trims the tail; wide pool fits the whole file. + expect(smallBudget).not.toContain('const value399 = 399;'); + expect(wideBudget).toContain('const value399 = 399;'); + expect(currentFileRegionLineCount(smallBudget)).toBeLessThan(currentFileRegionLineCount(wideBudget)); + }); + }); + describe('filter pipeline', () => { it('filters out import-only changes', async () => { const provider = createProvider(); diff --git a/extensions/copilot/src/lib/node/chatLibMain.ts b/extensions/copilot/src/lib/node/chatLibMain.ts index 905ddfad132c7e..d845f450a1f2eb 100644 --- a/extensions/copilot/src/lib/node/chatLibMain.ts +++ b/extensions/copilot/src/lib/node/chatLibMain.ts @@ -184,6 +184,18 @@ export interface INESProviderOptions { readonly terminalService: ITerminalService; readonly telemetrySender: ITelemetrySender; readonly logTarget?: ILogTarget; + /** + * Identifies the host editor (e.g. `{ name: 'vscode', version: '1.99.0' }`). + * Together with {@link editorPluginInfo} this sets the `Editor-Version` and + * `Editor-Plugin-Version` headers on outgoing requests (including the model + * list fetch) so the backend can identify the caller. + */ + readonly editorInfo: IEditorInfo; + /** + * Identifies the plugin/integration embedding the provider (e.g. + * `{ name: 'copilot-chat', version: '0.1.0' }`). See {@link editorInfo}. + */ + readonly editorPluginInfo: IEditorPluginInfo; /** * If true, the provider will wait for treatment variables to be set. * INESProvider.updateTreatmentVariables() must be called to unblock. @@ -386,7 +398,7 @@ class NESProvider extends Disposable implements INESProvider { } function setupServices(options: INESProviderOptions) { - const { fetcher, copilotTokenManager, telemetrySender, logTarget } = options; + const { fetcher, copilotTokenManager, telemetrySender, logTarget, editorInfo, editorPluginInfo } = options; const builder = new InstantiationServiceBuilder(); builder.define(IConfigurationService, new SyncDescriptor(OverridableConfigurationService, [options.configOverrides ?? new Map()])); builder.define(IExperimentationService, new SyncDescriptor(SimpleExperimentationService, [options.waitForTreatmentVariables])); @@ -402,7 +414,14 @@ function setupServices(options: INESProviderOptions) { builder.define(IDomainService, new SyncDescriptor(DomainService)); builder.define(ICAPIClientService, new SyncDescriptor(CAPIClientImpl)); builder.define(ICopilotTokenStore, new SyncDescriptor(CopilotTokenStore)); - builder.define(IEnvService, new SyncDescriptor(NullEnvService)); + builder.define(IEnvService, new class extends NullEnvService { + override getEditorInfo(): NameAndVersion { + return new NameAndVersion(editorInfo.name, editorInfo.version); + } + override getEditorPluginInfo(): NameAndVersion { + return new NameAndVersion(editorPluginInfo.name, editorPluginInfo.version); + } + }); builder.define(IFetcherService, new SyncDescriptor(SingleFetcherService, [fetcher])); builder.define(ITelemetryService, new SyncDescriptor(SimpleTelemetryService, [telemetrySender])); builder.define(IAuthenticationService, new SyncDescriptor(StaticGitHubAuthenticationService, [createStaticGitHubTokenProvider()])); diff --git a/extensions/copilot/src/lib/vscode-node/test/nesProvider.spec.ts b/extensions/copilot/src/lib/vscode-node/test/nesProvider.spec.ts index 2a3fa12a49e0bf..2a41ebd717d23c 100644 --- a/extensions/copilot/src/lib/vscode-node/test/nesProvider.spec.ts +++ b/extensions/copilot/src/lib/vscode-node/test/nesProvider.spec.ts @@ -160,6 +160,8 @@ describe('NESProvider Facade', () => { telemetrySender, terminalService, logTarget, + editorInfo: { name: 'my-editor', version: '1.2.3' }, + editorPluginInfo: { name: 'my-plugin', version: '4.5.6' }, }); nextEditProvider.updateTreatmentVariables({ 'config.github.copilot.chat.advanced.inlineEdits.xtabProvider.defaultModelConfigurationString': '{ "modelName": "xtab-test", "promptingStrategy": "copilotNesXtab", "includeTagsInCurrentFile": false }', @@ -173,6 +175,15 @@ describe('NESProvider Facade', () => { assert.ok(fetcher.requests[0].url.endsWith('/models'), `Unexpected URL: ${fetcher.requests[0].url}`); assert.ok(fetcher.requests[1].url.endsWith('/chat/completions'), `Unexpected URL: ${fetcher.requests[1].url}`); + // The model list request must carry the editor headers derived from the provided editor info. + assert.deepStrictEqual({ + 'Editor-Version': fetcher.requests[0].options.headers?.['Editor-Version'], + 'Editor-Plugin-Version': fetcher.requests[0].options.headers?.['Editor-Plugin-Version'], + }, { + 'Editor-Version': 'my-editor/1.2.3', + 'Editor-Plugin-Version': 'my-plugin/4.5.6', + }); + assert(fetcher.requests[1].options.json); assert(typeof fetcher.requests[1].options.json === 'object'); assert('model' in fetcher.requests[1].options.json); @@ -251,6 +262,8 @@ describe('NESProvider Facade', () => { terminalService: new NullTerminalService(), logTarget: new TestLogTarget(), languageDiagnosticsService: diagnosticsService, + editorInfo: { name: 'my-editor', version: '1.2.3' }, + editorPluginInfo: { name: 'my-plugin', version: '4.5.6' }, }); nextEditProvider.updateTreatmentVariables({ diff --git a/extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts b/extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts index 7e4c525dcb81f2..2d915cf4b48fb2 100644 --- a/extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts +++ b/extensions/copilot/src/platform/authentication/test/node/simulationTestCopilotTokenManager.ts @@ -6,6 +6,7 @@ import { BugIndicatingError } from '../../../../util/vs/base/common/errors'; import { Emitter, Event, Relay } from '../../../../util/vs/base/common/event'; import { safeStringify } from '../../../../util/vs/base/common/objects'; +import { getEditorVersionHeaders } from '../../../env/common/envService'; import { NullEnvService } from '../../../env/common/nullEnvService'; import { CopilotToken, createTestExtendedTokenInfo, ExtendedTokenInfo, TokenEnvelope } from '../../common/copilotToken'; import { ICopilotTokenManager, nowSeconds } from '../../common/copilotTokenManager'; @@ -73,7 +74,7 @@ class SimulationTestCopilotTokenManagerFromGitHubToken { { headers: { Authorization: `token ${this._githubToken}`, - ...NullEnvService.Instance.getEditorVersionHeaders(), + ...getEditorVersionHeaders(NullEnvService.Instance), } } ); diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 3efa1006c2f6f3..7e9f66b0530daf 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -812,6 +812,7 @@ export namespace ConfigKey { export const InlineEditsXtabProviderUsePrediction = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.usePrediction', ConfigType.ExperimentBased, true, vBoolean()); export const InlineEditsXtabProviderPatchModelPredictionKind = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.patchModelPredictionKind', ConfigType.ExperimentBased, xtabPromptOptions.PatchModelPrediction.FilePath, xtabPromptOptions.PatchModelPrediction.VALIDATOR); export const InlineEditsXtabProviderPatchFastYieldLineWithCursor = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.patchFastYieldLineWithCursor', ConfigType.ExperimentBased, true, vBoolean()); + export const InlineEditsXtabProviderPatchFastYieldLineWithCursorMultiLine = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.patchFastYieldLineWithCursorMultiLine', ConfigType.ExperimentBased, false, vBoolean()); export const InlineEditsXtabLanguageContextEnabledLanguages = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabledLanguages', ConfigType.Simple, LANGUAGE_CONTEXT_ENABLED_LANGUAGES); export const InlineEditsXtabLanguageContextTraitsPosition = defineTeamInternalSetting<'before' | 'after'>('chat.advanced.inlineEdits.xtabProvider.languageContext.traitsPosition', ConfigType.ExperimentBased, 'before'); export const InlineEditsDiagnosticsExplorationEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.inlineEditsDiagnosticsExplorationEnabled', ConfigType.Simple, false); @@ -893,8 +894,7 @@ export namespace ConfigKey { export const InlineEditsXtabLanguageContextMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.maxTokens); export const InlineEditsXtabIncludeNeighborFiles = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.enabled); export const InlineEditsXtabNeighborFilesMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.neighborFiles.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.neighborFiles.maxTokens); - export const InlineEditsXtabGlobalBudgetEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget.enabled', ConfigType.ExperimentBased, false); - export const InlineEditsXtabGlobalBudgetTotalTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget.totalTokens', ConfigType.ExperimentBased, xtabPromptOptions.GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS); + export const InlineEditsXtabGlobalBudget = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.globalBudget', ConfigType.ExperimentBased, undefined); export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined); export const InlineEditsXtabOnlyMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.onlyMergeConflictLines', ConfigType.ExperimentBased, false); export const InlineEditsXtabDuplicateAdditionsMode = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffPatch.duplicateAdditionsMode', ConfigType.ExperimentBased, DuplicateAdditionsMode.Off, DuplicateAdditionsMode.VALIDATOR); @@ -912,15 +912,6 @@ export namespace ConfigKey { export const InlineEditsJointCompletionsProviderTriggerChangeStrategy = defineTeamInternalSetting('chat.advanced.inlineEdits.jointCompletionsProvider.triggerChangeStrategy', ConfigType.ExperimentBased, JointCompletionsProviderTriggerChangeStrategy.NoTriggerOnCompletionsRequestInFlight); export const InstantApplyModelName = defineTeamInternalSetting('chat.advanced.instantApply.modelName', ConfigType.ExperimentBased, CHAT_MODEL.GPT4OPROXY); export const VerifyTextDocumentChanges = defineTeamInternalSetting('chat.advanced.inlineEdits.verifyTextDocumentChanges', ConfigType.ExperimentBased, false); - export const UseAutoModeRouting = defineTeamInternalSetting('chat.advanced.useAutoModeRouter', ConfigType.ExperimentBased, false); - /** Controls which `routing_method` value is sent to the auto-intent-service per request - * when `UseAutoModeRouting` is enabled. - * '' (empty/default) = omit `routing_method` and use the server default. - * 'binary' = binary classifier v1. - * 'hydra' = HYDRA multi-head capability matching. - * For experiments, this setting selects the routing method only when router usage is enabled; - * it does not by itself determine whether the router is called. */ - export const AutoModeRoutingMethod = defineTeamInternalSetting('chat.advanced.autoModeRoutingMethod', ConfigType.ExperimentBased, '', undefined, undefined, { experimentName: 'copilotchat.autoModeRoutingMethod' }); /** Inline Completions */ export const InlineCompletionsDefaultDiagnosticsOptions = defineTeamInternalSetting('chat.advanced.inlineCompletions.defaultDiagnosticsOptionsString', ConfigType.ExperimentBased, undefined); @@ -981,18 +972,18 @@ export namespace ConfigKey { export const UseAnthropicMessagesApi = defineSetting('chat.anthropic.useMessagesApi', ConfigType.ExperimentBased, true); /** Context editing mode for Anthropic Messages API. 'off' disables context editing. */ export const AnthropicContextEditingMode = defineSetting<'off' | 'clear-thinking' | 'clear-tooluse' | 'clear-both'>('chat.anthropic.contextEditing.mode', ConfigType.ExperimentBased, 'off'); - /** Configure reasoning summary style sent to Responses API */ - export const ResponsesApiReasoningSummary = defineSetting<'off' | 'detailed'>('chat.responsesApiReasoningSummary', ConfigType.ExperimentBased, 'detailed'); /** Enable context_management sent to Responses API */ export const ResponsesApiContextManagementEnabled = defineSetting('chat.responsesApiContextManagement.enabled', ConfigType.ExperimentBased, false); /** Enable client-side prompt_cache_key (conversationId:modelFamily) sent to Responses API */ export const ResponsesApiPromptCacheKeyEnabled = defineSetting('chat.responsesApi.promptCacheKey.enabled', ConfigType.ExperimentBased, false); - /** Enable persistent chain of thought for supported Responses API model families */ - export const ResponsesApiPersistentCoTEnabled = defineSetting('chat.responsesApi.persistentCoT.enabled', ConfigType.ExperimentBased, false); + /** Enable explicit prompt_cache_breakpoint markers sent to Responses API */ + export const ResponsesApiPromptCacheBreakpointEnabled = defineSetting('chat.responsesApi.promptCacheBreakpoint.enabled', ConfigType.ExperimentBased, false); /** Enable updated prompt for 5.3Codex model */ export const Updated53CodexPromptEnabled = defineSetting('chat.updated53CodexPrompt.enabled', ConfigType.ExperimentBased, true); - /** Enable updated prompt for Claude Opus 4.7 model */ - export const Claude47OpusPromptEnabled = defineSetting('chat.claude47OpusPrompt.enabled', ConfigType.ExperimentBased, false); + /** Enable updated prompt for Claude Opus 4.8 model */ + export const Claude48OpusPromptEnabled = defineSetting('chat.claude48OpusPrompt.enabled', ConfigType.ExperimentBased, false); + /** Enable updated prompt for Claude Sonnet 5 model */ + export const ClaudeSonnet5PromptEnabled = defineSetting('chat.claudeSonnet5Prompt.enabled', ConfigType.ExperimentBased, false); /** Enable get_changed_files tool for GPT-5.5 models */ export const EnableGpt55GetChangedFilesTool = defineSetting('chat.gpt55GetChangedFilesTool.enabled', ConfigType.ExperimentBased, true); /** Enable get_changed_files tool for Gemini 3 models */ @@ -1001,7 +992,7 @@ export namespace ConfigKey { export const EnableGemini3LowReasoningEffort = defineSetting('chat.gemini3LowReasoningEffort.enabled', ConfigType.ExperimentBased, false); /** Enable read_file tool for GPT-5.5 models */ export const EnableGpt55ReadFileTool = defineSetting('chat.gpt55ReadFileTool.enabled', ConfigType.ExperimentBased, true); - export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.ExperimentBased, true); + export const EnableChatImageUpload = defineSetting('chat.imageUpload.enabled', ConfigType.Simple, true); /** Enable Anthropic web search tool for BYOK Claude models */ export const AnthropicWebSearchToolEnabled = defineSetting('chat.anthropic.tools.websearch.enabled', ConfigType.ExperimentBased, false); /** Maximum number of web searches allowed per request */ @@ -1062,6 +1053,8 @@ export namespace ConfigKey { export const SummarizeAgentConversationHistory = defineSetting('chat.summarizeAgentConversationHistory.enabled', ConfigType.Simple, true); export const VirtualToolThreshold = defineSetting('chat.virtualTools.threshold', ConfigType.ExperimentBased, HARD_TOOL_LIMIT); export const CurrentEditorAgentContext = defineSetting('chat.agent.currentEditorContext.enabled', ConfigType.Simple, true); + // When enabled, models with a free long context window only show the long context option in the picker. Disabled (default) keeps the smaller option. Gates behavior introduced in microsoft/vscode#322950 and microsoft/vscode#323116. + export const PreferLongContext = defineSetting('chat.preferLongContext.enabled', ConfigType.Simple, false); /** BYOK */ export const AutoFixDiagnostics = defineSetting('chat.agent.autoFix', ConfigType.ExperimentBased, false); export const NotebookFollowCellExecution = defineSetting('chat.notebook.followCellExecution.enabled', ConfigType.Simple, false); @@ -1110,10 +1103,12 @@ export namespace ConfigKey { export const ViewImageToolEnabled = defineSetting('chat.tools.viewImage.enabled', ConfigType.ExperimentBased, true); /** Enable local session search index — tracks sessions locally and enables chronicle commands.*/ - export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, false); + export const LocalIndexEnabled = defineSetting('chat.localIndex.enabled', ConfigType.ExperimentBased, true); /** grep_search configs */ - export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'tag'); + export const GrepSearchOutputFormat = defineSetting<'grep' | 'tag'>('chat.tools.grepSearch.outputFormat', ConfigType.ExperimentBased, 'grep'); + export const GrepSearchDefaultMaxResults = defineSetting('chat.tools.grepSearch.defaultMaxResults', ConfigType.ExperimentBased, 20); + export const GrepSearchMaxResultsCap = defineSetting('chat.tools.grepSearch.maxResultsCap', ConfigType.ExperimentBased, 200); } export function getAllConfigKeys(): string[] { diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index fb725ff704d3ca..2acee2a253f212 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -38,6 +38,8 @@ const VSC_MODEL_HASHES_D = [ 'e82ff0e2d4e4bae1f012dc599d520f8d61becfc4762f3717577b270be199db92', ]; +const VSC_MODEL_HASHES_E: string[] = []; + // subset to allow replace string instead of apply patch. const VSC_MODEL_HASHES_EDIT_TOOL_SET = [ @@ -71,10 +73,6 @@ const HIDDEN_FAMILY_H_HASHES: string[] = [ '70fcded3f255d368e868cc807d8838a62108bfa5c86ce7d37966f58cda229e33', ]; -const HIDDEN_FAMILY_M_HASHES: string[] = [ - '0902565c0c0fe145633a1f246ae551acc0f621249ef050428eba357fbd4655ee', -]; - /** * Per-model capability override. Lets advanced users (and evals) alias an * unknown/preview model id to a known production family for capability @@ -156,9 +154,9 @@ export function isGpt55(model: LanguageModelChat | IChatEndpoint | string) { return family.startsWith('gpt-5.5') || HIDDEN_MODEL_B_HASHES.includes(h); } -export function isHiddenModelM(model: LanguageModelChat | IChatEndpoint | string) { - const family_hash = getCachedSha256Hash(typeof model === 'string' ? model : model.family); - return HIDDEN_FAMILY_M_HASHES.includes(family_hash); +export function isGpt56(model: LanguageModelChat | IChatEndpoint | string) { + const family = typeof model === 'string' ? model : model.family; + return family === 'gpt-5.6-sol' || family === 'gpt-5.6-terra' || family === 'gpt-5.6-luna'; } export function isGpt53Codex(model: LanguageModelChat | IChatEndpoint | string) { @@ -166,6 +164,19 @@ export function isGpt53Codex(model: LanguageModelChat | IChatEndpoint | string) return family.startsWith('gpt-5.3-codex'); } +export function isKimiFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { + const matches = (value: string): boolean => { + const normalized = value.toLowerCase(); + return normalized.includes('kimi-k2.6') || normalized.includes('kimi-k2.7-code'); + }; + + if (typeof model === 'string') { + return matches(model); + } + + return matches(model.family) || matches(getModelId(model)); +} + export function isVSCModelA(model: LanguageModelChat | IChatEndpoint) { const ID_hash = getCachedSha256Hash(getModelId(model)); @@ -197,6 +208,13 @@ export function isVSCModelD(model: LanguageModelChat | IChatEndpoint) { return VSC_MODEL_HASHES_D.includes(ID_hash) || VSC_MODEL_HASHES_D.includes(family_hash); } +export function isVSCModelE(model: LanguageModelChat | IChatEndpoint) { + const modelId = getModelId(model); + const ID_hash = getCachedSha256Hash(modelId); + const family_hash = getCachedSha256Hash(model.family); + return model.name.startsWith('vscModelE') || model.family.startsWith('vscModelE') || modelId.startsWith('vscModelE') || VSC_MODEL_HASHES_E.includes(ID_hash) || VSC_MODEL_HASHES_E.includes(family_hash); +} + export function isGpt52CodexFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { const family = typeof model === 'string' ? model : model.family; return family === 'gpt-5.2-codex'; @@ -240,7 +258,7 @@ export function modelSupportsApplyPatch(model: LanguageModelChat | IChatEndpoint || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isHiddenModelM(model); + || isGpt56(model); } /** @@ -254,21 +272,21 @@ export function modelPrefersJsonNotebookRepresentation(model: LanguageModelChat || isGpt52Family(model.family) || isGpt54(model) || isHiddenModelB(model) - || isHiddenModelM(model); + || isGpt56(model); } /** * Model supports replace_string_in_file as an edit tool. */ export function modelSupportsReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return isGeminiFamily(model) || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isGeminiFamily(model) || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** * Model supports multi_replace_string_in_file as an edit tool. */ export function modelSupportsMultiReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || isHiddenModelE(model) || isVSCModelReplaceStringSet(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isAnthropicFamily(model) || isHiddenModelE(model) || isVSCModelReplaceStringSet(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** @@ -276,7 +294,7 @@ export function modelSupportsMultiReplaceString(model: LanguageModelChat | IChat * without needing insert_edit_into_file. */ export function modelCanUseReplaceStringExclusively(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || model.family.includes('grok-code') || isHiddenModelE(model) || model.family.toLowerCase().includes('gemini-3') || isVSCModelReplaceStringSet(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isAnthropicFamily(model) || model.family.includes('grok-code') || isHiddenModelE(model) || model.family.toLowerCase().includes('gemini-3') || isVSCModelReplaceStringSet(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model) || isKimiFamily(model); } /** @@ -305,7 +323,16 @@ export function modelCanUseImageURL(model: LanguageModelChat | IChatEndpoint): b * The model supports native PDF document processing via document content parts. */ export function modelSupportsPDFDocuments(model: LanguageModelChat | IChatEndpoint): boolean { - return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isHiddenModelM(model); + return isAnthropicFamily(model) || isGpt5PlusFamily(model) || isGpt56(model); +} + +/** + * The model supports explicit prompt cache breakpoints via the OpenAI + * Responses API (`prompt_cache_breakpoint`). Scoped to OpenAI (GPT) models + * only, since this is an OpenAI-specific Responses API feature. + */ +export function modelSupportCacheBreakPoints(model: LanguageModelChat | IChatEndpoint): boolean { + return isGpt56(model); } /** @@ -429,10 +456,11 @@ export function getVerbosityForModelSync(model: IChatEndpoint): 'low' | 'medium' export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint | string): boolean { const id = typeof model === 'string' ? model : getModelId(model); const family = typeof model === 'string' ? model : model.family; + const isGpt56Model: boolean = isGpt56(model); const matches = (s: string) => { const n = s.toLowerCase().replace(/\./g, '-'); // OpenAI models with client-side tool search. - if (n === 'gpt-5-4' || n === 'gpt-5-5') { + if (n === 'gpt-5-4' || n === 'gpt-5-5' || isGpt56Model) { return true; } if (!n.startsWith('claude')) { @@ -455,7 +483,7 @@ export function modelSupportsToolSearch(model: LanguageModelChat | IChatEndpoint n === 'claude-opus-4' || n.startsWith('claude-opus-4-1') || n.startsWith('claude-opus-4-2'); return !isPre45; }; - return matches(id) || matches(family) || isHiddenModelM(family); + return matches(id) || matches(family); } /** diff --git a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts index 647b162de87acd..28d7f2283de8e7 100644 --- a/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts +++ b/extensions/copilot/src/platform/endpoint/common/endpointProvider.ts @@ -19,6 +19,11 @@ export type CustomModel = { export type EndpointEditToolName = 'find-replace' | 'multi-find-replace' | 'apply-patch' | 'code-rewrite'; +export interface IChatModelRequestOptions { + temperature?: number | null; + top_p?: number | null; +} + const allEndpointEditToolNames: ReadonlySet = new Set([ 'find-replace', 'multi-find-replace', @@ -115,6 +120,7 @@ export interface IModelAPIResponse { version: string; warning_messages?: { code: string; message: string }[]; info_messages?: { code: string; message: string }[]; + warning_text?: Record; billing?: IModelBilling; model_picker_price_category?: string; model_picker_category?: string; @@ -127,6 +133,7 @@ export type IChatModelInformation = IModelAPIResponse & { capabilities: IChatModelCapabilities; urlOrRequestMetadata?: string | RequestMetadata; requestHeaders?: Readonly>; + modelOptions?: Readonly; zeroDataRetentionEnabled?: boolean; /** * BYOK-only override that forces the body shape used when forwarding the reasoning effort to the model. diff --git a/extensions/copilot/src/platform/endpoint/node/automodeService.ts b/extensions/copilot/src/platform/endpoint/node/automodeService.ts index 94bdd3038092e1..bf1ce36b2795e6 100644 --- a/extensions/copilot/src/platform/endpoint/node/automodeService.ts +++ b/extensions/copilot/src/platform/endpoint/node/automodeService.ts @@ -11,7 +11,6 @@ import { Disposable, DisposableMap } from '../../../util/vs/base/common/lifecycl import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../vscodeTypes'; import { IAuthenticationService } from '../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../configuration/common/configurationService'; import { IEnvService } from '../../env/common/envService'; import { getImageTelemetryEventMeasurements, getImageTelemetryMeasurementsFromReferences, type ImageTelemetryMeasurements } from '../../image/common/imageTelemetry'; import { ILogService } from '../../log/common/logService'; @@ -104,6 +103,13 @@ class AutoModeTokenBank extends Disposable { } } +export interface AutoModeRoutingDecision { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: 'needs_reasoning' | 'no_reasoning' | 'fallback'; + confidence: number; +} + export const IAutomodeService = createServiceIdentifier('IAutomodeService'); export interface IAutomodeService { @@ -111,6 +117,13 @@ export interface IAutomodeService { resolveAutoModeEndpoint(chatRequest: ChatRequest | undefined, knownEndpoints: IChatEndpoint[]): Promise; + /** + * Returns the routing decision from the last call to {@link resolveAutoModeEndpoint}, + * or `undefined` if the router was not used (e.g. skipped, fallback, or non-auto model). + * Cleared after reading. + */ + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined; + /** * Marks the router cache for this conversation as needing re-evaluation. * The next call to {@link resolveAutoModeEndpoint} will re-run the router @@ -124,6 +137,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private readonly _autoModelCache: Map = new Map(); private _reserveTokens: DisposableMap = new DisposableMap(); private readonly _routerDecisionFetcher: RouterDecisionFetcher; + private _lastRoutingDecision: AutoModeRoutingDecision | undefined; constructor( @ICAPIClientService private readonly _capiClientService: ICAPIClientService, @@ -131,7 +145,6 @@ export class AutomodeService extends Disposable implements IAutomodeService { @ILogService private readonly _logService: ILogService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExperimentationService private readonly _expService: IExperimentationService, - @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvService private readonly _envService: IEnvService, @ITelemetryService private readonly _telemetryService: ITelemetryService, @IRequestLogger private readonly _requestLogger: IRequestLogger, @@ -161,6 +174,12 @@ export class AutomodeService extends Disposable implements IAutomodeService { super.dispose(); } + consumeLastRoutingDecision(): AutoModeRoutingDecision | undefined { + const decision = this._lastRoutingDecision; + this._lastRoutingDecision = undefined; + return decision; + } + /** * Resolve an auto mode endpoint * Optionally uses a router model to select the best endpoint based on the prompt. @@ -179,6 +198,10 @@ export class AutomodeService extends Disposable implements IAutomodeService { throw new Error('No auto mode endpoints provided.'); } + // Clear any previous routing decision upfront so stale data cannot + // leak to a consumer if this call takes a non-router path. + this._lastRoutingDecision = undefined; + const conversationId = chatRequest?.sessionResource?.toString() ?? chatRequest?.sessionId ?? 'unknown'; const entry = this._autoModelCache.get(conversationId); const tokenBank = this._acquireTokenBank(entry, chatRequest?.location, conversationId); @@ -239,6 +262,15 @@ export class AutomodeService extends Disposable implements IAutomodeService { selectedModel = this._applyVisionFallback(chatRequest, selectedModel, token.available_models, knownEndpoints); + // Store routing decision for the UI to consume (update resolved model to the final one after all overrides) + if (routerResult.routingDecision) { + this._lastRoutingDecision = { + ...routerResult.routingDecision, + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + }; + } + // Emit the final model selection alongside the router's recommendation // so analysts can detect overrides without fragile telemetry joins if (!skipRouter && routerResult.candidateModel) { @@ -315,7 +347,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { token: AutoModeAPIResponse, knownEndpoints: IChatEndpoint[], imageTelemetryEventMeasurements: Partial, - ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string }> { + ): Promise<{ selectedModel?: IChatEndpoint; lastRoutedPrompt?: string; fallbackReason?: string; candidateModel?: string; routingDecision?: AutoModeRoutingDecision }> { const prompt = chatRequest?.prompt?.trim(); const lastRoutedPrompt = entry?.lastRoutedPrompt ?? prompt; @@ -340,7 +372,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { previous_model: entry?.endpoint?.model, turn_number: (entry?.turnCount ?? 0) + 1, }; - const routingMethod = this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.AutoModeRoutingMethod, this._expService) || undefined; + const routingMethod = 'hydra'; // Filter available_models to only those the client can actually serve. // The AutoModels API and Models API are separate CAPI calls that can be @@ -372,22 +404,40 @@ export class AutomodeService extends Disposable implements IAutomodeService { return { lastRoutedPrompt: prompt, fallbackReason: 'emptyCandidateList' }; } - // Trust the router's ranked candidate list directly. + // Prefer chosen_model — it is the router's authoritative pick after any + // server-side re-ranking (e.g. Cost Sorting experiments). candidate_models + // is the ordered fallback list per the auto-intent-service contract + // (docs/integrators_onboarding.md: "Use chosen_model for the upcoming chat + // call, and use candidate_models as the ordered fallback list"). // Same-provider preference is intentionally NOT applied here — the router // already accounts for available models and re-runs after /compact, so // overriding its pick with same-provider negates cost-saving decisions. // Same-provider is still used in _selectDefaultModel (the non-router fallback). - const selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + const routerModel = result.chosen_model ?? result.candidate_models[0]; + let selectedModel = result.chosen_model ? knownEndpoints.find(e => e.model === result.chosen_model) : undefined; + if (!selectedModel) { + selectedModel = this._findFirstAvailableModel(result.candidate_models, knownEndpoints); + } if (!selectedModel) { - this._logService.warn(`[AutomodeService] None of the router's candidate_models matched knownEndpoints: [${result.candidate_models.join(', ')}]`); + this._logService.warn(`[AutomodeService] Router pick not in knownEndpoints: chosen_model=${result.chosen_model ?? 'n/a'}, candidate_models=[${result.candidate_models.join(', ')}]`); return { lastRoutedPrompt: prompt, fallbackReason: 'noMatchingEndpoint' }; } if (result.sticky_override) { - this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${result.candidate_models[0]}, actual_model=${selectedModel.model}`); + this._logService.trace(`[AutomodeService] Sticky routing override: confidence=${(result.confidence * 100).toFixed(1)}%, label=${result.predicted_label}, router_model=${routerModel}, actual_model=${selectedModel.model}`); } - return { selectedModel, lastRoutedPrompt: prompt, candidateModel: result.candidate_models[0] }; + return { + selectedModel, + lastRoutedPrompt: prompt, + candidateModel: routerModel, + routingDecision: { + resolvedModel: selectedModel.model, + resolvedModelName: selectedModel.name, + predictedLabel: result.predicted_label, + confidence: result.confidence, + }, + }; } catch (e) { const isTimeout = isAbortError(e); let fallbackReason: string; @@ -440,7 +490,7 @@ export class AutomodeService extends Disposable implements IAutomodeService { private _isRouterEnabled(chatRequest: ChatRequest | undefined): boolean { const isPanelChat = !chatRequest?.location || chatRequest?.location === ChatLocation.Panel; - return isPanelChat && this._configurationService.getExperimentBasedConfig(ConfigKey.TeamInternal.UseAutoModeRouting, this._expService); + return isPanelChat; } /** diff --git a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts index 4f6f23037f6940..7ee962e6c07856 100644 --- a/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/chatEndpoint.ts @@ -29,7 +29,7 @@ import { ITelemetryService, TelemetryProperties } from '../../telemetry/common/t import { TelemetryData } from '../../telemetry/common/telemetryData'; import { ITokenizerProvider } from '../../tokenizer/node/tokenizer'; import { ICAPIClientService } from '../common/capiClient'; -import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isAnthropicFamily, isGeminiFamily, isKimiFamily, modelSupportsContextEditing, modelSupportsToolSearch } from '../common/chatModelCapabilities'; import { IDomainService } from '../common/domainService'; import { CustomModel, IChatModelInformation, ModelSupportedEndpoint } from '../common/endpointProvider'; import { normalizeTokenPrices } from '../../../extension/conversation/common/languageModelAccess'; @@ -141,6 +141,7 @@ export class ChatEndpoint implements IChatEndpoint { public readonly modelPickerCategory?: string | undefined; public readonly customModel?: CustomModel | undefined; public readonly maxPromptImages?: number | undefined; + public readonly warningText?: Record | undefined; private readonly _supportsStreaming: boolean; @@ -190,6 +191,7 @@ export class ChatEndpoint implements IChatEndpoint { this._supportsStreaming = !!modelMetadata.capabilities.supports.streaming; this.customModel = modelMetadata.custom_model; this.maxPromptImages = modelMetadata.capabilities.limits?.vision?.max_prompt_images; + this.warningText = modelMetadata.warning_text; } // TODO: Thread enableThinking through the fetch pipeline (INetworkRequestOptions / chatMLFetcher positional params) @@ -388,6 +390,12 @@ export class ChatEndpoint implements IChatEndpoint { } } + // Force temperature and top_p for Kimi models regardless of what the client would otherwise send (per Moonshot recommendations). Temperature 0 strongly increases chances of looping. + if (isKimiFamily(this)) { + body.temperature = 1; + body.top_p = 0.95; + } + return body; } diff --git a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts index 3f1f0f5684c39b..17306f5c5fd7ea 100644 --- a/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/copilotChatEndpoint.ts @@ -75,7 +75,17 @@ export class CopilotUtilitySmallChatEndpoint { static readonly capiFamily: string = CHAT_MODEL.GPT4OMINI; static async resolve(modelFetcher: IModelMetadataFetcher, instantiationService: IInstantiationService): Promise { - const modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + let modelMetadata: IChatModelInformation; + try { + modelMetadata = await modelFetcher.getChatModelFromCapiFamily(CopilotUtilitySmallChatEndpoint.capiFamily); + } catch { + // The small family is selected client-side and may be absent from a + // given user's CAPI `/models` response (plan/region/rollout differences, + // or a server-side rename/removal). Fall back to the API-marked base + // utility model rather than letting the lookup throw through every + // `copilot-utility-small` caller. + modelMetadata = await modelFetcher.getCopilotUtilityModel(); + } return instantiationService.createInstance(CopilotChatEndpoint, modelMetadata); } } diff --git a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts index 46912f0693c3d1..601295b10a8063 100644 --- a/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/modelMetadataFetcher.ts @@ -175,7 +175,10 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._copilotUtilityModel; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); + // If the model fetch itself failed (e.g. an expired token returning HTTP 401), surface that + // underlying error rather than the misleading "no fallback model" message, which only makes + // sense when the fetch succeeded but the server genuinely marked no chat fallback. + throw this._lastFetchError ?? new Error(await this._getErrorMessage('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)')); } return resolvedModel; } @@ -184,7 +187,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isChatModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve chat model with CAPI family selection: ${family}`)); } return resolvedModel; } @@ -220,7 +223,7 @@ export class ModelMetadataFetcher extends Disposable implements IModelMetadataFe await this._taskSingler.getOrCreate(ModelMetadataFetcher.ALL_MODEL_KEY, this._fetchModels.bind(this)); const resolvedModel = this._familyMap.get(family)?.[0]; if (!resolvedModel || !isEmbeddingModelInformation(resolvedModel)) { - throw new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); + throw this._lastFetchError ?? new Error(await this._getErrorMessage(`Unable to resolve embeddings model with family selection: ${family}`)); } return resolvedModel; } diff --git a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts index fba5bbff3f782a..407986bef4baf2 100644 --- a/extensions/copilot/src/platform/endpoint/node/responsesApi.ts +++ b/extensions/copilot/src/platform/endpoint/node/responsesApi.ts @@ -27,7 +27,7 @@ import { IChatWebSocketManager } from '../../networking/node/chatWebSocketManage import { IExperimentationService } from '../../telemetry/common/nullExperimentationService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { TelemetryData } from '../../telemetry/common/telemetryData'; -import { getVerbosityForModelSync, isGpt54, isGpt55, isHiddenModelM } from '../common/chatModelCapabilities'; +import { getVerbosityForModelSync, modelSupportCacheBreakPoints } from '../common/chatModelCapabilities'; import { rawPartAsCompactionData } from '../common/compactionDataContainer'; import { rawPartAsPhaseData } from '../common/phaseDataContainer'; import { getIndexOfStatefulMarker, getStatefulMarkerAndIndex } from '../common/statefulMarkerContainer'; @@ -123,6 +123,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: ? new Map(options.requestOptions.tools.map(t => [t.function.name, t])) : undefined; const shouldLoadToolFromToolSearch = shouldDeferTools ? (name: string) => !toolDeferralService!.isNonDeferredTool(name) : undefined; + const promptCacheBreakpointsEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, expService); const body: IEndpointBody = { model, @@ -130,6 +131,7 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: toolsMap, shouldLoadToolFromToolSearch, modeChanged, + supportsCacheBreakpoints: promptCacheBreakpointsEnabled && modelSupportCacheBreakPoints(endpoint), }), stream: true, tools: finalTools.length > 0 ? finalTools : undefined, @@ -155,21 +157,15 @@ export function createResponsesRequestBody(accessor: ServicesAccessor, options: body.truncation = configService.getConfig(ConfigKey.Advanced.UseResponsesApiTruncation) ? 'auto' : 'disabled'; - const thinkingExplicitlyDisabled = options.modelCapabilities?.enableThinking === false; - const summaryConfig = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiReasoningSummary, expService); - const shouldDisableReasoningSummary = endpoint.family === 'gpt-5.3-codex-spark-preview' || thinkingExplicitlyDisabled; const effortFromSetting = configService.getConfig(ConfigKey.Advanced.ReasoningEffortOverride); const effort = endpoint.supportsReasoningEffort?.length ? (effortFromSetting || options.modelCapabilities?.reasoningEffort || 'medium') : undefined; - const summary = summaryConfig === 'off' || shouldDisableReasoningSummary ? undefined : summaryConfig; - const persistentCoTEnabled = configService.getExperimentBasedConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, expService) - && (isGpt54(endpoint) || isGpt55(endpoint) || isHiddenModelM(endpoint)); - if (effort || summary || persistentCoTEnabled) { + const summary: string | undefined = undefined; + if (effort || summary) { body.reasoning = { ...(effort ? { effort } : {}), - ...(summary ? { summary } : {}), - ...(persistentCoTEnabled ? { context: 'all_turns' } : {}) + ...(summary ? { summary } : {}) }; } @@ -304,10 +300,11 @@ interface RawMessagesToResponseAPIOptions { readonly toolsMap?: Map; readonly shouldLoadToolFromToolSearch?: (name: string) => boolean; readonly modeChanged?: boolean; + readonly supportsCacheBreakpoints?: boolean; } function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMessage[], ignoreStatefulMarker: boolean, webSocketStatefulMarker: string | undefined, options: RawMessagesToResponseAPIOptions = {}): { input: OpenAI.Responses.ResponseInputItem[]; previous_response_id?: string } { - const { toolsMap, shouldLoadToolFromToolSearch, modeChanged = false } = options; + const { toolsMap, shouldLoadToolFromToolSearch, modeChanged = false, supportsCacheBreakpoints = false } = options; const latestCompactionMessageIndex = getLatestCompactionMessageIndex(messages); const latestCompactionMessage = latestCompactionMessageIndex !== undefined ? createCompactionRoundTripMessage(messages[latestCompactionMessageIndex]) : undefined; @@ -378,6 +375,7 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe const input: OpenAI.Responses.ResponseInputItem[] = []; for (const message of messages) { + const inputStartIndex = input.length; switch (message.role) { case Raw.ChatRole.Assistant: if (message.content.length) { @@ -472,6 +470,16 @@ function rawMessagesToResponseAPI(modelId: string, messages: readonly Raw.ChatMe input.push({ role: 'system', content: message.content.map(rawContentToResponsesContent).filter(isDefined) }); break; } + + if (supportsCacheBreakpoints && input.length > inputStartIndex && hasCacheBreakpoint(message)) { + // Attach the prompt-cache marker to the last item this message produced, scanning back past + // reasoning/compaction items that cannot carry it. + for (let inputIndex = input.length - 1; inputIndex >= inputStartIndex; inputIndex--) { + if (tryApplyPromptCacheBreakpoint(input[inputIndex])) { + break; + } + } + } } return { input, previous_response_id: previousResponseId }; @@ -570,6 +578,56 @@ function rawContentToResponsesAssistantContent(part: Raw.ChatCompletionContentPa } } +interface ResponsesPromptCacheBreakpoint { + readonly mode: 'explicit'; +} + +const promptCacheBreakpoint: ResponsesPromptCacheBreakpoint = { mode: 'explicit' }; + +/** + * Whether a raw message carries one or more prompt-cache breakpoints. The Responses content + * converters drop `CacheBreakpoint` parts, so we detect them at the message level and later attach + * `prompt_cache_breakpoint` to the appropriate Responses input item/content block. + */ +function hasCacheBreakpoint(message: Raw.ChatMessage): boolean { + return message.content.some(part => part.type === Raw.ChatCompletionContentPartKind.CacheBreakpoint); +} + +/** + * Attaches a prompt-cache marker (`prompt_cache_breakpoint: { mode: 'explicit' }`) to a single + * Responses API input item. + * + * Items that carry a non-empty `content` array (user/system/assistant messages) receive the marker + * on their last content block. Items without a content array (`function_call`, + * `function_call_output`, `tool_search_*`) receive the marker at the item level. Returns whether a + * marker was applied. + */ +function tryApplyPromptCacheBreakpoint(item: OpenAI.Responses.ResponseInputItem): boolean { + const content = (item as { content?: unknown }).content; + if (Array.isArray(content)) { + const lastContentBlock = content.at(-1) as { prompt_cache_breakpoint?: ResponsesPromptCacheBreakpoint } | undefined; + if (!lastContentBlock) { + return false; + } + + lastContentBlock.prompt_cache_breakpoint = promptCacheBreakpoint; + return true; + } + + const itemType = (item as { type?: string }).type; + if ( + itemType === 'function_call' + || itemType === 'function_call_output' + || itemType === 'tool_search_call' + || itemType === 'tool_search_output' + ) { + (item as { prompt_cache_breakpoint?: ResponsesPromptCacheBreakpoint }).prompt_cache_breakpoint = promptCacheBreakpoint; + return true; + } + + return false; +} + /** * The Responses API rejects the entire request with * `400 invalid_request_body: Invalid 'input[N].id': '...'. Expected an ID that begins with 'rs'.` @@ -1116,7 +1174,16 @@ export class OpenAIResponsesProcessor { switch (chunk.type) { case 'error': - return onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + // Surface the error as a progress delta, but also produce a terminal + // completion so the request resolves to a meaningful server error + // instead of collapsing into the generic "Response contained no + // choices" fallback when the stream ends without a terminal event. + onProgress({ text: '', copilotErrors: [{ agent: 'openai', code: chunk.code || 'unknown', message: chunk.message, type: 'error', identifier: chunk.param || undefined }] }); + return this.buildTerminalCompletion( + { output: [] } as unknown as CapiResponseTerminalEvent['response'], + FinishedCompletionReason.ServerError, + { error: mapResponsesApiError({ code: chunk.code, message: chunk.message } as OpenAI.Responses.ResponseError) } + ); case 'response.output_text.delta': { const capiChunk: CapiResponsesTextDeltaEvent = chunk; // When text arrives from a new output item, emit a paragraph diff --git a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts index 436ce8b503868d..f205f027b4de72 100644 --- a/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts +++ b/extensions/copilot/src/platform/endpoint/node/routerDecisionFetcher.ts @@ -29,6 +29,7 @@ export interface RouterDecisionResponse { hydra_scores?: Record; chosen_model?: string; chosen_shortfall?: number; + reasoning_bucket?: string; } export interface RoutingContextSignals { diff --git a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts index fb5e98e155b2fd..a2d4e2aca4d72b 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/automodeService.spec.ts @@ -9,9 +9,6 @@ import type { ChatRequest } from 'vscode'; import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; import { ChatLocation } from '../../../../vscodeTypes'; import { IAuthenticationService } from '../../../authentication/common/authentication'; -import { ConfigKey, IConfigurationService } from '../../../configuration/common/configurationService'; -import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; -import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import { NullEnvService } from '../../../env/common/nullEnvService'; import { ILogService } from '../../../log/common/logService'; import { IChatEndpoint } from '../../../networking/common/networking'; @@ -58,7 +55,6 @@ describe('AutomodeService', () => { let mockLogService: ILogService; let mockInstantiationService: IInstantiationService; let mockExpService: IExperimentationService; - let configurationService: IConfigurationService; let mockChatEndpoint: IChatEndpoint; let envService: NullEnvService; let mockTelemetryService: ITelemetryService & { sendEnhancedGHTelemetryEvent: ReturnType; sendMSFTTelemetryEvent: ReturnType }; @@ -87,7 +83,6 @@ describe('AutomodeService', () => { mockLogService, mockInstantiationService, mockExpService, - configurationService, envService, mockTelemetryService, new NullRequestLogger() @@ -105,10 +100,7 @@ describe('AutomodeService', () => { } function enableRouter(): void { - (configurationService as InMemoryConfigurationService).setConfig( - ConfigKey.TeamInternal.UseAutoModeRouting, - true - ); + // Router is now always enabled for panel chat — no config key needed. } beforeEach(() => { @@ -145,7 +137,6 @@ describe('AutomodeService', () => { mockExpService = new NullExperimentationService(); - configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService()); envService = new NullEnvService(); mockTelemetryService = { sendTelemetryEvent: vi.fn(), @@ -286,24 +277,6 @@ describe('AutomodeService', () => { expect(parsed.previous_model).toBeUndefined(); }); - it('should not use router when routing is not enabled', async () => { - // Routing not enabled via UseAutoModeRouting config - automodeService = createService(); - - const chatRequest: Partial = { - location: ChatLocation.Panel, - prompt: 'test prompt' - }; - - await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [mockChatEndpoint]); - - // Verify that router API was NOT called (exp / config disabled) - expect(mockCAPIClientService.makeRequest).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ type: RequestType.ModelRouter }) - ); - }); - it('should not use router for terminal chat', async () => { enableRouter(); @@ -1103,6 +1076,38 @@ describe('AutomodeService', () => { }); }); + it('should emit candidateModel from chosen_model, not candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick: candidate_models[0] is gpt-5.3-codex but the + // authoritative chosen_model is gpt-5.4-mini. The telemetry candidateModel + // must reflect chosen_model so router-pick vs actual comparisons are valid. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-telemetry-chosen-model' + }; + + await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + + const telemetryCalls = mockTelemetryService.sendMSFTTelemetryEvent.mock.calls; + const selectionEvent = telemetryCalls.find((call: unknown[]) => call[0] === 'automode.routerModelSelection'); + expect(selectionEvent).toBeDefined(); + expect(selectionEvent![1]).toMatchObject({ + candidateModel: 'gpt-5.4-mini', + actualModel: 'gpt-5.4-mini', + overrideReason: 'none', + }); + }); + it('should emit overrideReason=clientOverride when vision fallback changes the model', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI', { supportsVision: true }); @@ -1261,13 +1266,15 @@ describe('AutomodeService', () => { ); }); - it('should iterate all candidate_models when first candidate has no endpoint', async () => { + it('should fall back to candidate_models when chosen_model has no endpoint', async () => { enableRouter(); const gpt41Endpoint = createEndpoint('gpt-4.1', 'OpenAI'); + // chosen_model is not in knownEndpoints, so selection falls back to + // the ordered candidate_models list and picks the first resolvable one. mockRouterResponse( ['gpt-4.1'], - { chosen_model: 'gpt-4.1', candidate_models: ['unknown-new-model', 'gpt-4.1'] } + { chosen_model: 'unknown-new-model', candidate_models: ['unknown-new-model', 'gpt-4.1'] } ); automodeService = createService(); @@ -1281,6 +1288,57 @@ describe('AutomodeService', () => { expect(result.model).toBe('gpt-4.1'); }); + it('should prefer chosen_model over candidate_models[0]', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // Server re-ranked the pick (e.g. Cost Sorting experiment): chosen_model + // is gpt-5.4-mini even though candidate_models[0] is gpt-5.3-codex. The + // client must send the chosen_model, per the auto-intent-service contract. + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-chosen-model' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + expect(result.model).toBe('gpt-5.4-mini'); + }); + + it('should surface chosen_model in the routing decision the UI displays', async () => { + enableRouter(); + const codexEndpoint = createEndpoint('gpt-5.3-codex', 'OpenAI'); + const miniEndpoint = createEndpoint('gpt-5.4-mini', 'OpenAI'); + + // The "Routed to " explainability label reads the routing + // decision surfaced via consumeLastRoutingDecision(). It must match the + // served endpoint (chosen_model), otherwise the label diverges from the + // model shown in the response footer (candidate_models[0]). + mockRouterResponse( + ['gpt-5.3-codex', 'gpt-5.4-mini'], + { chosen_model: 'gpt-5.4-mini', candidate_models: ['gpt-5.3-codex', 'gpt-5.4-mini'] } + ); + + automodeService = createService(); + const chatRequest: Partial = { + location: ChatLocation.Panel, + prompt: 'refactor this function', + sessionId: 'session-routing-decision' + }; + + const result = await automodeService.resolveAutoModeEndpoint(chatRequest as ChatRequest, [codexEndpoint, miniEndpoint]); + const decision = automodeService.consumeLastRoutingDecision(); + expect(decision?.resolvedModel).toBe('gpt-5.4-mini'); + expect(decision?.resolvedModel).toBe(result.model); + }); + it('should fall back to first known endpoint when all available_models are unknown', async () => { enableRouter(); const gpt4oEndpoint = createEndpoint('gpt-4o', 'OpenAI'); diff --git a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts index df5f0391d79c48..665209c8dedacf 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/copilotChatEndpoint.spec.ts @@ -451,4 +451,49 @@ describe('ChatEndpoint - Image Count Validation', () => { expect(() => filterImages(endpoint, messages, 2)).toThrow(/Too many images/); }); }); +}); + +describe('ChatEndpoint - Kimi temperature and top_p override', () => { + let mockServices: ReturnType; + + beforeEach(() => { + mockServices = createMockServices(); + }); + + const createEndpoint = (metadata: IChatModelInformation) => + new ChatEndpoint( + metadata, + mockServices.domainService, + mockServices.chatMLFetcher, + mockServices.tokenizerProvider, + mockServices.instantiationService, + mockServices.configurationService, + mockServices.expService, + mockServices.chatWebSocketService, + mockServices.logService + ); + + const createTextMessage = (): Raw.ChatMessage => ({ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'Hello' }] + }); + + const createOptionsWithPostOptions = (): ICreateEndpointBodyOptions => ({ + ...createTestOptions([createTextMessage()]), + postOptions: { temperature: 0, top_p: 1 } + }); + + it.each(['kimi-k2.6', 'kimi-k2.7-code'])('should force temperature=1 and top_p=0.95 for %s', (family) => { + const endpoint = createEndpoint(createNonAnthropicModelMetadata(family)); + const body = endpoint.createRequestBody(createOptionsWithPostOptions()); + expect(body.temperature).toBe(1); + expect(body.top_p).toBe(0.95); + }); + + it('should not override temperature or top_p for non-Kimi models', () => { + const endpoint = createEndpoint(createNonAnthropicModelMetadata('gpt-4o')); + const body = endpoint.createRequestBody(createOptionsWithPostOptions()); + expect(body.temperature).toBe(0); + expect(body.top_p).toBe(1); + }); }); \ No newline at end of file diff --git a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts index c8bd81d3882095..24a28604289f68 100644 --- a/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts +++ b/extensions/copilot/src/platform/endpoint/node/test/responsesApi.spec.ts @@ -21,7 +21,7 @@ import { SpyingTelemetryService } from '../../../telemetry/node/spyingTelemetryS import { createFakeStreamResponse } from '../../../test/node/fetcher'; import { createPlatformServices } from '../../../test/node/services'; import type { ThinkingData } from '../../../thinking/common/thinking'; -import { CustomDataPartMimeTypes } from '../../common/endpointTypes'; +import { CacheType, CustomDataPartMimeTypes } from '../../common/endpointTypes'; import { createResponsesRequestBody, getResponsesApiCompactionThresholdFromBody, processResponseFromChatEndpoint, responseApiInputToRawMessagesForLogging } from '../responsesApi'; const testEndpoint: IChatEndpoint = { @@ -369,59 +369,6 @@ describe('responseApiInputToRawMessagesForLogging', () => { }); describe('createResponsesRequestBody', () => { - it('enables persistent CoT on initial requests for hidden model M when the experiment is enabled', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const endpoint = { ...testEndpoint, family: 'ember-alpha', supportsReasoningEffort: ['low', 'medium', 'high'] }; - - const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), endpoint.model, endpoint)); - - expect(body.reasoning).toEqual({ effort: 'medium', summary: 'detailed', context: 'all_turns' }); - - accessor.dispose(); - services.dispose(); - }); - - it('does not enable persistent CoT when the experiment is disabled or the family is unsupported', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - const emberEndpoint = { ...testEndpoint, family: 'ember-alpha' }; - const unsupportedEndpoint = { ...testEndpoint, model: 'ember-alpha', family: 'other-family' }; - - const disabledBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), emberEndpoint.model, emberEndpoint)); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const unsupportedBody = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions([], false), unsupportedEndpoint.model, unsupportedEndpoint)); - - expect(disabledBody.reasoning?.context).toBeUndefined(); - expect(unsupportedBody.reasoning?.context).toBeUndefined(); - - accessor.dispose(); - services.dispose(); - }); - - it('keeps persistent CoT enabled when continuing from a previous response', () => { - const services = createPlatformServices(); - const accessor = services.createTestingAccessor(); - const instantiationService = accessor.get(IInstantiationService); - accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPersistentCoTEnabled, true); - const endpoint = { ...testEndpoint, family: 'ember-alpha' }; - const messages: Raw.ChatMessage[] = [ - createStatefulMarkerMessage(endpoint.model, 'resp-prev'), - { role: Raw.ChatRole.User, content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'continue' }] }, - ]; - - const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), endpoint.model, endpoint)); - - expect(body.previous_response_id).toBe('resp-prev'); - expect(body.reasoning?.context).toBe('all_turns'); - - accessor.dispose(); - services.dispose(); - }); - it('extracts compaction threshold from request body context management', () => { expect(getResponsesApiCompactionThresholdFromBody({ context_management: [{ @@ -984,6 +931,222 @@ describe('createResponsesRequestBody', () => { }); }); +describe('createResponsesRequestBody prompt_cache_breakpoint markers', () => { + const expectedPromptCacheBreakpoint = { mode: 'explicit' }; + const cacheBreakpointEndpoint: IChatEndpoint = { ...testEndpoint, family: 'gpt-5.6-sol' }; + + const cacheBreakpoint = (): Raw.ChatCompletionContentPart => ({ + type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, + cacheType: CacheType, + }); + + const buildBody = (messages: Raw.ChatMessage[], endpoint = cacheBreakpointEndpoint, enablePromptCacheBreakpoint = true) => { + const services = createPlatformServices(); + const accessor = services.createTestingAccessor(); + if (enablePromptCacheBreakpoint) { + accessor.get(IConfigurationService).setConfig(ConfigKey.ResponsesApiPromptCacheBreakpointEnabled, true); + } + const instantiationService = accessor.get(IInstantiationService); + const body = instantiationService.invokeFunction(servicesAccessor => createResponsesRequestBody(servicesAccessor, createRequestOptions(messages, false), endpoint.model, endpoint)); + accessor.dispose(); + services.dispose(); + return body; + }; + + it('does not attach prompt_cache_breakpoint by default when the experiment flag is disabled', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages, cacheBreakpointEndpoint, false); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the last content block of a user message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'first' }, + { type: Raw.ChatCompletionContentPartKind.Text, text: 'second' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'user', + content: [ + { type: 'input_text', text: 'first' }, + { type: 'input_text', text: 'second', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }, + ], + }); + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the last content block of a system message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.System, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'be concise' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'system', + content: [{ type: 'input_text', text: 'be concise', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }], + }); + }); + + it('attaches prompt_cache_breakpoint to the last output_text of a terminal assistant message', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.Assistant, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'final answer' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'assistant', + type: 'message', + content: [{ type: 'output_text', text: 'final answer', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }], + }); + }); + + it('attaches prompt_cache_breakpoint at item level to a tool result (function_call_output)', () => { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_1', type: 'function', function: { name: 'read_file', arguments: '{}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_1', + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'result' }, + cacheBreakpoint(), + ], + }, + ]; + + const body = buildBody(messages); + + expect(body.input?.[1]).toMatchObject({ + type: 'function_call_output', + call_id: 'call_1', + output: 'result', + prompt_cache_breakpoint: expectedPromptCacheBreakpoint, + }); + }); + + it('attaches prompt_cache_breakpoint at item level to the last function_call when the assistant has tool calls', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.Assistant, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'calling' }, + cacheBreakpoint(), + ], + toolCalls: [ + { id: 'call_a', type: 'function', function: { name: 'tool_a', arguments: '{}' } }, + { id: 'call_b', type: 'function', function: { name: 'tool_b', arguments: '{}' } }, + ], + }]; + + const body = buildBody(messages); + const input = body.input as OpenAI.Responses.ResponseInputItem[]; + + const lastCall = input.find(item => isFunctionCallInputItem(item, 'tool_b')); + expect(lastCall).toMatchObject({ prompt_cache_breakpoint: expectedPromptCacheBreakpoint }); + + const firstCall = input.find(item => isFunctionCallInputItem(item, 'tool_a')); + expect(firstCall).not.toHaveProperty('prompt_cache_breakpoint'); + + const messageItem = input.find(item => (item as { type?: string }).type === 'message'); + expect((messageItem as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('attaches prompt_cache_breakpoint to the trailing image item of a tool result with images', () => { + const messages: Raw.ChatMessage[] = [ + { + role: Raw.ChatRole.Assistant, + content: [], + toolCalls: [{ id: 'call_img', type: 'function', function: { name: 'screenshot', arguments: '{}' } }], + }, + { + role: Raw.ChatRole.Tool, + toolCallId: 'call_img', + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'see image' }, + { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: 'data:image/png;base64,abc', detail: 'auto' } }, + cacheBreakpoint(), + ], + }, + ]; + + const body = buildBody(messages); + + expect(body.input?.at(-1)).toMatchObject({ + role: 'user', + content: [ + { type: 'input_text', text: 'Image associated with the above tool call:' }, + { type: 'input_image', prompt_cache_breakpoint: expectedPromptCacheBreakpoint }, + ], + }); + expect(body.input?.[1]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('does not synthesize a whitespace text block when the marked message has no other content', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [cacheBreakpoint()], + }]; + + const body = buildBody(messages); + + expect(body.input?.[0]).toMatchObject({ + role: 'user', + content: [], + }); + }); + + it('does not attach prompt_cache_breakpoint when the message has no breakpoint', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }], + }]; + + const body = buildBody(messages); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); + + it('does not attach prompt_cache_breakpoint when the model does not support cache breakpoints', () => { + const messages: Raw.ChatMessage[] = [{ + role: Raw.ChatRole.User, + content: [ + { type: Raw.ChatCompletionContentPartKind.Text, text: 'hello' }, + cacheBreakpoint(), + ], + }]; + + const body = buildBody(messages, testEndpoint); + + expect((body.input?.[0] as { content: unknown[] }).content[0]).not.toHaveProperty('prompt_cache_breakpoint'); + }); +}); + describe('processResponseFromChatEndpoint telemetry', () => { it('emits engine.messages for Responses API assistant output', async () => { const services = createPlatformServices(); @@ -1753,4 +1916,28 @@ describe('processResponseFromChatEndpoint terminal events', () => { metadata: { code: 'internal_error' }, }); }); + + it('maps a raw error stream event to a terminal ServerError completion', async () => { + // Root cause of #322209: a raw Responses API `error` stream event is only + // surfaced as a `copilotErrors` progress delta and never yields a terminal + // completion. With no completion, the downstream chat fetcher falls through + // to the generic `RESPONSE_CONTAINED_NO_CHOICES` ("Response contained no + // choices") instead of a meaningful server-error message. + const errorEvent = { + type: 'error', + code: 'server_error', + message: 'The server had an error while processing your request.', + param: null, + }; + + const [completion] = await runStream(`data: ${JSON.stringify(errorEvent)}\n\n`); + + expect(completion).toBeDefined(); + expect(completion.finishReason).toBe(FinishedCompletionReason.ServerError); + expect(completion.error).toEqual({ + code: 0, + message: 'The server had an error while processing your request.', + metadata: { code: 'server_error' }, + }); + }); }); diff --git a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts index 799a6c84ab7848..8b5ed39ee5ba77 100644 --- a/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts +++ b/extensions/copilot/src/platform/endpoint/test/node/chatModelCapabilities.spec.ts @@ -8,7 +8,7 @@ import { ConfigKey, IConfigurationService } from '../../../configuration/common/ import { DefaultsOnlyConfigurationService } from '../../../configuration/common/defaultsOnlyConfigurationService'; import { InMemoryConfigurationService } from '../../../configuration/test/common/inMemoryConfigurationService'; import type { IChatEndpoint } from '../../../networking/common/networking'; -import { getModelCapabilityOverride, modelSupportsContextEditing, modelSupportsPDFDocuments, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; +import { getModelCapabilityOverride, isKimiFamily, modelCanUseApplyPatchExclusively, modelCanUseReplaceStringExclusively, modelSupportsApplyPatch, modelSupportsContextEditing, modelSupportsMultiReplaceString, modelSupportsPDFDocuments, modelSupportsReplaceString, modelSupportsToolSearch } from '../../common/chatModelCapabilities'; function fakeModel(family: string, model: string = family) { return { family, model } as unknown as IChatEndpoint; @@ -41,6 +41,69 @@ describe('modelSupportsPDFDocuments', () => { }); }); +describe('Kimi edit tool capabilities', () => { + test('uses replace-string tools without insert-edit or apply-patch', () => { + const models = { + 'kimi-k2.6': fakeModel('kimi-k2.6'), + 'kimi-k2.7-code': fakeModel('kimi-k2.7-code'), + 'moonshot/kimi-k2.7-code': fakeModel('moonshot/kimi-k2.7-code'), + 'moonshot/kimi-k2.6': fakeModel('moonshot/kimi-k2.6'), + 'unknown-family + kimi model id': fakeModel('unknown-family', 'kimi-k2.7-code-preview'), + }; + const actual = Object.fromEntries(Object.entries(models).map(([name, model]) => [name, { + isKimiFamily: isKimiFamily(model), + supportsReplaceString: modelSupportsReplaceString(model), + supportsMultiReplaceString: modelSupportsMultiReplaceString(model), + canUseReplaceStringExclusively: modelCanUseReplaceStringExclusively(model), + supportsApplyPatch: modelSupportsApplyPatch(model), + canUseApplyPatchExclusively: modelCanUseApplyPatchExclusively(model), + }])); + + expect(actual).toEqual({ + 'kimi-k2.6': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'kimi-k2.7-code': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'moonshot/kimi-k2.7-code': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'moonshot/kimi-k2.6': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + 'unknown-family + kimi model id': { + isKimiFamily: true, + supportsReplaceString: true, + supportsMultiReplaceString: true, + canUseReplaceStringExclusively: true, + supportsApplyPatch: false, + canUseApplyPatchExclusively: false, + }, + }); + }); +}); + describe('modelSupportsToolSearch', () => { test('supports Claude Sonnet/Opus 4.5 and up, including new and future families', () => { expect(modelSupportsToolSearch('claude-sonnet-4-5')).toBe(true); @@ -73,7 +136,7 @@ describe('modelSupportsToolSearch', () => { }); test('rejects Haiku and legacy Claude families', () => { - // Haiku is current-gen but has no tool search support — denied explicitly. + // Haiku is current-gen but has no tool search support — denied explicitly. expect(modelSupportsToolSearch('claude-haiku-4-5')).toBe(false); expect(modelSupportsToolSearch('claude-haiku-4.5')).toBe(false); expect(modelSupportsToolSearch('claude-3-5-sonnet-20241022')).toBe(false); diff --git a/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts new file mode 100644 index 00000000000000..183fdae98cdab3 --- /dev/null +++ b/extensions/copilot/src/platform/endpoint/test/node/copilotChatEndpoint.spec.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { LanguageModelChat } from 'vscode'; +import { describe, expect, test } from 'vitest'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { CHAT_MODEL } from '../../../configuration/common/configurationService'; +import { IChatModelInformation } from '../../common/endpointProvider'; +import { IModelMetadataFetcher } from '../../node/modelMetadataFetcher'; +import { CopilotUtilityChatEndpoint, CopilotUtilitySmallChatEndpoint } from '../../node/copilotChatEndpoint'; + +/** + * Builds a minimal valid chat model record as it would appear in the + * hydrated CAPI `/models` response. + */ +function chatModel(id: string, family: string, isChatFallback = false): IChatModelInformation { + return { + id, + vendor: 'openai', + name: id, + version: '1.0', + model_picker_enabled: false, + is_chat_default: false, + is_chat_fallback: isChatFallback, + capabilities: { + type: 'chat', + family, + tokenizer: 'o200k_base' as any, + limits: { max_prompt_tokens: 8192, max_output_tokens: 4096 }, + supports: { streaming: undefined }, + }, + }; +} + +/** + * A fake `IModelMetadataFetcher` that mimics the real one's lookup semantics + * against an in-memory set of models, including the same error message as the + * real implementation when a CAPI family is absent. + */ +class FakeModelMetadataFetcher implements IModelMetadataFetcher { + private readonly _byFamily = new Map(); + private _utilityModel: IChatModelInformation | undefined; + + constructor(models: IChatModelInformation[]) { + for (const model of models) { + const family = model.capabilities.family; + const list = this._byFamily.get(family) ?? []; + list.push(model); + this._byFamily.set(family, list); + if (model.is_chat_fallback) { + this._utilityModel = model; + } + } + } + + onDidModelsRefresh = (() => { /* noop */ }) as any; + + async getAllCompletionModels(): Promise { return []; } + async getAllChatModels(): Promise { + return Array.from(this._byFamily.values()).flat(); + } + async getChatModelFromApiModel(_model: LanguageModelChat): Promise { return undefined; } + async getEmbeddingsModel(): Promise { throw new Error('not implemented'); } + + async getCopilotUtilityModel(): Promise { + if (!this._utilityModel) { + throw new Error('Unable to resolve Copilot utility chat model (server did not mark a chat fallback model)'); + } + return this._utilityModel; + } + + async getChatModelFromCapiFamily(family: string): Promise { + const resolved = this._byFamily.get(family)?.[0]; + if (!resolved) { + throw new Error(`Unable to resolve chat model with CAPI family selection: ${family}`); + } + return resolved; + } +} + +/** + * A fake instantiation service whose `createInstance` records the model + * metadata it was handed, so the test can assert which model the resolver + * ultimately selected without building the full `CopilotChatEndpoint`. + */ +function fakeInstantiationService(): { service: IInstantiationService; lastModel: () => IChatModelInformation | undefined } { + let last: IChatModelInformation | undefined; + const service = { + createInstance: (_ctor: unknown, modelMetadata: IChatModelInformation) => { + last = modelMetadata; + return { model: modelMetadata.id } as any; + }, + } as unknown as IInstantiationService; + return { service, lastModel: () => last }; +} + +describe('CopilotUtilitySmallChatEndpoint.resolve (issue #321184)', () => { + test('falls back to the base utility model when the gpt-4o-mini family is absent from /models', async () => { + // CAPI `/models` for this user contains a base chat-fallback model, but + // no model in the client-selected small family (gpt-4o-mini). This mirrors + // the plan/region/rollout differences reported in issue #321184. + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + // Sanity: the small family really is unresolvable for this user. + await expect(fetcher.getChatModelFromCapiFamily(CHAT_MODEL.GPT4OMINI)).rejects.toThrow( + 'Unable to resolve chat model with CAPI family selection: gpt-4o-mini' + ); + + // The resolver must degrade gracefully to the base utility model rather + // than letting the lookup throw through every copilot-utility-small caller. + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); + + test('uses the gpt-4o-mini family when it is present', async () => { + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4o-mini', CHAT_MODEL.GPT4OMINI), + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilitySmallChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4o-mini'); + }); + + test('sibling CopilotUtilityChatEndpoint.resolve already degrades gracefully', async () => { + // The sibling resolver is unaffected because it uses getCopilotUtilityModel(). + const fetcher = new FakeModelMetadataFetcher([ + chatModel('gpt-4.1', 'gpt-4.1', /* isChatFallback */ true), + ]); + const { service, lastModel } = fakeInstantiationService(); + + await CopilotUtilityChatEndpoint.resolve(fetcher, service); + + expect(lastModel()?.id).toBe('gpt-4.1'); + }); +}); diff --git a/extensions/copilot/src/platform/env/common/envService.ts b/extensions/copilot/src/platform/env/common/envService.ts index 738a322ee445af..035f8322c9f323 100644 --- a/extensions/copilot/src/platform/env/common/envService.ts +++ b/extensions/copilot/src/platform/env/common/envService.ts @@ -135,16 +135,24 @@ export abstract class AbstractEnvService implements IEnvService { */ abstract getEditorPluginInfo(): NameAndVersion; - getEditorVersionHeaders(): { [key: string]: string } { - return { - 'Editor-Version': this.getEditorInfo().format(), - 'Editor-Plugin-Version': this.getEditorPluginInfo().format(), - }; - } - abstract openExternal(target: URI): Promise; } +/** + * Builds the editor-related request headers (`Editor-Version` and `Editor-Plugin-Version`) + * that identify the host editor and the Copilot plugin to the backend. + * + * Implemented as a free function taking any {@link IEnvService} so that it works for every + * environment (VS Code, the standalone chat-lib/CLI host, tests) - including implementations + * that do not extend {@link AbstractEnvService}. + */ +export function getEditorVersionHeaders(envService: IEnvService): { [key: string]: string } { + return { + 'Editor-Version': envService.getEditorInfo().format(), + 'Editor-Plugin-Version': envService.getEditorPluginInfo().format(), + }; +} + // FIXME: This needs to be used in locations where the EnvService is not yet available, so it's // not part of the env service itself. export const isScenarioAutomation = env['IS_SCENARIO_AUTOMATION'] === '1'; \ No newline at end of file diff --git a/extensions/copilot/src/platform/git/common/gitService.ts b/extensions/copilot/src/platform/git/common/gitService.ts index c21f8688d8fbf6..bcdb99d2d24c2e 100644 --- a/extensions/copilot/src/platform/git/common/gitService.ts +++ b/extensions/copilot/src/platform/git/common/gitService.ts @@ -70,7 +70,7 @@ export interface IGitService extends IDisposable { restore(uri: URI, paths: string[], options?: { staged?: boolean; ref?: string }): Promise; createWorktree(uri: URI, options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise; + deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(uri: URI, sourceRepositoryUri: URI, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts index bb6ae6884df16f..012fe6b7cc8f60 100644 --- a/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts +++ b/extensions/copilot/src/platform/git/vscode-node/gitServiceImpl.ts @@ -316,7 +316,7 @@ export class GitServiceImpl extends Disposable implements IGitService { return await repository?.createWorktree(options); } - async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise { const gitAPI = this.gitExtensionService.getExtensionApi(); const repository = gitAPI?.getRepository(uri); return await repository?.deleteWorktree(path, options); diff --git a/extensions/copilot/src/platform/git/vscode/git.d.ts b/extensions/copilot/src/platform/git/vscode/git.d.ts index 2254e384fa069a..33fa62c171c1e8 100644 --- a/extensions/copilot/src/platform/git/vscode/git.d.ts +++ b/extensions/copilot/src/platform/git/vscode/git.d.ts @@ -315,7 +315,7 @@ export interface Repository { dropStash(index?: number): Promise; createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(path: string, options?: { force?: boolean }): Promise; + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts index 6b7f747c059093..3f9beaf603b9d8 100644 --- a/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts +++ b/extensions/copilot/src/platform/ignore/node/test/mockGitService.ts @@ -122,7 +122,7 @@ export class MockGitService implements IGitService { return Promise.resolve(undefined); } - deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean }): Promise { + deleteWorktree(_uri: URI, _path: string, _options?: { force?: boolean; label?: string }): Promise { return Promise.resolve(); } diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts new file mode 100644 index 00000000000000..0a4a922caf0c0e --- /dev/null +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/promptSectionTokens.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Approximate token counts for each section of the NES ("xtab") prompt. + * + * All values are computed with the same char/4 approximation used for prompt + * budgeting (see `XtabProvider.computeTokens`), not an exact tokenizer, so they + * are diagnostics rather than a precise reproduction of the endpoint's + * `promptTokens`. Every count except `systemPrompt` is scoped to the user + * message and reflects the rendered section (including its `<|...|>` tags and + * any inline headers such as `current_file_path:`). + */ +export interface PromptSectionTokenCounts { + /** `recently_viewed_code_snippets` block (in global-budget mode this merges recent docs, language-context snippets and neighbor files). */ + readonly recentlyViewed: number; + /** `current_file_content` block, including the `current_file_path:` header. */ + readonly currentFile: number; + /** Lint errors block (with its surrounding newline padding); 0 when absent. */ + readonly lintErrors: number; + /** Rendered `edit_diff_history` block (including its section tags). */ + readonly editHistory: number; + /** `area_around_code_to_edit` block; 0 for strategies that emit `cursor_location` or neither. */ + readonly areaAroundCodeToEdit: number; + /** `cursor_location` block; 0 for strategies that emit `area_around_code_to_edit` or neither. */ + readonly cursorLocation: number; + /** Related-information language traits (`getRelatedInformation`); 0 when absent. */ + readonly relatedInformation: number; + /** Trailing instruction postScript; 0 when `includePostScript` is false. */ + readonly postScript: number; + /** Formatting glue not attributed to any section: `userPromptTotal - sum(sections)` (newlines, optional backtick fences, trim, and char/4 rounding). May be slightly negative. */ + readonly overhead: number; + /** Total tokens of the final trimmed user prompt. `sum(sections) + overhead === userPromptTotal`. */ + readonly userPromptTotal: number; + /** Tokens of the system message. Not part of the user prompt; filled in by the provider. */ + readonly systemPrompt: number; + + /** + * Breakdown of the {@link recentlyViewed} section into the three sources it + * is assembled from. These do not sum exactly to {@link recentlyViewed} + * because that section also includes the `<|recently_viewed_code_snippets|>` + * tags and the `\n\n` glue joining the individual snippets. + */ + readonly recentlyViewedSubsections: RecentlyViewedSubsectionTokenCounts; +} + +/** + * Approximate (char/4) token counts for the constituent subsections of the + * `recently_viewed_code_snippets` block. Rendered in the order below (recent + * files first, neighbor files last, closest to the current file). + */ +export interface RecentlyViewedSubsectionTokenCounts { + /** Recently-viewed/edited files reconstructed from the xtab edit/view history. */ + readonly recentlyViewedFiles: number; + /** Language-context *snippets* from the language server (the `Snippet`-kind items; distinct from the `relatedInformation` traits section). */ + readonly languageContext: number; + /** Neighbor ("similar") file snippets from the Completions-style provider. */ + readonly neighborFiles: number; +} diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index aeeb53e8c500e5..8e6bf8f62c5f3b 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -3,8 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Result } from '../../../../util/common/result'; import { assertNever } from '../../../../util/vs/base/common/assert'; -import { IValidator, vBoolean, vEnum, vNumber, vObj, vRequired, vString, vUndefined, vUnion } from '../../../configuration/common/validator'; +import { IValidator, vArray, vBoolean, vEnum, vNumber, vObj, vRequired, vString, vUndefined, vUnion } from '../../../configuration/common/validator'; import { ImportChanges } from './importFilteringOptions'; export enum IncludeLineNumbersOption { @@ -82,8 +83,14 @@ export type DiffHistoryOptions = { }; /** - * Parts that participate in the global-budget cascade. `currentFile` and lint - * output are intentionally excluded and continue to use their own per-part caps. + * Parts that are rendered by the global-budget cascade and listed in `order`. + * Lint output is intentionally excluded and keeps its own per-part shape. + * + * `currentFile` is NOT in this set: it is clipped outside the cascade (around the + * cursor) but still draws an allocation from the pool via {@link GlobalBudgetSharePart}. + * It is clipped LAST (after the cascade) to its share plus whatever budget the + * cascade left unused, so it only ever receives leftover and never donates into + * the cascade. */ export type GlobalBudgetPart = | 'recentlyViewedDocuments' @@ -91,30 +98,43 @@ export type GlobalBudgetPart = | 'neighborFiles' | 'diffHistory'; +/** + * Parts that receive a `shares` allocation from the pool: every rendered + * {@link GlobalBudgetPart} plus `currentFile`, which is sized from the pool but + * clipped externally (see {@link GlobalBudgetOptions.currentFileBudget}). + */ +export type GlobalBudgetSharePart = GlobalBudgetPart | 'currentFile'; + /** * Opt-in global-budget allocation modelled after the cascade in * `CascadingPromptFactory` (completions-core): every participating part gets a - * percentage share of a single `totalTokens` pool, parts are rendered in - * `order`, and any unused tokens in one part cascade as surplus to the next. + * percentage share of a single `totalTokens` pool, the rendered parts are emitted + * in `order`, and any unused tokens in one part cascade as surplus to the next. + * + * `currentFile` participates through `shares` only: it is clipped LAST, after the + * cascade runs, to its share of the pool plus whatever budget the cascade left + * unused (its `finalSurplus`), so it reuses the leftover and trims less. * * When `undefined` (the default), each part uses its own `maxTokens` cap as * before and no cross-part budget reuse happens. */ export type GlobalBudgetOptions = { readonly totalTokens: number; - /** Cascade order. Earlier parts get budget first; their surplus flows to later parts. */ + /** Cascade render order. Earlier parts get budget first; their surplus flows to later parts. `currentFile` is not listed here. */ readonly order: readonly GlobalBudgetPart[]; - /** Share of `totalTokens` allocated to each part. Must sum to 1 across `order`. */ - readonly shares: Readonly>; + /** Share of `totalTokens` allocated to each part. Must sum to ~1 across `order` plus `currentFile`. */ + readonly shares: Readonly>; }; export namespace GlobalBudgetOptions { /** - * Default cascade: language context donates first (often disabled), then - * recently-viewed documents (always-on, accepts most of the surplus), then - * neighbor files (must run after recently-viewed because it consults - * `docsInPrompt` to avoid duplicating recently-viewed documents), then - * diff history. + * Default render order: language context first (often disabled, donates its + * share onward), then recently-viewed documents (always-on, accepts most of + * the surplus), then neighbor files (must run after recently-viewed because it + * consults `docsInPrompt` to avoid duplicating recently-viewed documents), + * then diff history. `currentFile` is sized from the pool but clipped last + * (after the cascade, so it can reuse the cascade's leftover), so it is not + * part of this order. */ export const DEFAULT_ORDER: readonly GlobalBudgetPart[] = [ 'languageContext', @@ -123,15 +143,149 @@ export namespace GlobalBudgetOptions { 'diffHistory', ]; - /** Shares matching today's per-part `maxTokens` ratios (volume-neutral baseline). */ - export const DEFAULT_SHARES: Readonly> = { - recentlyViewedDocuments: 2 / 6, - languageContext: 2 / 6, - neighborFiles: 1 / 6, - diffHistory: 1 / 6, + /** + * Shares matching today's per-part `maxTokens` ratios (volume-neutral + * baseline): each part's share is its legacy cap divided by the pool total + * ({@link DEFAULT_TOTAL_TOKENS}), so `floor(totalTokens * share)` reproduces + * that cap exactly. Sum to 1 across all parts. + */ + export const DEFAULT_SHARES: Readonly> = { + currentFile: 1500 / 7500, + recentlyViewedDocuments: 2000 / 7500, + languageContext: 2000 / 7500, + neighborFiles: 1000 / 7500, + diffHistory: 1000 / 7500, }; - export const DEFAULT_TOTAL_TOKENS = 6000; + /** + * The pool size: the sum of today's per-part `maxTokens` caps + * (`currentFile` 1500 + `recentlyViewedDocuments` 2000 + `languageContext` + * 2000 + `neighborFiles` 1000 + `diffHistory` 1000), so the default global + * budget neither grows nor shrinks the total available budget. + */ + export const DEFAULT_TOTAL_TOKENS = 7500; + + /** + * The token budget allotted to `currentFile` from the pool: `floor(totalTokens + * * shares.currentFile)`. Used to override the per-part `currentFile.maxTokens` + * cap when clipping the current file under a global budget. + */ + export function currentFileBudget(globalBudget: GlobalBudgetOptions): number { + return Math.max(0, Math.floor(globalBudget.totalTokens * (globalBudget.shares.currentFile ?? 0))); + } + + /** + * Validate {@link GlobalBudgetOptions} since it is runtime-configurable + * (e.g. via experiments). Catches misconfigurations that would otherwise + * cause silent, hard-to-debug behavior: + * - `totalTokens` not a finite, non-negative number + * - duplicate parts in `order` (would render the same part twice) + * - missing share for any part in `order` or for `currentFile` + * - any share not a finite, non-negative number (negative shares would break + * budget conservation: a negative allocation clamps to 0 yet still counts + * toward the share sum, letting the remaining parts over-allocate past the pool) + * - shares not summing to ~1 across `order` plus `currentFile` (would over/under-allocate) + * - `neighborFiles` ordered before `recentlyViewedDocuments` (the former + * consults `docsInPrompt` populated by the latter) + */ + export function validate(globalBudget: GlobalBudgetOptions): void { + if (!Number.isFinite(globalBudget.totalTokens) || globalBudget.totalTokens < 0) { + throw new Error(`globalBudget.totalTokens must be a finite, non-negative number, got ${globalBudget.totalTokens}`); + } + + const seen = new Set(); + for (const part of globalBudget.order) { + if (seen.has(part)) { + throw new Error(`globalBudget.order contains duplicate part '${part}'`); + } + seen.add(part); + if (typeof globalBudget.shares[part] !== 'number') { + throw new Error(`globalBudget.shares is missing entry for '${part}'`); + } + if (!Number.isFinite(globalBudget.shares[part]) || globalBudget.shares[part] < 0) { + throw new Error(`globalBudget.shares['${part}'] must be a finite, non-negative number, got ${globalBudget.shares[part]}`); + } + } + + if (typeof globalBudget.shares.currentFile !== 'number') { + throw new Error(`globalBudget.shares is missing entry for 'currentFile'`); + } + if (!Number.isFinite(globalBudget.shares.currentFile) || globalBudget.shares.currentFile < 0) { + throw new Error(`globalBudget.shares['currentFile'] must be a finite, non-negative number, got ${globalBudget.shares.currentFile}`); + } + + const recentIdx = globalBudget.order.indexOf('recentlyViewedDocuments'); + const neighborIdx = globalBudget.order.indexOf('neighborFiles'); + if (recentIdx !== -1 && neighborIdx !== -1 && neighborIdx < recentIdx) { + throw new Error(`globalBudget.order must place 'recentlyViewedDocuments' before 'neighborFiles'`); + } + + const sharesSum = globalBudget.order.reduce((sum, part) => sum + globalBudget.shares[part], 0) + globalBudget.shares.currentFile; + const epsilon = 1e-3; + if (Math.abs(sharesSum - 1) > epsilon) { + throw new Error(`globalBudget.shares across order must sum to ~1, got ${sharesSum}`); + } + } + + /** + * Structural validator for the experiment-driven JSON config string. Every + * top-level field is optional; omitted fields fall back to the defaults in + * {@link fromConfigString}. When `shares` is provided it must list every + * part (the rendered cascade parts plus `currentFile`) so the pool stays + * fully allocated; partial `shares` objects are rejected. + */ + export const VALIDATOR = vObj({ + 'totalTokens': vUnion(vNumber(), vUndefined()), + 'order': vUnion(vArray(vEnum('languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory')), vUndefined()), + 'shares': vUnion(vObj({ + 'currentFile': vRequired(vNumber()), + 'recentlyViewedDocuments': vRequired(vNumber()), + 'languageContext': vRequired(vNumber()), + 'neighborFiles': vRequired(vNumber()), + 'diffHistory': vRequired(vNumber()), + }), vUndefined()), + }); + + /** + * Parse the single experiment-driven JSON config string (modelled after + * `modelConfigurationString`) into a fully-populated {@link GlobalBudgetOptions}. + * + * Any field absent from the JSON falls back to its `DEFAULT_*` value, so + * `'{}'` enables the global budget with the volume-neutral defaults and + * `'{"totalTokens":6000}'` only overrides the pool size. The merged result is + * then run through {@link validate} so semantic misconfigurations (bad share + * sums, duplicate/misordered parts) are reported. + * + * Returns a {@link Result.error} (never throws) so callers can fall back to a + * disabled global budget and report the error via telemetry. + */ + export function fromConfigString(configString: string): Result { + let parsed: unknown; + try { + parsed = JSON.parse(configString); + } catch (e) { + return Result.error(`Failed to parse globalBudget config string: ${e instanceof Error ? e.message : String(e)}`); + } + + const { content, error } = VALIDATOR.validate(parsed); + if (error) { + return Result.error(`globalBudget config validation failed: ${error.message}`); + } + + const options: GlobalBudgetOptions = { + totalTokens: content.totalTokens ?? DEFAULT_TOTAL_TOKENS, + order: content.order ?? DEFAULT_ORDER, + shares: content.shares ?? DEFAULT_SHARES, + }; + + try { + validate(options); + } catch (e) { + return Result.error(e instanceof Error ? e.message : String(e)); + } + + return Result.ok(options); + } } export type PagedClipping = { pageSize: number }; diff --git a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts index 8b5ee481c9248a..3fa58e7837e45c 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/inlineEditLogContext.ts @@ -15,6 +15,7 @@ import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRa import { SerializedEdit } from './dataTypes/editUtils'; import { FetchCancellationError } from './dataTypes/fetchCancellationError'; import { LanguageContextResponse, SerializedContextResponse, serializeLanguageContext } from './dataTypes/languageContext'; +import { PromptSectionTokenCounts } from './dataTypes/promptSectionTokens'; import { RootedLineEdit } from './dataTypes/rootedLineEdit'; import { DebugRecorderBookmark } from './debugRecorderBookmark'; import { ISerializedNextEditRequest, StatelessNextEditRequest } from './statelessNextEditProvider'; @@ -177,6 +178,31 @@ export class InlineEditRequestLogContext { lines.push('\n\n'); } + if (this._promptSectionTokens) { + const t = this._promptSectionTokens; + lines.push(`## Prompt section tokens ${fromCacheStatus}`); + lines.push('
Click to view\n'); + lines.push('Approximate (char/4) token counts per prompt section.\n'); + lines.push('Indented `↳` rows break down `recently_viewed_code_snippets` by source; they do not sum exactly to the section total (section tags and inter-snippet newline glue are excluded).\n'); + lines.push('| Section | Tokens |'); + lines.push('| --- | --- |'); + lines.push(`| system_prompt | ${t.systemPrompt} |`); + lines.push(`| recently_viewed_code_snippets | ${t.recentlyViewed} |`); + lines.push(`|   ↳ recently viewed files (xtab history) | ${t.recentlyViewedSubsections.recentlyViewedFiles} |`); + lines.push(`|   ↳ language context | ${t.recentlyViewedSubsections.languageContext} |`); + lines.push(`|   ↳ neighbor files | ${t.recentlyViewedSubsections.neighborFiles} |`); + lines.push(`| current_file_content | ${t.currentFile} |`); + lines.push(`| lint_errors | ${t.lintErrors} |`); + lines.push(`| edit_diff_history | ${t.editHistory} |`); + lines.push(`| area_around_code_to_edit | ${t.areaAroundCodeToEdit} |`); + lines.push(`| cursor_location | ${t.cursorLocation} |`); + lines.push(`| related_information | ${t.relatedInformation} |`); + lines.push(`| post_script | ${t.postScript} |`); + lines.push(`| overhead (newlines / backticks / trim) | ${t.overhead} |`); + lines.push(`| **user_prompt_total** | **${t.userPromptTotal}** |`); + lines.push('\n
\n'); + } + if (this._isAccepted !== undefined) { lines.push(`## Accepted : ${this._isAccepted ? 'Yes' : 'No'}`); } @@ -302,6 +328,7 @@ export class InlineEditRequestLogContext { if (logContextOfCachedEdit._prompt) { this._prompt = logContextOfCachedEdit._prompt; } + this._promptSectionTokens = logContextOfCachedEdit._promptSectionTokens ?? this._promptSectionTokens; this.response = logContextOfCachedEdit.response ?? this.response; this._responseResults = logContextOfCachedEdit._responseResults ?? this._responseResults; if (logContextOfCachedEdit.fullResponsePromise) { @@ -331,6 +358,7 @@ export class InlineEditRequestLogContext { if (logContextOfReusedRequest._prompt) { this._prompt = logContextOfReusedRequest._prompt; } + this._promptSectionTokens = logContextOfReusedRequest._promptSectionTokens ?? this._promptSectionTokens; this.response = logContextOfReusedRequest.response ?? this.response; this._responseResults = logContextOfReusedRequest._responseResults ?? this._responseResults; if (logContextOfReusedRequest.fullResponsePromise) { @@ -365,6 +393,7 @@ export class InlineEditRequestLogContext { public _prompt: string | undefined = undefined; private _rawMessages: Raw.ChatMessage[] | undefined = undefined; + public _promptSectionTokens: PromptSectionTokenCounts | undefined = undefined; get prompt(): string | undefined { return this._prompt; @@ -385,6 +414,11 @@ export class InlineEditRequestLogContext { this.fireDidChange(); } + setPromptSectionTokens(counts: PromptSectionTokenCounts) { + this._promptSectionTokens = counts; + this.fireDidChange(); + } + /** * Raw chat messages used to construct the cursor-jump (next-cursor-line * prediction) prompt, and the document-line offset range the model can diff --git a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts index b4d3cca449b2c7..706ec30283281f 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/statelessNextEditProvider.ts @@ -21,6 +21,7 @@ import { DocumentId } from './dataTypes/documentId'; import { Edits } from './dataTypes/edit'; import { SerializedEdit } from './dataTypes/editUtils'; import { LanguageId } from './dataTypes/languageId'; +import { PromptSectionTokenCounts } from './dataTypes/promptSectionTokens'; import { DebugRecorderBookmark } from './debugRecorderBookmark'; import { InlineEditRequestLogContext } from './inlineEditLogContext'; import { stringifyChatMessages } from './utils/stringifyChatMessages'; @@ -420,7 +421,9 @@ export interface IStatelessNextEditTelemetry { /* diff history info */ readonly nDiffsInPrompt: number | undefined; - readonly diffTokensInPrompt: number | undefined; + + /* prompt section token counts (approximate char/4 counts; see PromptSectionTokenCounts) */ + readonly promptSectionTokens: PromptSectionTokenCounts | undefined; /* neighbor (similar files) snippets info */ readonly nNeighborSnippetsComputed: number | undefined; @@ -519,7 +522,7 @@ export class StatelessNextEditTelemetryBuilder { cursorJumpPrompt: this._cursorJumpPrompt ? JSON.stringify(this._cursorJumpPrompt.map(({ role, content }) => ({ role, content }))) : undefined, cursorJumpResponse: this._cursorJumpResponse, nDiffsInPrompt: this._nDiffsInPrompt, - diffTokensInPrompt: this._diffTokensInPrompt, + promptSectionTokens: this._promptSectionTokens, nNeighborSnippetsComputed: this._nNeighborSnippetsComputed, nNeighborSnippetsInPrompt: this._nNeighborSnippetsInPrompt, neighborSnippetIndicesInPrompt: this._neighborSnippetIndicesInPrompt, @@ -714,9 +717,9 @@ export class StatelessNextEditTelemetryBuilder { return this; } - private _diffTokensInPrompt: number | undefined; - public setDiffTokensInPrompt(n: number): this { - this._diffTokensInPrompt = n; + private _promptSectionTokens: PromptSectionTokenCounts | undefined; + public setPromptSectionTokens(counts: PromptSectionTokenCounts): this { + this._promptSectionTokens = counts; return this; } diff --git a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts index c2f8bb278776df..31c46134d8ba3f 100644 --- a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts +++ b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { ImportChanges } from '../../common/dataTypes/importFilteringOptions'; -import { applyStrategyConfig, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../../common/dataTypes/xtabPromptOptions'; +import { applyStrategyConfig, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy } from '../../common/dataTypes/xtabPromptOptions'; function baseConfig(overrides: Partial = {}): ModelConfiguration { return { @@ -102,3 +102,155 @@ describe('MODEL_CONFIGURATION_VALIDATOR', () => { expect(result.error).toBeDefined(); }); }); + +describe('GlobalBudgetOptions', () => { + + function gb(overrides: Partial = {}): GlobalBudgetOptions { + return { + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + ...overrides, + }; + } + + describe('volume-neutral defaults', () => { + // Guards the core no-regression promise: enabling the global budget with the + // default total + shares must reproduce today's per-part `maxTokens` caps + // exactly. If anyone changes DEFAULT_SHARES or DEFAULT_TOTAL_TOKENS in a way + // that shifts a part's budget, this fails loudly instead of silently + // shrinking/growing prompts in the experiment arm. + it('reproduce the legacy per-part caps', () => { + const total = GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS; + const shares = GlobalBudgetOptions.DEFAULT_SHARES; + const computed = { + currentFile: Math.floor(total * shares.currentFile), + recentlyViewedDocuments: Math.floor(total * shares.recentlyViewedDocuments), + languageContext: Math.floor(total * shares.languageContext), + neighborFiles: Math.floor(total * shares.neighborFiles), + diffHistory: Math.floor(total * shares.diffHistory), + }; + expect(computed).toEqual({ + currentFile: DEFAULT_OPTIONS.currentFile.maxTokens, + recentlyViewedDocuments: DEFAULT_OPTIONS.recentlyViewedDocuments.maxTokens, + languageContext: DEFAULT_OPTIONS.languageContext.maxTokens, + neighborFiles: DEFAULT_OPTIONS.neighborFiles.maxTokens, + diffHistory: DEFAULT_OPTIONS.diffHistory.maxTokens, + }); + }); + + it('shares sum to exactly 1', () => { + const shares = GlobalBudgetOptions.DEFAULT_SHARES; + const sum = shares.currentFile + shares.recentlyViewedDocuments + shares.languageContext + shares.neighborFiles + shares.diffHistory; + expect(sum).toBe(1); + }); + }); + + describe('currentFileBudget', () => { + it('floors totalTokens * shares.currentFile', () => { + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 2 / 8 } }))).toBe(2000); + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 999, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 1 / 3 } }))).toBe(333); + }); + + it('clamps at 0 for a zero share', () => { + expect(GlobalBudgetOptions.currentFileBudget(gb({ totalTokens: 8000, shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0 } }))).toBe(0); + }); + }); + + describe('validate', () => { + it('accepts the defaults', () => { + expect(() => GlobalBudgetOptions.validate(gb())).not.toThrow(); + }); + + it('throws on a duplicate part in order', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + order: ['languageContext', 'languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory'], + }))).toThrow(/duplicate part 'languageContext'/); + }); + + it('throws when shares omit currentFile', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { languageContext: 0.25, recentlyViewedDocuments: 0.25, neighborFiles: 0.25, diffHistory: 0.25 } as GlobalBudgetOptions['shares'], + }))).toThrow(/missing entry for 'currentFile'/); + }); + + it('throws when shares do not sum to ~1 across order plus currentFile', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: 0.9 }, + }))).toThrow(/shares across order must sum to ~1/); + }); + + it('throws when neighborFiles is ordered before recentlyViewedDocuments', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + order: ['languageContext', 'neighborFiles', 'recentlyViewedDocuments', 'diffHistory'], + }))).toThrow(/must place 'recentlyViewedDocuments' before 'neighborFiles'/); + }); + + it('throws on a negative totalTokens', () => { + expect(() => GlobalBudgetOptions.validate(gb({ totalTokens: -1 }))).toThrow(/totalTokens must be a finite, non-negative number/); + }); + + it('throws on a negative share (which would break budget conservation)', () => { + // Sums to 1 so the legacy sum check passes, but a negative share clamps to + // a 0 allocation yet still counts toward the sum, letting other parts + // over-allocate past the pool. Must be rejected. + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { currentFile: 0.25, languageContext: -0.25, recentlyViewedDocuments: 1.0, neighborFiles: 0, diffHistory: 0 }, + }))).toThrow(/must be a finite, non-negative number/); + }); + + it('throws on a non-finite share', () => { + expect(() => GlobalBudgetOptions.validate(gb({ + shares: { ...GlobalBudgetOptions.DEFAULT_SHARES, currentFile: Number.NaN }, + }))).toThrow(/must be a finite, non-negative number/); + }); + }); + + describe('fromConfigString', () => { + it('fills every omitted field with the defaults', () => { + const result = GlobalBudgetOptions.fromConfigString('{}'); + expect(result.isOk() && result.val).toEqual({ + totalTokens: GlobalBudgetOptions.DEFAULT_TOTAL_TOKENS, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + }); + + it('overrides only the fields present in the JSON', () => { + const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 6000 })); + expect(result.isOk() && result.val).toEqual({ + totalTokens: 6000, + order: GlobalBudgetOptions.DEFAULT_ORDER, + shares: GlobalBudgetOptions.DEFAULT_SHARES, + }); + }); + + it('parses a fully-specified config and ignores unknown keys', () => { + const order: GlobalBudgetOptions['order'] = ['languageContext', 'recentlyViewedDocuments', 'neighborFiles', 'diffHistory']; + const shares: GlobalBudgetOptions['shares'] = { currentFile: 0.4, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 }; + const result = GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: 12000, order, shares, unknown: 'ignored' })); + expect(result.isOk() && result.val).toEqual({ totalTokens: 12000, order, shares }); + }); + + it('errors on malformed JSON', () => { + expect(GlobalBudgetOptions.fromConfigString('{ not json').isError()).toBe(true); + }); + + it('errors when a field has the wrong type', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ totalTokens: '6000' })).isError()).toBe(true); + }); + + it('errors on an unknown part in order', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ order: ['languageContext', 'bogus'] })).isError()).toBe(true); + }); + + it('errors when shares are partial (every part is required)', () => { + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares: { currentFile: 1 } })).isError()).toBe(true); + }); + + it('errors when the merged config is semantically invalid', () => { + const shares = { currentFile: 0.9, languageContext: 0.2, recentlyViewedDocuments: 0.2, neighborFiles: 0.1, diffHistory: 0.1 }; + expect(GlobalBudgetOptions.fromConfigString(JSON.stringify({ shares })).isError()).toBe(true); + }); + }); +}); diff --git a/extensions/copilot/src/platform/networking/common/networking.ts b/extensions/copilot/src/platform/networking/common/networking.ts index 5ae09254cda3da..394c09b5c69e8d 100644 --- a/extensions/copilot/src/platform/networking/common/networking.ts +++ b/extensions/copilot/src/platform/networking/common/networking.ts @@ -77,7 +77,7 @@ export interface IEndpointBody { prediction?: Prediction; messages?: any[]; n?: number; - reasoning?: { effort?: string; summary?: string; context?: 'current_turn' | 'all_turns' }; + reasoning?: { effort?: string; summary?: string }; tool_choice?: OptionalChatRequestParams['tool_choice'] | { type: 'function'; name: string } | string; top_logprobs?: number; intent?: boolean; @@ -340,6 +340,7 @@ export interface IChatEndpoint extends IEndpoint { readonly showInModelPicker: boolean; readonly isPremium?: boolean; readonly degradationReason?: string; + readonly warningText?: Record; readonly multiplier?: number; readonly restrictedToSkus?: string[]; /** diff --git a/extensions/copilot/src/platform/proxyModels/node/proxyModelsService.ts b/extensions/copilot/src/platform/proxyModels/node/proxyModelsService.ts index 57a6aab70cec5b..e0f45e6febfe88 100644 --- a/extensions/copilot/src/platform/proxyModels/node/proxyModelsService.ts +++ b/extensions/copilot/src/platform/proxyModels/node/proxyModelsService.ts @@ -12,6 +12,7 @@ import { autorun, observableFromEvent } from '../../../util/vs/base/common/obser import { CopilotToken } from '../../authentication/common/copilotToken'; import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStore'; import { ICAPIClientService } from '../../endpoint/common/capiClient'; +import { getEditorVersionHeaders, IEnvService } from '../../env/common/envService'; import { WireTypes } from '../../inlineEdits/common/dataTypes/inlineEditsModelsTypes'; import { ILogService } from '../../log/common/logService'; import { IFetcherService, Response } from '../../networking/common/fetcherService'; @@ -30,6 +31,7 @@ export class ProxyModelsService extends Disposable implements IProxyModelsServic @ICAPIClientService private readonly _capiClient: ICAPIClientService, @IFetcherService private readonly _fetchService: IFetcherService, @ILogService private readonly _logService: ILogService, + @IEnvService private readonly _envService: IEnvService, ) { super(); @@ -89,6 +91,7 @@ export class ProxyModelsService extends Disposable implements IProxyModelsServic r = await this._fetchService.fetch(url, { headers: { 'Authorization': `Bearer ${copilotToken.token}`, + ...getEditorVersionHeaders(this._envService), }, method: 'GET', timeout: 10_000, diff --git a/extensions/copilot/src/platform/proxyModels/test/node/proxyModelsService.spec.ts b/extensions/copilot/src/platform/proxyModels/test/node/proxyModelsService.spec.ts new file mode 100644 index 00000000000000..421839b28a6c96 --- /dev/null +++ b/extensions/copilot/src/platform/proxyModels/test/node/proxyModelsService.spec.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { suite, test } from 'vitest'; +import { Event } from '../../../../util/vs/base/common/event'; +import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation'; +import { CopilotToken, createTestExtendedTokenInfo } from '../../../authentication/common/copilotToken'; +import { ICopilotTokenStore } from '../../../authentication/common/copilotTokenStore'; +import { getEditorVersionHeaders, IEnvService } from '../../../env/common/envService'; +import { FetchOptions, IAbortController, IFetcherService, PaginationOptions, Response, WebSocketConnection } from '../../../networking/common/fetcherService'; +import { createFakeResponse } from '../../../test/node/fetcher'; +import { createPlatformServices } from '../../../test/node/services'; +import { ProxyModelsService } from '../../node/proxyModelsService'; + +suite('ProxyModelsService', function () { + + test('includes editor-related headers when fetching the models list', async function () { + let capturedHeaders: { [name: string]: string } | undefined; + + class CapturingFetcherService implements IFetcherService { + declare readonly _serviceBrand: undefined; + readonly onDidFetch = Event.None; + readonly onDidCompleteFetch = Event.None; + + getUserAgentLibrary(): string { + return 'test'; + } + fetch(url: string, options: FetchOptions): Promise { + capturedHeaders = options.headers; + return Promise.resolve(createFakeResponse(200, { models: [] })); + } + createWebSocket(): WebSocketConnection { + throw new Error('Method not implemented.'); + } + disconnectAll(): Promise { + throw new Error('Method not implemented.'); + } + makeAbortController(): IAbortController { + return new AbortController(); + } + isAbortError(): boolean { + return false; + } + isInternetDisconnectedError(): boolean { + return false; + } + isFetcherError(): boolean { + return false; + } + isNetworkProcessCrashedError(): boolean { + return false; + } + getUserMessageForFetcherError(): string { + throw new Error('Method not implemented.'); + } + fetchWithPagination(_baseUrl: string, _options: PaginationOptions): Promise { + throw new Error('Method not implemented.'); + } + } + + const testingServiceCollection = createPlatformServices(); + testingServiceCollection.define(IFetcherService, new CapturingFetcherService()); + const accessor = testingServiceCollection.createTestingAccessor(); + + // Seed the token store so the service fetches the models list on construction. + accessor.get(ICopilotTokenStore).copilotToken = new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })); + + const service = accessor.get(IInstantiationService).createInstance(ProxyModelsService); + try { + await Event.toPromise(service.onModelListUpdated); + } finally { + service.dispose(); + } + + assert.deepStrictEqual(capturedHeaders, { + 'Authorization': `Bearer test-token`, + ...getEditorVersionHeaders(accessor.get(IEnvService)), + }); + }); +}); diff --git a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts index f09ba0ae0f3be9..d0c572b2ed1999 100644 --- a/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts +++ b/extensions/copilot/src/platform/test/node/simulationWorkspaceServices.ts @@ -801,7 +801,7 @@ export class TestingGitService implements IGitService { return undefined; } - async deleteWorktree(uri: URI, path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(uri: URI, path: string, options?: { force?: boolean; label?: string }): Promise { return; } diff --git a/extensions/copilot/src/util/common/test/shims/chatTypes.ts b/extensions/copilot/src/util/common/test/shims/chatTypes.ts index e911a840d28ced..cdbce3da03115f 100644 --- a/extensions/copilot/src/util/common/test/shims/chatTypes.ts +++ b/extensions/copilot/src/util/common/test/shims/chatTypes.ts @@ -186,6 +186,20 @@ export class ChatResponsePullRequestPart { } +export class ChatResponseAutoModeResolutionPart { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: string; + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number) { + this.resolvedModel = resolvedModel; + this.resolvedModelName = resolvedModelName; + this.predictedLabel = predictedLabel; + this.confidence = confidence; + } +} + + export class ChatResponseCodeCitationPart { value: vscode.Uri; license: string; diff --git a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts index 35a1d656b56cdb..a2aa56f6953b43 100644 --- a/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts +++ b/extensions/copilot/src/util/common/test/shims/vscodeTypesShim.ts @@ -18,7 +18,7 @@ import { SnippetString } from '../../../vs/workbench/api/common/extHostTypes/sni import { SnippetTextEdit } from '../../../vs/workbench/api/common/extHostTypes/snippetTextEdit'; import { SymbolInformation, SymbolKind } from '../../../vs/workbench/api/common/extHostTypes/symbolInformation'; import { EndOfLine, TextEdit } from '../../../vs/workbench/api/common/extHostTypes/textEdit'; -import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; +import { AISearchKeyword, ChatErrorLevel, ChatInputNotificationSeverity, ChatQuestion, ChatQuestionType, ChatReferenceBinaryData, ChatReferenceDiagnostic, ChatRequestEditedFileEventKind, ChatRequestEditorData, ChatRequestNotebookData, ChatRequestTurn, ChatRequestTurn2, ChatResponseAnchorPart, ChatResponseAutoModeResolutionPart, ChatResponseClearToPreviousToolInvocationReason, ChatResponseCodeblockUriPart, ChatResponseCodeCitationPart, ChatResponseCommandButtonPart, ChatResponseConfirmationPart, ChatResponseExtensionsPart, ChatResponseExternalEditPart, ChatResponseFileTreePart, ChatResponseHookPart, ChatResponseInfoPart, ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart, ChatResponseMovePart, ChatResponseNotebookEditPart, ChatResponseProgressPart, ChatResponseProgressPart2, ChatResponsePullRequestPart, ChatResponseQuestionCarouselPart, ChatResponseReferencePart, ChatResponseReferencePart2, ChatResponseTextEditPart, ChatResponseThinkingProgressPart, ChatResponseTurn, ChatResponseTurn2, ChatResponseWarningPart, ChatResponseWorkspaceEditPart, ChatSessionStatus, ChatSubagentToolInvocationData, ChatToolInvocationPart, ExcludeSettingOptions, LanguageModelChatMessage, LanguageModelChatMessageRole, LanguageModelChatToolMode, LanguageModelDataPart, LanguageModelDataPart2, LanguageModelError, LanguageModelPartAudience, LanguageModelPromptTsxPart, LanguageModelTextPart, LanguageModelTextPart2, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolExtensionSource, LanguageModelToolMCPSource, LanguageModelToolResult, LanguageModelToolResult2, LanguageModelToolResultPart, LanguageModelToolResultPart2, McpHttpServerDefinition, McpStdioServerDefinition, McpToolInvocationContentData, TextSearchMatch2 } from './chatTypes'; import { TextDocumentChangeReason, TextEditorSelectionChangeKind, WorkspaceEdit } from './editing'; import { ChatLocation, ChatVariableLevel, DiagnosticSeverity, ExtensionMode, FileType, TextEditorCursorStyle, TextEditorLineNumbersStyle, TextEditorRevealType } from './enums'; import { t } from './l10n'; @@ -106,6 +106,7 @@ const shim: typeof vscodeTypes = { TerminalShellExecutionCommandLineConfidence, ChatRequestEditedFileEventKind, ChatResponsePullRequestPart, + ChatResponseAutoModeResolutionPart, LanguageModelTextPart2, LanguageModelDataPart2, LanguageModelThinkingPart, diff --git a/extensions/copilot/src/vscodeTypes.ts b/extensions/copilot/src/vscodeTypes.ts index 6b9519e05a6b67..a931ec06d7ccc1 100644 --- a/extensions/copilot/src/vscodeTypes.ts +++ b/extensions/copilot/src/vscodeTypes.ts @@ -42,6 +42,7 @@ export import ChatResponseMovePart = vscode.ChatResponseMovePart; export import ChatResponseExtensionsPart = vscode.ChatResponseExtensionsPart; export import ChatResponseExternalEditPart = vscode.ChatResponseExternalEditPart; export import ChatResponsePullRequestPart = vscode.ChatResponsePullRequestPart; +export import ChatResponseAutoModeResolutionPart = vscode.ChatResponseAutoModeResolutionPart; export import ChatResponseMarkdownWithVulnerabilitiesPart = vscode.ChatResponseMarkdownWithVulnerabilitiesPart; export import ChatResponseCodeblockUriPart = vscode.ChatResponseCodeblockUriPart; export import ChatResponseTextEditPart = vscode.ChatResponseTextEditPart; diff --git a/extensions/copilot/test/base/simulationOptions.ts b/extensions/copilot/test/base/simulationOptions.ts index c0ddce04585af9..e73de4640a321c 100644 --- a/extensions/copilot/test/base/simulationOptions.ts +++ b/extensions/copilot/test/base/simulationOptions.ts @@ -16,12 +16,42 @@ export enum NesDatagenSampleTask { CursorBoth = 'cursor-both', } +/** + * Shape of the recordings in the nes-datagen input file. + */ +export enum NesDatagenInputFormat { + /** Per-request "alternative action" recordings bookmarked at the NES request time. */ + AlternativeAction = 'alternative-action', + /** Continuous enhanced-telemetry slices with no request bookmark; a pivot is synthesized. */ + Continuous = 'continuous', +} + +/** + * How to choose the pivot in a continuous recording (only meaningful when + * `--input-format=continuous`). The pivot splits the timeline into context and + * the oracle (next user edit). + */ +export enum PivotStrategy { + /** Pick a single eligible pivot uniformly at random. */ + Random = 'random', +} + export type NesDatagen = { readonly input: string; readonly output: string | undefined; readonly rowOffset: number; readonly workerMode: boolean; readonly sampleTask: NesDatagenSampleTask; + /** Shape of the input recordings. */ + readonly inputFormat: NesDatagenInputFormat; + /** Pivot selection strategy for continuous recordings. Ignored for alternative-action input. */ + readonly pivotStrategy: PivotStrategy; + /** + * Seed for the continuous pivot RNG. Resolved once (random when `--seed` is + * omitted) so it can be propagated to all parallel workers for reproducible + * output. Ignored for alternative-action input. + */ + readonly seed: number; /** Minimum same-file lines above the request cursor for a move to count as a jump. */ readonly sameFileJumpMinAbove: number; /** Minimum same-file lines below the request cursor for a move to count as a jump. */ @@ -195,6 +225,9 @@ export class SimulationOptions { rowOffset: typeof argv['row-offset'] === 'number' ? argv['row-offset'] : 0, workerMode: boolean(argv['worker'], false), sampleTask: SimulationOptions.validateSampleTask(argv['sample-task']), + inputFormat: SimulationOptions.validateInputFormat(argv['input-format']), + pivotStrategy: SimulationOptions.validatePivotStrategy(argv['pivot-strategy']), + seed: SimulationOptions.resolveSeed(argv['seed']), sameFileJumpMinAbove: typeof argv['same-file-jump-min-above'] === 'number' ? argv['same-file-jump-min-above'] : 2, sameFileJumpMinBelow: typeof argv['same-file-jump-min-below'] === 'number' ? argv['same-file-jump-min-below'] : 5, } @@ -275,6 +308,14 @@ export class SimulationOptions { ` --input Path to a JSON or JSON Lines file with training data recordings (required)`, ` Format is inferred from the extension: .jsonl/.ndjson → JSON Lines, otherwise JSON array`, ` --out Output path for the JSON Lines file. Default: _output.jsonl`, + ` --input-format Shape of the input recordings (default: alternative-action)`, + ` Values: alternative-action, continuous`, + ` alternative-action → per-request recordings bookmarked at the NES request time`, + ` continuous → continuous enhanced-telemetry slices; a pivot is synthesized`, + ` --pivot-strategy How to pick the pivot in a continuous recording (default: random; only for --input-format=continuous)`, + ` Values: random`, + ` random → pick a single eligible pivot uniformly at random`, + ` --seed Integer seed for the continuous pivot RNG (default: random, logged for reproducibility)`, ` --sample-task Which target to generate (default: xtab)`, ` Values: xtab, cursor-same-file, cursor-cross-file, cursor-both`, ` xtab → edit-prediction sample (assistant = an edit)`, @@ -298,6 +339,8 @@ export class SimulationOptions { ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-same-file`, ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-cross-file`, ` npm run simulate -- --config-file=config.json nes-datagen --input=data.json --sample-task=cursor-both --same-file-jump-min-above=8 --same-file-jump-min-below=8`, + ` npm run simulate -- --config-file=config.json nes-datagen --input=continuous.jsonl --input-format=continuous`, + ` npm run simulate -- --config-file=config.json nes-datagen --input=continuous.jsonl --input-format=continuous --pivot-strategy=random --seed=42`, ``, ].join('\n')); } @@ -349,6 +392,49 @@ export class SimulationOptions { } return value as NesDatagenSampleTask; } + + private static validateInputFormat(value: unknown): NesDatagenInputFormat { + if (value === undefined || value === null) { + return NesDatagenInputFormat.AlternativeAction; + } + if (typeof value !== 'string') { + throw new Error(`--input-format must be a string, but got: ${typeof value}`); + } + const allowed = Object.values(NesDatagenInputFormat) as string[]; + if (!allowed.includes(value)) { + throw new Error(`--input-format must be one of [${allowed.join(', ')}], but got: ${value}`); + } + return value as NesDatagenInputFormat; + } + + private static validatePivotStrategy(value: unknown): PivotStrategy { + if (value === undefined || value === null) { + return PivotStrategy.Random; + } + if (typeof value !== 'string') { + throw new Error(`--pivot-strategy must be a string, but got: ${typeof value}`); + } + const allowed = Object.values(PivotStrategy) as string[]; + if (!allowed.includes(value)) { + throw new Error(`--pivot-strategy must be one of [${allowed.join(', ')}], but got: ${value}`); + } + return value as PivotStrategy; + } + + /** + * Resolve the continuous pivot seed. When `--seed` is omitted a random + * 32-bit seed is generated so that the parent can log it and propagate it to + * every worker, keeping output reproducible. + */ + private static resolveSeed(value: unknown): number { + if (value === undefined || value === null) { + return Math.floor(Math.random() * 0x100000000); + } + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new Error(`--seed must be an integer, but got: ${value}`); + } + return value >>> 0; + } } function cliOptionsToWellKnownEmbeddingsType(model: string | undefined): EmbeddingType | undefined { diff --git a/extensions/copilot/test/pipeline/alternativeAction/processor.ts b/extensions/copilot/test/pipeline/alternativeAction/processor.ts index 2067508d4fee55..98af88e081b2f8 100644 --- a/extensions/copilot/test/pipeline/alternativeAction/processor.ts +++ b/extensions/copilot/test/pipeline/alternativeAction/processor.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IAlternativeAction } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; import { Edits } from '../../../src/platform/inlineEdits/common/dataTypes/edit'; import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; import { StringEdit, StringReplacement } from '../../../src/util/vs/editor/common/core/edits/stringEdit'; @@ -22,17 +21,22 @@ export namespace Processor { } /** - * Split a recording at its NES request bookmark and resolve the active - * document at that moment. Exposed so callers (in particular nes-datagen - * cursor-jump detectors) can reason about what the user did *after* the request - * without re-implementing the same splitting logic that - * {@link createScoringForAlternativeAction} performs internally. + * Split a recording at a pivot time (the NES request bookmark for + * per-request recordings, or a synthesized pivot for continuous ones) and + * resolve the active document at that moment. Exposed so callers (in + * particular nes-datagen cursor-jump detectors and the continuous-recording + * path) can reason about what the user did *after* the pivot without + * re-implementing the same splitting logic that {@link createScoring} + * performs internally. * - * Returns `undefined` if the recording cannot be split (missing - * `requestTime`, empty entries, no resolvable active document, etc.). + * `requestTime` is the pivot: entries with `time <= requestTime` form the + * prior portion, the rest form the post-request portion. + * + * Returns `undefined` if the recording cannot be split (empty entries, pivot + * before all entries, no resolvable active document, etc.). */ - export function splitRecording(altAction: IAlternativeAction): ISplitRecording | undefined { - const processedRecording = splitRecordingAtRequestTime(altAction); + export function splitRecording(entries: LogEntry[], requestTime: number): ISplitRecording | undefined { + const processedRecording = splitRecordingAtRequestTime(entries, requestTime); if (!processedRecording) { return undefined; } @@ -61,13 +65,14 @@ export namespace Processor { }; } - export function createScoringForAlternativeAction( - altAction: IAlternativeAction, + export function createScoring( + entries: LogEntry[], + requestTime: number, proposedEdits: IStringReplacement[], isAccepted: boolean, ): Scoring.t | undefined { - const processedRecording = splitRecordingAtRequestTime(altAction); + const processedRecording = splitRecordingAtRequestTime(entries, requestTime); if (!processedRecording) { log('Could not split recording at request time'); return undefined; @@ -111,24 +116,17 @@ export namespace Processor { return scoring; } - function splitRecordingAtRequestTime(altAction: IAlternativeAction): { + function splitRecordingAtRequestTime(entries: LogEntry[], requestTime: number): { wholeRecording: LogEntry[]; recordingPriorToRequest: LogEntry[]; recordingAfterRequest: LogEntry[]; } | undefined { - if (!altAction.recording) { + if (!entries || entries.length === 0) { return undefined; } - const recording = altAction.recording.entries; - if (!recording || recording.length === 0) { - return undefined; - } - - const requestTime = altAction.recording.requestTime; - - const recordingIdxOfRequestTime = binarySearch(recording, (entry: LogEntry) => { + const recordingIdxOfRequestTime = binarySearch(entries, (entry: LogEntry) => { if (entry.kind === 'meta') { return -1; } else { @@ -141,11 +139,11 @@ export namespace Processor { return undefined; } - const recordingPriorToRequest = recording.slice(0, recordingIdxOfRequestTime + 1); - const recordingAfterRequest = recording.slice(recordingIdxOfRequestTime + 1); + const recordingPriorToRequest = entries.slice(0, recordingIdxOfRequestTime + 1); + const recordingAfterRequest = entries.slice(recordingIdxOfRequestTime + 1); return { - wholeRecording: recording, + wholeRecording: entries, recordingPriorToRequest, recordingAfterRequest }; diff --git a/extensions/copilot/test/pipeline/alternativeAction/util.ts b/extensions/copilot/test/pipeline/alternativeAction/util.ts index deb60faa9abfa8..89eae086117371 100644 --- a/extensions/copilot/test/pipeline/alternativeAction/util.ts +++ b/extensions/copilot/test/pipeline/alternativeAction/util.ts @@ -11,6 +11,14 @@ export function log(...args: any[]) { } } +/** + * Find an index `i` such that `compare(array[i]) === 0`, or — when no exact + * match exists — the index of the greatest element with `compare < 0` + * (`-1` if none). Assumes `array` is sorted ascending by `compare`. + * + * NOTE: when several elements compare equal (e.g. share a timestamp) this + * returns an *arbitrary* one of them, not necessarily the first or last. + */ export function binarySearch( array: readonly T[], compare: (element: T) => number diff --git a/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts b/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts new file mode 100644 index 00000000000000..cb090f89c2bcf9 --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/continuousRecord.spec.ts @@ -0,0 +1,36 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { parseContinuousRecord } from './continuousRecord'; + +const entries = [{ kind: 'documentEncountered', id: 0, time: 1, relativePath: 'a.ts' }]; + +describe('parseContinuousRecord', () => { + it('parses a stringified recording payload', () => { + const recording = { entries, entriesSize: 10, windowStart: 1, windowEnd: 2, sessionId: 's', sequenceNumber: 3 }; + const record = parseContinuousRecord({ recording: JSON.stringify(recording) }, 4); + expect(record).toEqual({ originalRowIndex: 4, value: recording }); + }); + + it('tolerates a pre-parsed recording object (fixtures)', () => { + const recording = { entries, entriesSize: 10 }; + expect(parseContinuousRecord({ recording }, 0).value.entriesSize).toBe(10); + }); + + it('throws when the recording column is missing', () => { + expect(() => parseContinuousRecord({}, 0)).toThrow(/Missing key/); + }); + + it('throws when entries were dropped at send time (over cap)', () => { + const record = { recording: JSON.stringify({ entriesSize: 999999 }) }; + expect(() => parseContinuousRecord(record, 0)).toThrow(/entries is missing or empty/); + }); + + it('throws when entries are empty', () => { + const record = { recording: JSON.stringify({ entries: [], entriesSize: 0 }) }; + expect(() => parseContinuousRecord(record, 0)).toThrow(/entries is missing or empty/); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/continuousRecord.ts b/extensions/copilot/test/pipeline/continuous/continuousRecord.ts new file mode 100644 index 00000000000000..bdf74d5c525fc8 --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/continuousRecord.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IContinuousRecording } from '../../../src/extension/inlineEdits/node/continuousEnhancedTelemetrySender'; +import { ErrorUtils } from '../../../src/util/common/errors'; +import type { WithRowIndex } from '../withRowIndex'; +import { streamJsonRecords } from '../streamJsonRecords'; + +/** + * A single parsed continuous-recording input record (one telemetry slice), + * paired with its 0-based position within the input file / chunk. + */ +export type IContinuousRecord = WithRowIndex; + +/** + * Column carrying the (stringified) recording payload. Matches the telemetry + * property name emitted by `ContinuousEnhancedTelemetrySender`; export queries + * should alias the recording column to `recording`. + */ +const RECORDING_KEY = 'recording'; + +/** + * Parse a single continuous input record. + * + * The `recording` field is, by export convention, a JSON string (the sender + * calls `JSON.stringify(recording)`); a pre-parsed object is also tolerated for + * fixtures and hand-authored inputs. + * + * Throws if the record is missing the recording column or has no usable + * entries — callers collect these as per-record errors rather than aborting. + */ +export function parseContinuousRecord(record: Record, rowIndex: number): IContinuousRecord { + const raw = record[RECORDING_KEY]; + if (raw === undefined || raw === null) { + throw new Error(`Missing key: ${RECORDING_KEY}`); + } + + let recording: IContinuousRecording; + if (typeof raw === 'string') { + recording = JSON.parse(raw) as IContinuousRecording; + } else if (typeof raw === 'object') { + recording = raw as IContinuousRecording; + } else { + throw new Error(`'${RECORDING_KEY}' must be a JSON string or object, got ${typeof raw}`); + } + + if (!recording.entries || recording.entries.length === 0) { + // `entries` is dropped when the payload exceeded the sender cap; such a + // slice carries no edit history and cannot produce a sample. + throw new Error(`recording.entries is missing or empty (entriesSize: ${recording.entriesSize ?? '?'})`); + } + + return { + originalRowIndex: rowIndex, + value: recording, + }; +} + +/** + * Stream-parse a continuous-recording input file (JSON array or JSON Lines), + * validating each record into an {@link IContinuousRecord}. Records that fail + * validation are reported in `errors` (with their row index) rather than + * aborting the load. Mirrors `loadAndParseInput` for the alternative-action path. + */ +export async function loadAndParseContinuousInput(inputPath: string, verbose = false): Promise<{ + records: IContinuousRecord[]; + errors: WithRowIndex[]; +}> { + const records: IContinuousRecord[] = []; + const errors: WithRowIndex[] = []; + + let i = 0; + for await (const record of streamJsonRecords>(inputPath)) { + const rowIndex = i++; + try { + records.push(parseContinuousRecord(record, rowIndex)); + } catch (e) { + errors.push({ originalRowIndex: rowIndex, value: ErrorUtils.fromUnknown(e) }); + } + } + + if (verbose) { + console.log(`Read ${i} continuous records from ${inputPath}`); + } + + return { records, errors }; +} diff --git a/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts b/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts new file mode 100644 index 00000000000000..6b11a22a1305eb --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/pivotStrategy.spec.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { deriveSeed, selectPivots } from './pivotStrategy'; + +const ID = 0; + +function meta(): LogEntry { + return { kind: 'meta', data: { repoRootUri: 'file:///ws' } }; +} +function encountered(time: number): LogEntry { + return { kind: 'documentEncountered', id: ID, time, relativePath: 'a.ts' }; +} +function setContent(time: number): LogEntry { + return { kind: 'setContent', id: ID, time, content: 'x', v: 0 }; +} +function changed(time: number): LogEntry { + return { kind: 'changed', id: ID, time, edit: [[0, 0, 'y']], v: 1 }; +} +function selection(time: number): LogEntry { + return { kind: 'selectionChanged', id: ID, time, selection: [[0, 0]] }; +} + +/** A well-formed recording: meta, encounter, content, selection, then 2 later edits. */ +const recording: LogEntry[] = [meta(), encountered(1), setContent(2), selection(3), changed(4), changed(5)]; + +describe('selectPivots (random)', () => { + it('selects only changed/selectionChanged entries (never framing)', () => { + // Indices: 0 meta, 1 documentEncountered, 2 setContent, 3 selectionChanged, + // 4 changed, 5 changed. Only 3 and 4 are eligible (5 is last → no oracle; + // 1 and 2 are framing). Sweep seeds to exercise the random choice. + const chosen = new Set(); + for (let seed = 0; seed < 50; seed++) { + const pivots = selectPivots(recording, PivotStrategy.Random, Random.create(seed)); + expect(pivots).toHaveLength(1); + chosen.add(pivots[0]); + } + expect([...chosen].sort()).toEqual([3, 4]); + }); + + it('is deterministic for a given seed', () => { + const a = selectPivots(recording, PivotStrategy.Random, Random.create(42)); + const b = selectPivots(recording, PivotStrategy.Random, Random.create(42)); + expect(a).toEqual(b); + }); + + it('rejects records whose only unique-time entries are framing', () => { + // documentEncountered + setContent have unique times but are framing; + // the single changed is last, so no oracle follows it. + const framingOnly: LogEntry[] = [encountered(1), setContent(2), changed(3)]; + expect(selectPivots(framingOnly, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects records with duplicate times (ambiguous split boundary)', () => { + const dup: LogEntry[] = [encountered(1), changed(1), changed(1)]; + expect(selectPivots(dup, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects records with no edit after any candidate', () => { + const noOracle: LogEntry[] = [encountered(1), setContent(2), selection(3)]; + expect(selectPivots(noOracle, PivotStrategy.Random, Random.create(1))).toEqual([]); + }); + + it('rejects an empty recording', () => { + expect(selectPivots([], PivotStrategy.Random, Random.create(1))).toEqual([]); + }); +}); + +describe('deriveSeed', () => { + it('is deterministic and index-dependent', () => { + expect(deriveSeed(100, 5)).toBe(deriveSeed(100, 5)); + expect(deriveSeed(100, 5)).not.toBe(deriveSeed(100, 6)); + expect(deriveSeed(100, 5)).not.toBe(deriveSeed(101, 5)); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts b/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts new file mode 100644 index 00000000000000..8470c9b1c6a2b5 --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/pivotStrategy.ts @@ -0,0 +1,139 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { assertNever } from '../../../src/util/vs/base/common/assert'; +import { PivotStrategy } from '../../base/simulationOptions'; + +/** + * Choose pivot *times* for a recording according to `strategy`. + * + * Continuous slices (from `ContinuousEnhancedTelemetrySender`) have no NES + * request bookmark, so a pivot must be synthesized. A pivot splits the timeline + * into *context* (everything at or before the pivot) and the *oracle* (the + * user's next edits after the pivot). + * + * Returns the `time` of each chosen pivot entry (matching the `requestTime` + * semantics the downstream split expects, see `Processor.splitRecording`). + * Returns an empty array when no eligible pivot exists. + * + * The return type is an array so future strategies (idle-gap, every-edit, ...) + * can yield multiple pivots per record; `random` yields at most one. + */ +export function selectPivots(entries: readonly LogEntry[], strategy: PivotStrategy, rng: Random): number[] { + switch (strategy) { + case PivotStrategy.Random: + return selectRandomPivot(entries, rng); + default: + return assertNever(strategy); + } +} + +function selectRandomPivot(entries: readonly LogEntry[], rng: Random): number[] { + const candidates = eligiblePivotIndices(entries); + if (candidates.length === 0) { + return []; + } + const idx = candidates[rng.nextIntRange(0, candidates.length)]; + return [entryTime(entries[idx])!]; +} + +/** + * Indices `i` that are valid pivot points. An index is eligible when: + * + * - `entries[i].kind` is `changed` or `selectionChanged`. This is the key + * constraint and it satisfies two requirements at once: + * 1. *Replayability.* The replay engine establishes the prefix's active + * document only on `changed`/`selectionChanged` (see + * `ObservableWorkspaceRecordingReplayer`); pivoting on framing entries + * (`documentEncountered`/`setContent`/`opened`) would leave the prefix + * with no active document and fail replay. Because the pivot is also the + * last entry of the prefix, the split's active document (the last + * id-bearing entry) and the replayer's active document coincide. + * 2. *In-window.* Continuous slices carry self-contained framing whose true + * times can pre-date the slice window (see `DebugRecorder.getDocumentLogInRange`), + * whereas `changed`/`selectionChanged` only ever come from in-window + * activity. Restricting to those kinds keeps the pivot inside the window. + * These are also exactly the moments a real NES request fires (after an edit + * or a cursor move), so the synthesized sample mirrors production. + * - `entries[i].time` is *globally unique* across the recording. Uniqueness + * guarantees that splitting by time lands deterministically on index `i` + * (`binarySearch` returns an arbitrary index among equal-time entries), which + * defends the rare case where an in-window event shares a timestamp with + * framing or with another document's event. + * - the active document (`entries[i].id`) was introduced by a + * `documentEncountered` somewhere, so its path is resolvable. + * - the suffix `[i+1..]` holds at least one `changed` event on that active + * document — i.e. a non-empty oracle exists. + * + * @returns eligible indices in ascending order (possibly empty). + */ +function eligiblePivotIndices(entries: readonly LogEntry[]): number[] { + const n = entries.length; + const eligible: number[] = []; + if (n < 2) { + return eligible; + } + + // Single forward pass to compute the set of document ids ever introduced, + // the last index a `changed` occurred per document id, and time frequencies. + const encounteredIds = new Set(); + const lastChangedIdxForId = new Map(); + const timeFrequency = new Map(); + for (let i = 0; i < n; i++) { + const entry = entries[i]; + if (entry.kind === 'documentEncountered') { + encounteredIds.add(entry.id); + } else if (entry.kind === 'changed') { + lastChangedIdxForId.set(entry.id, i); + } + + const time = entryTime(entry); + if (time !== undefined) { + timeFrequency.set(time, (timeFrequency.get(time) ?? 0) + 1); + } + } + + for (let i = 0; i < n - 1; i++) { + const entry = entries[i]; + if (entry.kind !== 'changed' && entry.kind !== 'selectionChanged') { + continue; // pivot must establish a replayable, in-window active document + } + if (timeFrequency.get(entry.time) !== 1) { + continue; // need an unambiguous, deterministic split boundary + } + if (!encounteredIds.has(entry.id)) { + continue; // active document must be resolvable to a path + } + const lastChangedIdx = lastChangedIdxForId.get(entry.id); + if (lastChangedIdx === undefined || lastChangedIdx <= i) { + continue; // a non-empty oracle must follow the pivot on the active doc + } + eligible.push(i); + } + + return eligible; +} + +/** The numeric event time of an entry, or `undefined` for `meta` entries. */ +function entryTime(entry: LogEntry): number | undefined { + return 'time' in entry ? entry.time : undefined; +} + +/** + * Derive a per-record seed from a base seed and a record index using a + * splitmix32-style integer hash. This makes each record's pivot selection + * depend only on `(baseSeed, globalRecordIndex)` — never on how the input is + * chunked across parallel workers — so runs are reproducible from `--seed` + * regardless of `--parallelism`. + */ +export function deriveSeed(baseSeed: number, index: number): number { + let h = (baseSeed ^ (index + 0x9e3779b9)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x21f0aaad) >>> 0; + h = Math.imul(h ^ (h >>> 15), 0x735a2d97) >>> 0; + h = (h ^ (h >>> 15)) >>> 0; + return h; +} diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts new file mode 100644 index 00000000000000..b9f0746a14a5ed --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/processContinuous.spec.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { IContinuousRecord } from './continuousRecord'; +import { CONTINUOUS_SUGGESTION_STATUS, processContinuousRecord, processContinuousRecords } from './processContinuous'; + +const doc = Array.from({ length: 30 }, (_, i) => `// L${String(i).padStart(2, '0')}`).join('\n') + '\n'; + +// Window metadata the sender always ships; irrelevant to pivot/replay logic but +// required by `IContinuousRecording`, so these tests fill it with fixed dummies. +const META = { windowStart: 0, windowEnd: 1, sessionId: 'test', sequenceNumber: 0 }; + +const entries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/x.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: doc, v: 0 }, + { kind: 'selectionChanged', id: 0, time: 1002, selection: [[14, 14]] }, + { kind: 'changed', id: 0, time: 1004, edit: [[175, 175, 'Z']], v: 1 }, + { kind: 'selectionChanged', id: 0, time: 1006, selection: [[175, 175]] }, + { kind: 'changed', id: 0, time: 1008, edit: [[7, 7, 'Q']], v: 2 }, +]; + +function record(): IContinuousRecord { + return { originalRowIndex: 0, value: { entries, entriesSize: 100, ...META } }; +} + +describe('processContinuousRecord', () => { + it('synthesizes an oracle-only row and resolves language from the active file', () => { + const result = processContinuousRecord(record(), 1002); + expect(result.isOk()).toBe(true); + if (result.isError()) { return; } + expect(result.val.row.suggestionStatus).toBe(CONTINUOUS_SUGGESTION_STATUS); + expect(result.val.row.activeDocumentLanguageId).toBe('typescript'); + result.val.replayer.dispose(); + }); + + it('errors when the recording has no entries', () => { + const empty: IContinuousRecord = { originalRowIndex: 0, value: { entries: [], entriesSize: 0, ...META } }; + expect(processContinuousRecord(empty, 0).isError()).toBe(true); + }); +}); + +describe('processContinuousRecords', () => { + it('produces identical pivots across runs with the same seed', () => { + const a = processContinuousRecords([record()], PivotStrategy.Random, 99, 0); + const b = processContinuousRecords([record()], PivotStrategy.Random, 99, 0); + expect(a.processed.length).toBe(b.processed.length); + expect(a.errors).toEqual(b.errors); + [...a.processed, ...b.processed].forEach(p => p.replayer.dispose()); + }); + + it('every selected pivot is replayable (eligible ⟹ replayable)', () => { + // Regression guard: a pivot that `selectPivots` deems eligible must always + // produce a real sample, never a replay error. Sweep seeds so different + // eligible pivots (the in-window selectionChanged/changed entries) are hit. + for (let seed = 0; seed < 50; seed++) { + const { processed, errors } = processContinuousRecords([record()], PivotStrategy.Random, seed, 0); + expect(errors).toEqual([]); + expect(processed).toHaveLength(1); + processed.forEach(p => p.replayer.dispose()); + } + }); + + it('reports a no-eligible-pivot error when no oracle follows', () => { + const noOracle: IContinuousRecord = { + originalRowIndex: 3, + value: { entries: entries.slice(0, 4), entriesSize: 50, ...META }, + }; + const { processed, errors } = processContinuousRecords([noOracle], PivotStrategy.Random, 1, 0); + expect(processed).toHaveLength(0); + expect(errors[0]).toMatchObject({ originalRowIndex: 3 }); + }); + + it('isolates a throwing record so the rest of the batch still produces rows', () => { + // A malformed oracle edit (overlapping replacements) makes replay throw a + // `BugIndicatingError` from the `StringEdit` constructor. One bad record + // must not abort the whole batch: it should surface as a per-record error + // while its siblings still process — matching the alternative-action path. + const malformedEntries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 3000, relativePath: 'src/bad.ts' }, + { kind: 'setContent', id: 0, time: 3001, content: doc, v: 0 }, + { kind: 'changed', id: 0, time: 3003, edit: [[5, 5, 'A']], v: 1 }, + { kind: 'changed', id: 0, time: 3005, edit: [[10, 14, 'X'], [12, 16, 'Y']], v: 2 }, + ]; + const malformed: IContinuousRecord = { originalRowIndex: 1, value: { entries: malformedEntries, entriesSize: 80, ...META } }; + const good = (originalRowIndex: number): IContinuousRecord => ({ originalRowIndex, value: { entries, entriesSize: 100, ...META } }); + + const { processed, errors } = processContinuousRecords([good(0), malformed, good(2)], PivotStrategy.Random, 7, 0); + + expect(processed).toHaveLength(2); + expect(errors.map(e => ({ originalRowIndex: e.originalRowIndex, isError: e.value instanceof Error }))).toEqual([{ originalRowIndex: 1, isError: true }]); + processed.forEach(p => p.replayer.dispose()); + }); +}); diff --git a/extensions/copilot/test/pipeline/continuous/processContinuous.ts b/extensions/copilot/test/pipeline/continuous/processContinuous.ts new file mode 100644 index 00000000000000..a44541bbfe37fd --- /dev/null +++ b/extensions/copilot/test/pipeline/continuous/processContinuous.ts @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAlternativeAction } from '../../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; +import { Random } from '../../../src/platform/inlineEdits/test/node/random'; +import { LogEntry } from '../../../src/platform/workspaceRecorder/common/workspaceLog'; +import { ErrorUtils } from '../../../src/util/common/errors'; +import { Result } from '../../../src/util/common/result'; +import { PivotStrategy } from '../../base/simulationOptions'; +import { IInputRow } from '../parseInput'; +import { IProcessedRow, processRecordingAtPivot } from '../replayRecording'; +import { IContinuousRecord } from './continuousRecord'; +import { deriveSeed, selectPivots } from './pivotStrategy'; +import type { WithRowIndex } from '../withRowIndex'; + +/** + * Sentinel `suggestionStatus` for samples synthesized from a continuous + * recording: no model suggestion was ever made, so the sample is oracle-only + * (the model edit scores 0). + */ +export const CONTINUOUS_SUGGESTION_STATUS = 'continuous'; + +/** + * Build a synthetic {@link IInputRow} for one continuous-recording pivot. + * + * Continuous slices carry no model suggestion, prompt, or response — only the + * recorded edit timeline — so those fields use sentinels: an empty + * `suggestedEdit` produces no proposed edits, which is exactly what we want for + * oracle-only training data. `activeDocumentLanguageId` is filled in by the + * caller after replay, once the active file (and hence its language) is known. + */ +function synthesizeRow(record: IContinuousRecord, entries: LogEntry[], pivotTime: number, languageId: string): IInputRow { + const alternativeAction: IAlternativeAction = { + text: undefined, + textLength: 0, + selection: [], + edits: [], + tags: [CONTINUOUS_SUGGESTION_STATUS], + recording: { + entries, + entriesSize: record.value.entriesSize, + requestTime: pivotTime, + }, + }; + + return { + originalRowIndex: record.originalRowIndex, + suggestionStatus: CONTINUOUS_SUGGESTION_STATUS, + alternativeAction, + prompt: [], + modelResponse: '', + postProcessingOutcome: { suggestedEdit: '', isInlineCompletion: false }, + activeDocumentLanguageId: languageId, + }; +} + +/** + * Turn a single continuous recording into a processed row by splitting it at + * `pivotTime`. The returned {@link IProcessedRow} holds a live replayer that the + * caller must dispose. + * + * Never throws: like {@link processRow}, any unexpected error during replay + * (e.g. a malformed recorded edit) is caught and returned as an error `Result`, + * so one bad record can't abort a whole batch (see {@link processContinuousRecords}). + */ +export function processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result { + try { + return _processContinuousRecord(record, pivotTime); + } catch (e: unknown) { + return Result.error(ErrorUtils.fromUnknown(e)); + } +} + +function _processContinuousRecord(record: IContinuousRecord, pivotTime: number): Result { + const entries = record.value.entries; + if (!entries || entries.length === 0) { + return Result.fromString('Continuous recording has no entries'); + } + + const result = processRecordingAtPivot({ + row: synthesizeRow(record, entries, pivotTime, ''), + entries, + requestTime: pivotTime, + proposedEdits: [], + isAccepted: false, + }); + if (result.isError()) { + return result; + } + + // The replayer resolves the active document's language from its file + // extension; reuse that so continuous samples carry a real language id + // (continuous telemetry has no per-slice language column). + const languageId = result.val.activeDocument.languageId.get(); + return Result.ok({ + ...result.val, + row: { ...result.val.row, activeDocumentLanguageId: languageId }, + }); +} + +/** + * Process a batch of continuous recordings into processed rows. + * + * Each record's pivot selection is seeded from `deriveSeed(baseSeed, rowOffset + + * record.originalRowIndex)`, so output is reproducible from `--seed` and + * independent of how records are sharded across parallel workers. + * + * `rowOffset` is the global index of the first record in this chunk (0 for + * single-process runs); it is added to each record's local index to form the + * global record index used for seeding. + * + * Each returned `IProcessedRow` holds a live replayer that the caller must dispose. + */ +export function processContinuousRecords( + records: readonly IContinuousRecord[], + strategy: PivotStrategy, + baseSeed: number, + rowOffset: number, +): { + processed: IProcessedRow[]; + errors: WithRowIndex[]; +} { + const processed: IProcessedRow[] = []; + const errors: WithRowIndex[] = []; + + for (const record of records) { + const entries = record.value.entries; + if (!entries || entries.length === 0) { + errors.push({ originalRowIndex: record.originalRowIndex, value: new Error('Continuous recording has no entries') }); + continue; + } + + const globalRecordIndex = rowOffset + record.originalRowIndex; + const rng = Random.create(deriveSeed(baseSeed, globalRecordIndex)); + + const pivots = selectPivots(entries, strategy, rng); + if (pivots.length === 0) { + errors.push({ originalRowIndex: record.originalRowIndex, value: new Error(`No eligible pivot found (strategy: ${strategy}, ${entries.length} entries)`) }); + continue; + } + + // NOTE: each materialized row is keyed downstream by `originalRowIndex` + // (prompt/response/output maps in pipeline.ts). The shipped `random` + // strategy yields at most one pivot per record, so that key stays unique. + // A future multi-pivot strategy (idle-gap, every-edit, ...) MUST first + // introduce a per-sample id (e.g. `{ originalRowIndex, pivotOrdinal }`) + // threaded through those maps, otherwise rows sharing a record index + // would overwrite each other. + for (const pivotTime of pivots) { + const result = processContinuousRecord(record, pivotTime); + if (result.isError()) { + errors.push({ originalRowIndex: record.originalRowIndex, value: result.err }); + } else { + processed.push(result.val); + } + } + } + + return { processed, errors }; +} diff --git a/extensions/copilot/test/pipeline/parseInput.ts b/extensions/copilot/test/pipeline/parseInput.ts index 4cd101ccc151a0..d58511032e78d5 100644 --- a/extensions/copilot/test/pipeline/parseInput.ts +++ b/extensions/copilot/test/pipeline/parseInput.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { IAlternativeAction } from '../../src/extension/inlineEdits/node/nextEditProviderTelemetry'; +import { ErrorUtils } from '../../src/util/common/errors'; import { streamJsonRecords } from './streamJsonRecords'; +import type { WithRowIndex } from './withRowIndex'; /** * A single row from the JSON input. @@ -80,10 +82,10 @@ function parseInputRecord(record: Record, rowIndex: number): IIn */ export async function loadAndParseInput(inputPath: string, verbose = false): Promise<{ rows: IInputRow[]; - errors: { rowIndex: number; error: string }[]; + errors: WithRowIndex[]; }> { const rows: IInputRow[] = []; - const errors: { rowIndex: number; error: string }[] = []; + const errors: WithRowIndex[] = []; let i = 0; for await (const record of streamJsonRecords>(inputPath)) { @@ -92,8 +94,8 @@ export async function loadAndParseInput(inputPath: string, verbose = false): Pro rows.push(parseInputRecord(record, rowIndex)); } catch (e) { errors.push({ - rowIndex, - error: e instanceof Error ? e.message : String(e), + originalRowIndex: rowIndex, + value: ErrorUtils.fromUnknown(e), }); } } diff --git a/extensions/copilot/test/pipeline/pipeline.ts b/extensions/copilot/test/pipeline/pipeline.ts index c4a956143f023f..78e8b1eff483af 100644 --- a/extensions/copilot/test/pipeline/pipeline.ts +++ b/extensions/copilot/test/pipeline/pipeline.ts @@ -14,7 +14,9 @@ import { Limiter } from '../../src/util/vs/base/common/async'; import { OffsetRange } from '../../src/util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText'; import { applyConfigFile, loadConfigFile } from '../base/simulationContext'; -import { NesDatagen, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions'; +import { NesDatagen, NesDatagenInputFormat, NesDatagenSampleTask, SimulationOptions } from '../base/simulationOptions'; +import { loadAndParseContinuousInput } from './continuous/continuousRecord'; +import { processContinuousRecords } from './continuous/processContinuous'; import { detectCrossFileJump, detectSameFileJump } from './cursorJump/detectJump'; import { generateCursorPromptFromRecording, installCursorJumpCapturingFetcher } from './cursorJump/cursorJumpPromptStep'; import { generateCrossFileResponse, generateSameFileResponse } from './cursorJump/cursorJumpResponseStep'; @@ -25,6 +27,7 @@ import { IProcessedRow, parseSuggestedEdit, processAllRows } from './replayRecor import { generateAllResponses, generateResponse, IResponseGenerationInput, applyEditsToContent } from './responseStep'; import { streamJsonRecords } from './streamJsonRecords'; import { openWriteStream } from './writeStream'; +import type { WithRowIndex } from './withRowIndex'; function logErrors(errors: readonly { error: string }[], verbose: boolean, log: (...ps: any[]) => void): void { if (errors.length > 0 && verbose) { @@ -63,6 +66,74 @@ export type RunPipelineOptions = { readonly parallelism: number; }; +/** + * Result of the shared "parse input + replay into processed rows" front half of + * both pipelines. Abstracts over the input format so the xtab and cursor-jump + * pipelines don't need to know whether the rows came from alternative-action or + * continuous recordings. + */ +interface ILoadedProcessedRows { + /** + * Number of input records that parsed successfully — the starting count for + * the `[1/5]` progress line and the pipeline summary funnel. Parse failures + * are excluded and counted separately in {@link parseErrors}. + */ + readonly recordCount: number; + /** Per-record parse failures. */ + readonly parseErrors: readonly WithRowIndex[]; + /** Successfully replayed rows. Each holds a live replayer the caller must dispose. */ + readonly processed: IProcessedRow[]; + /** Per-record replay/processing failures. */ + readonly replayErrors: readonly WithRowIndex[]; + /** + * Resolve the language id for a record by its `originalRowIndex` (the record's + * position in the input file), for error messages. Continuous records have no + * language before replay, so this returns `'?'` for them. + */ + readonly languageForRow: (originalRowIndex: number) => string; +} + +/** + * Parse the input and replay each recording into processed rows, dispatching on + * `--input-format`. This is the format-aware front half shared by both the xtab + * and cursor-jump pipelines. + * + * For continuous input the pivot RNG is seeded from `nesDatagenOpts.seed` and + * `nesDatagenOpts.rowOffset`, so output is reproducible and independent of how + * the input is sharded across workers. + */ +async function loadAndProduceProcessedRows(nesDatagenOpts: NesDatagen, verbose: boolean): Promise { + const inputPath = nesDatagenOpts.input; + + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + const { records, errors: parseErrors } = await loadAndParseContinuousInput(inputPath, verbose); + const { processed, errors: replayErrors } = processContinuousRecords( + records, + nesDatagenOpts.pivotStrategy, + nesDatagenOpts.seed, + nesDatagenOpts.rowOffset, + ); + return { + recordCount: records.length, + parseErrors, + processed, + replayErrors, + languageForRow: () => '?', + }; + } + + const { rows, errors: parseErrors } = await loadAndParseInput(inputPath, verbose); + const { processed, errors: replayErrors } = processAllRows(rows); + const languageByRowIndex = new Map(rows.map(row => [row.originalRowIndex, row.activeDocumentLanguageId])); + return { + recordCount: rows.length, + parseErrors, + processed, + replayErrors, + languageForRow: (originalRowIndex: number) => languageByRowIndex.get(originalRowIndex) ?? '?', + }; +} + /** * Single-process pipeline entry point. Dispatches to the xtab or cursor-jump * pipeline based on the configured sample task. @@ -99,17 +170,18 @@ async function runXtabPipeline(opts: RunPipelineOptions, log: (...ps: any[]) => log(`\n=== Pipeline ===`); log(` Input: ${inputPath}`); log(` Concurrency: ${concurrency}`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } - // Step 1: Parse input - const { rows, errors } = await loadAndParseInput(inputPath, verbose); - log(` [1/5] Input parsed: ${rows.length} rows, ${errors.length} errors`); - logErrors(errors, verbose, log); + // Step 1+2: Parse input and replay recordings (format-aware) + const { recordCount, parseErrors, processed, replayErrors, languageForRow } = await loadAndProduceProcessedRows(nesDatagenOpts, verbose); + log(` [1/5] Input parsed: ${recordCount} rows, ${parseErrors.length} errors`); + logErrors(parseErrors.map(e => ({ error: e.value.message })), verbose, log); - // Step 2: Replay recordings - const { processed, errors: replayErrors } = processAllRows(rows); log(` [2/5] Recordings replayed: ${processed.length} ok, ${replayErrors.length} errors`); logErrors(replayErrors.map(e => ({ - error: `[sample ${e.rowIndex + rowOffset}, ${rows[e.rowIndex]?.activeDocumentLanguageId ?? '?'}] ${e.error}`, + error: `[sample ${e.originalRowIndex + rowOffset}, ${languageForRow(e.originalRowIndex)}] ${e.value.message}`, })), verbose, log); // Step 3: Generate prompts @@ -215,7 +287,7 @@ async function runXtabPipeline(opts: RunPipelineOptions, log: (...ps: any[]) => } // Summary - log(`\n Pipeline: Input(${rows.length}) → Replay(${processed.length}) → Prompt(${prompts.length}) → Response(${responses.length}) → Output(${writeResult.written})`); + log(`\n Pipeline: Input(${recordCount}) → Replay(${processed.length}) → Prompt(${prompts.length}) → Response(${responses.length}) → Output(${writeResult.written})`); } finally { for (const p of processed) { p.replayer.dispose(); @@ -241,15 +313,17 @@ async function runCursorPipeline(opts: RunPipelineOptions, log: (...ps: any[]) = log(` Sample task: ${task}`); log(` Concurrency: ${concurrency}`); log(` Same-file jump thresholds: above=${nesDatagenOpts.sameFileJumpMinAbove}, below=${nesDatagenOpts.sameFileJumpMinBelow}`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } - const { rows, errors } = await loadAndParseInput(inputPath, verbose); - log(` [1/5] Input parsed: ${rows.length} rows, ${errors.length} errors`); - logErrors(errors, verbose, log); + const { recordCount, parseErrors, processed, replayErrors, languageForRow } = await loadAndProduceProcessedRows(nesDatagenOpts, verbose); + log(` [1/5] Input parsed: ${recordCount} rows, ${parseErrors.length} errors`); + logErrors(parseErrors.map(e => ({ error: e.value.message })), verbose, log); - const { processed, errors: replayErrors } = processAllRows(rows); log(` [2/5] Recordings replayed: ${processed.length} ok, ${replayErrors.length} errors`); logErrors(replayErrors.map(e => ({ - error: `[sample ${e.rowIndex + rowOffset}, ${rows[e.rowIndex]?.activeDocumentLanguageId ?? '?'}] ${e.error}`, + error: `[sample ${e.originalRowIndex + rowOffset}, ${languageForRow(e.originalRowIndex)}] ${e.value.message}`, })), verbose, log); // Detect jumps first — many rows will be skipped here, no point capturing @@ -428,7 +502,7 @@ async function runCursorPipeline(opts: RunPipelineOptions, log: (...ps: any[]) = const writeResult = await writeSamples(outputPath, samples); log(` [5/5] Output written: ${writeResult.written} samples → ${writeResult.outputPath}`); - log(`\n Pipeline: Input(${rows.length}) → Replay(${processed.length}) → Jumps(${jumps.size}) → Prompt(${prompts.length}) → Output(${writeResult.written})`); + log(`\n Pipeline: Input(${recordCount}) → Replay(${processed.length}) → Jumps(${jumps.size}) → Prompt(${prompts.length}) → Output(${writeResult.written})`); } finally { for (const p of processed) { p.replayer.dispose(); @@ -528,7 +602,11 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise const numWorkers = Math.max(1, Math.min(os.cpus().length, opts.parallelism, Math.ceil(totalRecords / 25))); console.log(`\n=== Pipeline (parallel: ${numWorkers} workers) ===`); - console.log(` Input: ${inputPath} (${totalRecords} rows)\n`); + console.log(` Input: ${inputPath} (${totalRecords} rows)`); + if (nesDatagenOpts.inputFormat === NesDatagenInputFormat.Continuous) { + console.log(` Input format: continuous (pivot-strategy: ${nesDatagenOpts.pivotStrategy}, seed: ${nesDatagenOpts.seed})`); + } + console.log(''); if (totalRecords === 0) { console.log(` No records to process.`); @@ -573,6 +651,11 @@ export async function runInputPipelineParallel(opts: SimulationOptions): Promise '--row-offset', String(start), '--parallelism', String(opts.parallelism), '--sample-task', nesDatagenOpts.sampleTask, + '--input-format', nesDatagenOpts.inputFormat, + '--pivot-strategy', nesDatagenOpts.pivotStrategy, + // Propagate the parent's resolved seed so every worker selects the + // same pivots it would in a single-process run (reproducibility). + '--seed', String(nesDatagenOpts.seed), '--same-file-jump-min-above', String(nesDatagenOpts.sameFileJumpMinAbove), '--same-file-jump-min-below', String(nesDatagenOpts.sameFileJumpMinBelow), '--worker', diff --git a/extensions/copilot/test/pipeline/replayRecording.spec.ts b/extensions/copilot/test/pipeline/replayRecording.spec.ts new file mode 100644 index 00000000000000..df94c4980b0968 --- /dev/null +++ b/extensions/copilot/test/pipeline/replayRecording.spec.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog'; +import { IInputRow } from './parseInput'; +import { processAllRows } from './replayRecording'; + +const doc = `const a = 1;\nconst b = 2;\n`; + +/** + * Build an {@link IInputRow} whose recording replays a pre-pivot no-op edit and + * then applies `oracleEdit` after the pivot. Disjoint replacements replay + * cleanly; overlapping replacements make replay throw, which is how we exercise + * the error path without any stubbing. + */ +function makeRow(originalRowIndex: number, oracleEdit: [number, number, string][]): IInputRow { + const entries: LogEntry[] = [ + { kind: 'meta', data: { repoRootUri: 'file:///ws' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/a.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: doc, v: 0 }, + // Pre-pivot no-op edit so the replayer has a `lastId`. + { kind: 'changed', id: 0, time: 1002, edit: [[0, 0, '']], v: 1 }, + // --- requestTime 1003 splits here; the rest is the oracle --- + { kind: 'changed', id: 0, time: 1004, edit: oracleEdit, v: 2 }, + ]; + return { + originalRowIndex, + suggestionStatus: 'accepted', + alternativeAction: { + text: doc, + textLength: doc.length, + selection: [], + edits: [], + tags: [], + recording: { entries, entriesSize: entries.length, requestTime: 1003 }, + }, + prompt: [], + modelResponse: '', + postProcessingOutcome: { suggestedEdit: '', isInlineCompletion: false }, + activeDocumentLanguageId: 'typescript', + }; +} + +describe('processAllRows', () => { + it('labels replay errors with the row\'s originalRowIndex, not its filtered array position', () => { + // Earlier parse failures make `loadAndParseInput` hand back a *sparse* + // `rows` array: survivors keep their true input-file `originalRowIndex` + // (0, 3, 5), so the failing row's index (3) differs from its position (1) + // in the array. The error must be labeled by index, not position. + const rows = [ + makeRow(0, [[6, 7, 'x']]), // valid: rename `a` -> `x` + makeRow(3, [[0, 4, 'X'], [2, 6, 'Y']]), // malformed: overlapping -> replay throws + makeRow(5, [[6, 7, 'y']]), // valid: rename `a` -> `y` + ]; + + const { processed, errors } = processAllRows(rows); + try { + expect(processed.map(p => p.originalRowIndex)).toEqual([0, 5]); + expect(errors.map(e => ({ originalRowIndex: e.originalRowIndex, isError: e.value instanceof Error }))).toEqual([ + { originalRowIndex: 3, isError: true }, + ]); + } finally { + processed.forEach(p => p.replayer.dispose()); + } + }); +}); diff --git a/extensions/copilot/test/pipeline/replayRecording.ts b/extensions/copilot/test/pipeline/replayRecording.ts index 269b7e09d3108d..d644d125017e78 100644 --- a/extensions/copilot/test/pipeline/replayRecording.ts +++ b/extensions/copilot/test/pipeline/replayRecording.ts @@ -7,11 +7,15 @@ import { IRecordingInformation, ObservableWorkspaceRecordingReplayer } from '../ import { DocumentId } from '../../src/platform/inlineEdits/common/dataTypes/documentId'; import { IObservableDocument, MutableObservableWorkspace } from '../../src/platform/inlineEdits/common/observableWorkspace'; import { LogEntry } from '../../src/platform/workspaceRecorder/common/workspaceLog'; +import { ErrorUtils } from '../../src/util/common/errors'; +import { Result } from '../../src/util/common/result'; import { coalesce } from '../../src/util/vs/base/common/arrays'; import { StringText } from '../../src/util/vs/editor/common/core/text/abstractText'; import { Processor } from './alternativeAction/processor'; +import { IStringReplacement } from './alternativeAction/types'; import { IInputRow } from './parseInput'; import { applyEditsToContent } from './responseStep'; +import type { WithRowIndex } from './withRowIndex'; /** * Result of processing a single input row: replayed workspace + oracle edit. @@ -90,48 +94,75 @@ export function parseSuggestedEdit(suggestedEditStr: string): [start: number, en } } -function formatError(e: unknown): string { - if (e instanceof Error) { - if (e.message === 'An unexpected bug occurred.' && e.stack) { - const frames = e.stack.split('\n').slice(1, 4).map(f => f.trim()).join(' <- '); - return `${e.message} Stack: ${frames}`; - } - return e.message; - } - return String(e); -} - /** * Process a single input row: split recording at request time, replay * the pre-request portion and extract the oracle edit. */ -export function processRow(row: IInputRow): IProcessedRow | { error: string } { +export function processRow(row: IInputRow): Result { try { return _processRow(row); } catch (e: unknown) { - return { error: `Unexpected error: ${formatError(e)}` }; + return Result.error(ErrorUtils.fromUnknown(e)); } } -function _processRow(row: IInputRow): IProcessedRow | { error: string } { +function _processRow(row: IInputRow): Result { const proposedEdits = coalesce([parseSuggestedEdit(row.postProcessingOutcome.suggestedEdit)]); const isAccepted = row.suggestionStatus === 'accepted'; - const split = Processor.splitRecording(row.alternativeAction); + const recording = row.alternativeAction?.recording; + const entries = recording?.entries; + if (!recording || !entries || entries.length === 0) { + const entryCount = entries?.length ?? 0; + return Result.fromString(`No recording entries to process (${entryCount} entries, lang: ${row.activeDocumentLanguageId})`); + } + + return processRecordingAtPivot({ + row, + entries, + requestTime: recording.requestTime, + proposedEdits, + isAccepted, + }); +} + +/** + * Pivot-centric core shared by the per-request (alternative-action) and the + * continuous-recording paths. Splits `entries` at `requestTime`, replays the + * pre-pivot portion into a live workspace and extracts the oracle edit that + * follows the pivot. + * + * For per-request recordings the pivot is the NES request bookmark + * (`recording.requestTime`); for continuous recordings it is synthesized by a + * pivot strategy. The returned {@link IProcessedRow} holds a live replayer that + * the caller must dispose. + */ +export function processRecordingAtPivot(args: { + /** Input row metadata threaded through to the result; synthesized for continuous recordings. */ + readonly row: IInputRow; + /** Full recording timeline (must be non-empty). */ + readonly entries: LogEntry[]; + /** Pivot time: entries with `time <= requestTime` are context, the rest hold the oracle. */ + readonly requestTime: number; + readonly proposedEdits: IStringReplacement[]; + readonly isAccepted: boolean; +}): Result { + const { row, entries, requestTime, proposedEdits, isAccepted } = args; + + const split = Processor.splitRecording(entries, requestTime); if (!split) { - const entryCount = row.alternativeAction?.recording?.entries?.length ?? 0; - return { error: `Could not split recording at request time (${entryCount} entries, lang: ${row.activeDocumentLanguageId})` }; + return Result.fromString(`Could not split recording at request time (${entries.length} entries, lang: ${row.activeDocumentLanguageId})`); } - const scoring = Processor.createScoringForAlternativeAction( - row.alternativeAction, + const scoring = Processor.createScoring( + entries, + requestTime, proposedEdits, isAccepted, ); if (!scoring) { - const entryCount = row.alternativeAction?.recording?.entries?.length ?? 0; - return { error: `Processor.createScoringForAlternativeAction returned undefined (${entryCount} entries, lang: ${row.activeDocumentLanguageId})` }; + return Result.fromString(`Processor.createScoring returned undefined (${entries.length} entries, lang: ${row.activeDocumentLanguageId})`); } const recording = scoring.scoringContext.recording; @@ -145,84 +176,88 @@ function _processRow(row: IInputRow): IProcessedRow | { error: string } { }; const replayer = new ObservableWorkspaceRecordingReplayer(recordingInfo); - let lastDocId: DocumentId; try { - const result = replayer.replay(); - lastDocId = result.lastDocId; - } catch (e) { - replayer.dispose(); - return { error: `Replay failed (${recording.log.length} entries, file: ${recording.nextUserEdit?.relativePath ?? 'unknown'}): ${formatError(e)}` }; - } + const { lastDocId } = replayer.replay(); - const workspace = replayer.workspace; - const activeDocument = workspace.getDocument(lastDocId); - if (!activeDocument) { - replayer.dispose(); - return { error: `Active document not found after replay: ${lastDocId}` }; - } + const workspace = replayer.workspace; + const activeDocument = workspace.getDocument(lastDocId); + if (!activeDocument) { + replayer.dispose(); + return Result.fromString(`Active document not found after replay: ${lastDocId}`); + } + + // Prefer scoring edit URI, fall back to oracle path + const activeFilePath = scoring.edits[0]?.documentUri ?? recording.nextUserEdit?.relativePath ?? 'unknown'; - // Prefer scoring edit URI, fall back to oracle path - const activeFilePath = scoring.edits[0]?.documentUri ?? recording.nextUserEdit?.relativePath ?? 'unknown'; - - // Compute cursor-at-request from the *last* `selectionChanged` on the - // active doc within the pre-request portion. Multi-cursor selections use - // the primary (first) range — matches `IObservableDocument._primarySelectionLine` - // semantics. If no selection event exists for the active doc, leave as - // undefined so cursor-jump detectors can skip the row. - const cursorAtRequest = (() => { - for (let i = split.recordingPriorToRequest.length - 1; i >= 0; i--) { - const entry = split.recordingPriorToRequest[i]; - if (entry.kind === 'selectionChanged' && entry.id === split.currentFile.id && entry.selection.length > 0) { - const offset = entry.selection[0][0]; - const content = activeDocument.value.get().value; - const transformer = new StringText(content).getTransformer(); - const lineNumber = transformer.getPosition(Math.min(offset, content.length)).lineNumber - 1; - return { offset, lineNumber }; + // Compute cursor-at-request from the *last* `selectionChanged` on the + // active doc within the pre-request portion. Multi-cursor selections use + // the primary (first) range — matches `IObservableDocument._primarySelectionLine` + // semantics. If no selection event exists for the active doc, leave as + // undefined so cursor-jump detectors can skip the row. + const cursorAtRequest = (() => { + for (let i = split.recordingPriorToRequest.length - 1; i >= 0; i--) { + const entry = split.recordingPriorToRequest[i]; + if (entry.kind === 'selectionChanged' && entry.id === split.currentFile.id && entry.selection.length > 0) { + const offset = entry.selection[0][0]; + const content = activeDocument.value.get().value; + const transformer = new StringText(content).getTransformer(); + const lineNumber = transformer.getPosition(Math.min(offset, content.length)).lineNumber - 1; + return { offset, lineNumber }; + } } - } - return undefined; - })(); - - // Snapshot every observed doc's content at request time. Walks the - // pre-request portion once applying setContent + changed events, so - // cross-file jump detection can resolve the target line even when the - // target was opened before the bookmark. - const idToContentAtRequest = (() => { - const map = new Map(); - for (const entry of split.recordingPriorToRequest) { - if (entry.kind === 'setContent') { - map.set(entry.id, entry.content); - } else if (entry.kind === 'changed') { - const c = map.get(entry.id); - if (c === undefined) { - continue; + return undefined; + })(); + + // Snapshot every observed doc's content at request time. Walks the + // pre-request portion once applying setContent + changed events, so + // cross-file jump detection can resolve the target line even when the + // target was opened before the bookmark. + const idToContentAtRequest = (() => { + const map = new Map(); + for (const entry of split.recordingPriorToRequest) { + if (entry.kind === 'setContent') { + map.set(entry.id, entry.content); + } else if (entry.kind === 'changed') { + const c = map.get(entry.id); + if (c === undefined) { + continue; + } + // Replacements within a single `changed` event are all relative + // to the same base content, so they must be applied + // offset-descending (as `applyEditsToContent` does) — applying + // ascending in-place would shift later original offsets. + map.set(entry.id, applyEditsToContent(c, entry.edit)); } - // Replacements within a single `changed` event are all relative - // to the same base content, so they must be applied - // offset-descending (as `applyEditsToContent` does) — applying - // ascending in-place would shift later original offsets. - map.set(entry.id, applyEditsToContent(c, entry.edit)); } - } - return map; - })(); + return map; + })(); - return { - originalRowIndex: row.originalRowIndex, - row, - replayer, - workspace, - activeDocId: lastDocId, - activeDocument, - activeFilePath, - nextUserEdit: recording.nextUserEdit, - recordingInfo, - recordingAfterRequest: split.recordingAfterRequest, - activeDocLogId: split.currentFile.id, - idToRelativePath: split.idToFileMap, - cursorAtRequest, - idToContentAtRequest, - }; + return Result.ok({ + originalRowIndex: row.originalRowIndex, + row, + replayer, + workspace, + activeDocId: lastDocId, + activeDocument, + activeFilePath, + nextUserEdit: recording.nextUserEdit, + recordingInfo, + recordingAfterRequest: split.recordingAfterRequest, + activeDocLogId: split.currentFile.id, + idToRelativePath: split.idToFileMap, + cursorAtRequest, + idToContentAtRequest, + }); + } catch (e) { + // `replayer.replay()` and the post-replay analysis above (cursor/content + // reconstruction) can throw on a malformed recording — e.g. a non-disjoint + // `changed` edit rejected by the `StringEdit` constructor, or + // `applyEditsToContent` over non-disjoint replacements. Dispose the live + // replayer before the error unwinds so a single bad record can't leak it; + // callers only dispose the replayer on the success path. + replayer.dispose(); + throw e; + } } /** @@ -231,17 +266,18 @@ function _processRow(row: IInputRow): IProcessedRow | { error: string } { */ export function processAllRows(rows: readonly IInputRow[]): { processed: IProcessedRow[]; - errors: { rowIndex: number; error: string }[]; + errors: WithRowIndex[]; } { const processed: IProcessedRow[] = []; - const errors: { rowIndex: number; error: string }[] = []; + const errors: WithRowIndex[] = []; for (let i = 0; i < rows.length; i++) { - const result = processRow(rows[i]); - if ('error' in result) { - errors.push({ rowIndex: i, error: result.error }); + const row = rows[i]; + const result = processRow(row); + if (result.isError()) { + errors.push({ originalRowIndex: row.originalRowIndex, value: result.err }); } else { - processed.push(result); + processed.push(result.val); } } diff --git a/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts new file mode 100644 index 00000000000000..22b03cd1854fe6 --- /dev/null +++ b/extensions/copilot/test/pipeline/test/continuousPipeline.e2e.spec.ts @@ -0,0 +1,221 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, test } from 'vitest'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; +import { runInputPipeline, RunPipelineOptions } from '../pipeline'; +import { allContinuousRecords, continuousFixtures } from './fixtures/continuousFixtureData'; + +/** + * End-to-end tests for the nes-datagen pipeline on the **continuous** input + * format (`--input-format=continuous`). + * + * These exercise the full `runInputPipeline` — continuous parse → pivot + * synthesis → split → replay → prompt/response generation → output — with real + * fixture data, mirroring `pipeline.e2e.spec.ts` for the alternative-action + * path. The fixtures are crafted so each valid slice has exactly one eligible + * pivot, making the output deterministic regardless of the pivot RNG seed. + */ + +const configPath = path.join(__dirname, 'fixtures', 'config.json'); + +let tmpDir: string; +let inputPath: string; +let outputPath: string; + +function parseJsonl(contents: string): T[] { + return contents + .split('\n') + .filter(line => line.length > 0) + .map(line => JSON.parse(line) as T); +} + +beforeAll(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nes-datagen-continuous-e2e-')); + inputPath = path.join(tmpDir, 'input.json'); + outputPath = path.join(tmpDir, 'output.jsonl'); + await fs.writeFile(inputPath, JSON.stringify(allContinuousRecords, null, 2)); +}); + +afterAll(async () => { + if (tmpDir) { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +}); + +interface OutputSample { + messages: { role: 'system' | 'user' | 'assistant'; content: string }[]; + metadata: { + rowIndex: number; + language: string; + suggestionStatus: string; + filePath: string; + docContent: string; + oracleEdits: [number, number, string][]; + }; +} + +async function runContinuousPipeline(opts?: Partial): Promise<{ + samples: OutputSample[]; + logs: string[]; + output: string; +}> { + const logs: string[] = []; + const log = (...args: unknown[]) => { + logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); + }; + + const pipelineOpts: RunPipelineOptions = { + nesDatagen: { + input: inputPath, + output: outputPath, + rowOffset: 0, + workerMode: false, + sampleTask: NesDatagenSampleTask.Xtab, + sameFileJumpMinAbove: 5, + sameFileJumpMinBelow: 5, + inputFormat: NesDatagenInputFormat.Continuous, + pivotStrategy: PivotStrategy.Random, + seed: 42, + }, + configFile: configPath, + verbose: true, + parallelism: 1, + ...opts, + }; + + await runInputPipeline(pipelineOpts, log); + + const output = await fs.readFile(outputPath, 'utf-8'); + return { samples: parseJsonl(output), logs, output }; +} + +describe('nes-datagen continuous pipeline e2e', () => { + + describe('full pipeline run', () => { + let result: Awaited>; + + beforeAll(async () => { + result = await runContinuousPipeline(); + }); + + test('produces one oracle-only sample per valid slice', () => { + // 2 valid slices (ts + py); the capped slice is dropped at parse. + expect(result.samples.length).toBe(2); + }); + + test('reports the capped (entries-dropped) slice as a parse error', () => { + const parseLog = result.logs.find(l => l.includes('[1/5]')); + expect(parseLog).toBeDefined(); + expect(parseLog).toContain('1 errors'); + }); + + test('logs the continuous input format with strategy and seed', () => { + const formatLog = result.logs.find(l => l.includes('Input format: continuous')); + expect(formatLog).toBeDefined(); + expect(formatLog).toContain('pivot-strategy: random'); + expect(formatLog).toContain('seed: 42'); + }); + + test('every sample is tagged as a continuous (oracle-only) sample', () => { + for (const sample of result.samples) { + expect(sample.metadata.suggestionStatus).toBe('continuous'); + } + }); + + test('resolves language and file path from the replayed slice', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript'); + const py = result.samples.find(s => s.metadata.language === 'python'); + + expect(ts).toBeDefined(); + expect(py).toBeDefined(); + expect(ts!.metadata.filePath).toContain('src/math.ts'); + expect(py!.metadata.filePath).toContain('src/greet.py'); + }); + + test('extracts the post-pivot oracle edit', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript')!; + const py = result.samples.find(s => s.metadata.language === 'python')!; + + expect(ts.metadata.oracleEdits.some(([, , text]) => text === 'sum')).toBe(true); + expect(py.metadata.oracleEdits.some(([, , text]) => text === ' -> str')).toBe(true); + }); + + test('docContent is the slice content at the pivot (pre-oracle)', () => { + const ts = result.samples.find(s => s.metadata.language === 'typescript')!; + // Context reflects the pre-pivot edit but not the oracle: the + // original `add` is present and has not yet been renamed to `sum`. + expect(ts.metadata.docContent).toContain('export function add('); + expect(ts.metadata.docContent).not.toContain('sum'); + }); + + test('every sample carries non-empty system, user and assistant messages', () => { + for (const sample of result.samples) { + const roles = sample.messages.map(m => m.role); + expect(roles).toEqual(['system', 'user', 'assistant']); + for (const msg of sample.messages) { + expect(msg.content.trim().length).toBeGreaterThan(0); + } + } + }); + }); + + test('is reproducible: two runs with the same seed produce identical output', async () => { + const a = await runContinuousPipeline(); + const b = await runContinuousPipeline(); + expect(a.output).toBe(b.output); + }); + + test('a slice whose oracle edit is malformed is isolated, not fatal', async () => { + // Overlapping replacements in the post-pivot `changed` make the split + // throw; the batch must still produce the sibling sample rather than abort. + const badEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 3000, relativePath: 'src/bad.ts' }, + { kind: 'setContent', id: 0, time: 3001, content: 'const value = 1;\n', v: 0 }, + // Pre-pivot edit → the only eligible pivot (a later `changed` follows). + { kind: 'changed', id: 0, time: 3002, edit: [[0, 0, '// x\n']], v: 1 }, + // Malformed oracle: the two replacements overlap. + { kind: 'changed', id: 0, time: 3004, edit: [[6, 11, 'a'], [8, 13, 'b']], v: 2 }, + ]; + const badRecord = { recording: JSON.stringify({ entries: badEntries, entriesSize: 100 }) }; + + const mixedInput = path.join(tmpDir, 'mixed-input.json'); + const mixedOutput = path.join(tmpDir, 'mixed-output.jsonl'); + await fs.writeFile(mixedInput, JSON.stringify([continuousFixtures.ts.record, badRecord])); + + const logs: string[] = []; + await runInputPipeline( + { + nesDatagen: { + input: mixedInput, + output: mixedOutput, + rowOffset: 0, + workerMode: false, + sampleTask: NesDatagenSampleTask.Xtab, + sameFileJumpMinAbove: 5, + sameFileJumpMinBelow: 5, + inputFormat: NesDatagenInputFormat.Continuous, + pivotStrategy: PivotStrategy.Random, + seed: 42, + }, + configFile: configPath, + verbose: true, + parallelism: 1, + }, + (...args: unknown[]) => { logs.push(args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ')); }, + ); + + const samples = parseJsonl(await fs.readFile(mixedOutput, 'utf-8')); + expect(samples.length).toBe(1); + expect(samples[0].metadata.filePath).toContain('src/math.ts'); + + // The malformed slice is surfaced as a replay error, not swallowed. + const replayLog = logs.find(l => l.includes('[2/5]')); + expect(replayLog).toContain('1 errors'); + }); +}); diff --git a/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts index 3c26c468267448..a10e4e13de9f6f 100644 --- a/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts +++ b/extensions/copilot/test/pipeline/test/cursorJumpPipeline.e2e.spec.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; import { allCursorJumpRecords, cursorJumpFixtures } from './fixtures/cursorJumpFixtureData'; @@ -79,6 +79,9 @@ async function runCursorPipeline(sampleTask: NesDatagenSampleTask, nesDatagenOve rowOffset: 0, workerMode: false, sampleTask, + inputFormat: NesDatagenInputFormat.AlternativeAction, + pivotStrategy: PivotStrategy.Random, + seed: 0, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, ...nesDatagenOverrides, diff --git a/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.ts b/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.ts new file mode 100644 index 00000000000000..ec5c320da3e28d --- /dev/null +++ b/extensions/copilot/test/pipeline/test/fixtures/continuousFixtureData.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Fixture data for the continuous-recording nes-datagen pipeline e2e tests. + * + * Continuous slices (from `ContinuousEnhancedTelemetrySender`) carry no NES + * request bookmark, so the pipeline synthesizes a pivot. Each record here is + * shaped the way the continuous loader expects — a single `recording` column + * holding the stringified `IContinuousRecording` payload. + * + * Both valid recordings are crafted to have **exactly one** eligible pivot, so + * the produced sample is deterministic regardless of the pivot RNG seed: + * - a pre-pivot `changed` (the eligible pivot) gives the NES prompt real edit + * history to work from, and is itself eligible because a later `changed` + * (the oracle) follows it on the same document; + * - the trailing `changed` is the oracle and is itself ineligible (no edit + * follows it on the active document). + * This exercises the real strategy → split → replay → prompt → oracle path. + */ + +// --------------------------------------------------------------------------- +// Scenario 1 – TypeScript: rename `add` → `sum` +// --------------------------------------------------------------------------- + +const tsDocContent = + `export function add(a: number, b: number): number {\n` + + `\treturn a + b;\n` + + `}\n`; + +// `add` occupies offsets [16, 19) in `export function add(...`. The pre-pivot +// edit appends a trailing newline (at the end, so it doesn't shift `add`), +// giving the context some edit history without disturbing the oracle offsets. +const tsEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 1000, relativePath: 'src/math.ts' }, + { kind: 'setContent', id: 0, time: 1001, content: tsDocContent, v: 0 }, + // Only eligible pivot: an in-context edit that establishes edit history. + { kind: 'changed', id: 0, time: 1002, edit: [[tsDocContent.length, tsDocContent.length, '\n']], v: 1 }, + // Oracle (post-pivot): rename `add` → `sum`. + { kind: 'changed', id: 0, time: 1004, edit: [[16, 19, 'sum']], v: 2 }, +]; + +const tsRecord = { + recording: JSON.stringify({ + entries: tsEntries, + entriesSize: JSON.stringify(tsEntries).length, + windowStart: 900, + windowEnd: 1100, + sessionId: 'sess-ts', + sequenceNumber: 7, + }), +}; + +// --------------------------------------------------------------------------- +// Scenario 2 – Python: add a ` -> str` return annotation +// --------------------------------------------------------------------------- + +const pyDocContent = + `def greet(name):\n` + + ` return f"Hello, {name}!"\n`; + +// The `:` sits at offset 15 in `def greet(name):`. The pre-pivot edit appends a +// trailing newline so the offset-15 insertion offsets stay stable. +const pyEntries = [ + { kind: 'meta', data: { repoRootUri: 'file:///workspace' } }, + { kind: 'documentEncountered', id: 0, time: 2000, relativePath: 'src/greet.py' }, + { kind: 'setContent', id: 0, time: 2001, content: pyDocContent, v: 0 }, + // Only eligible pivot: an in-context edit that establishes edit history. + { kind: 'changed', id: 0, time: 2002, edit: [[pyDocContent.length, pyDocContent.length, '\n']], v: 1 }, + // Oracle (post-pivot): insert ` -> str` before the colon. + { kind: 'changed', id: 0, time: 2004, edit: [[15, 15, ' -> str']], v: 2 }, +]; + +const pyRecord = { + recording: JSON.stringify({ + entries: pyEntries, + entriesSize: JSON.stringify(pyEntries).length, + windowStart: 1900, + windowEnd: 2100, + sessionId: 'sess-py', + sequenceNumber: 8, + }), +}; + +// --------------------------------------------------------------------------- +// Scenario 3 – Invalid: `entries` dropped because the payload exceeded the cap. +// The sender still ships the window metadata and `entriesSize`; only `entries` +// is omitted. Such a slice has no usable history and must be reported as a +// parse error rather than aborting the run. +// --------------------------------------------------------------------------- + +const cappedRecord = { + recording: JSON.stringify({ + entriesSize: 250_000, + windowStart: 2900, + windowEnd: 3100, + sessionId: 'sess-capped', + sequenceNumber: 9, + }), +}; + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +export const continuousFixtures = { + ts: { record: tsRecord, docContent: tsDocContent }, + py: { record: pyRecord, docContent: pyDocContent }, + capped: { record: cappedRecord }, +} as const; + +/** All records in the format the continuous loader expects: a JSON array. */ +export const allContinuousRecords = [tsRecord, pyRecord, cappedRecord]; diff --git a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts index 045eec522cd7b8..f3b89e4076d15d 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.e2e.spec.ts @@ -6,7 +6,7 @@ import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterAll, beforeAll, describe, expect, test } from 'vitest'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; import { allRecords, fixtures } from './fixtures/fixtureData'; @@ -85,7 +85,7 @@ async function runPipeline(opts?: Partial): Promise<{ input: inputPath, output: outputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: true, @@ -263,7 +263,7 @@ describe('nes-datagen pipeline e2e', () => { input: invalidInputPath, output: invalidOutputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: false, @@ -290,7 +290,7 @@ describe('nes-datagen pipeline e2e', () => { input: inputPath, output: outputPath, rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: undefined, verbose: false, @@ -309,7 +309,7 @@ describe('nes-datagen pipeline e2e', () => { input: inputPath, output: offsetOutputPath, rowOffset: 100, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0, }, configFile: configPath, verbose: false, diff --git a/extensions/copilot/test/pipeline/test/pipeline.spec.ts b/extensions/copilot/test/pipeline/test/pipeline.spec.ts index 07d5458742c4eb..4d422af6d6683a 100644 --- a/extensions/copilot/test/pipeline/test/pipeline.spec.ts +++ b/extensions/copilot/test/pipeline/test/pipeline.spec.ts @@ -7,7 +7,7 @@ import * as fs from 'fs/promises'; import path from 'path'; import { expect, suite, test } from 'vitest'; import { Result } from '../../../src/util/common/result'; -import { NesDatagenSampleTask } from '../../base/simulationOptions'; +import { NesDatagenInputFormat, NesDatagenSampleTask, PivotStrategy } from '../../base/simulationOptions'; import { IInputRow } from '../parseInput'; import { runInputPipeline, RunPipelineOptions } from '../pipeline'; @@ -108,7 +108,7 @@ suite.skip('from csv to input rows to pipeline', () => { input: inputRowsFilePath, output: path.join(fixtures, 'output.jsonl'), rowOffset: 0, - workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5 + workerMode: false, sampleTask: NesDatagenSampleTask.Xtab, sameFileJumpMinAbove: 5, sameFileJumpMinBelow: 5, inputFormat: NesDatagenInputFormat.AlternativeAction, pivotStrategy: PivotStrategy.Random, seed: 0 }, configFile: configFilePath, verbose: true, diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChangesetConstants.ts b/extensions/copilot/test/pipeline/withRowIndex.ts similarity index 50% rename from src/vs/sessions/contrib/providers/agentHost/browser/agentHostChangesetConstants.ts rename to extensions/copilot/test/pipeline/withRowIndex.ts index 68d697002a120e..0de746d82cdcfd 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostChangesetConstants.ts +++ b/extensions/copilot/test/pipeline/withRowIndex.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ /** - * Minimum interval between changeset-driven UI recomputes. While an agent - * edits, the host streams many changeset envelopes per second; coalescing them - * to ~10 updates/second keeps the Changes view responsive without perceptible - * lag, and stops every envelope from forcing a full list relayout. + * A value paired with the `originalRowIndex` identifying its source row in the + * input container it was read from. */ -export const CHANGESET_UPDATE_THROTTLE_MS = 100; +export type WithRowIndex = { + readonly originalRowIndex: number; + readonly value: T; +}; diff --git a/extensions/emmet/package-lock.json b/extensions/emmet/package-lock.json index f1aeb0bcbd5d94..591bf3adb682a6 100644 --- a/extensions/emmet/package-lock.json +++ b/extensions/emmet/package-lock.json @@ -9,7 +9,7 @@ "version": "10.0.0", "license": "MIT", "dependencies": { - "@emmetio/css-parser": "ramya-rao-a/css-parser#vscode", + "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^0.3.3", "@emmetio/math-expression": "^1.0.5", "@vscode/emmet-helper": "^2.8.8", @@ -40,8 +40,10 @@ } }, "node_modules/@emmetio/css-parser": { - "version": "0.4.0", - "resolved": "git+ssh://git@github.com/ramya-rao-a/css-parser.git#370c480ac103bd17c7bcfb34bf5d577dc40d3660", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha1-DSl1uR26WGEuXDXjaogO9CQGfsM=", + "license": "MIT", "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" diff --git a/extensions/emmet/package.json b/extensions/emmet/package.json index 6a40129910efb6..ff49a4a21f4ea5 100644 --- a/extensions/emmet/package.json +++ b/extensions/emmet/package.json @@ -488,7 +488,7 @@ "@types/node": "24.x" }, "dependencies": { - "@emmetio/css-parser": "ramya-rao-a/css-parser#vscode", + "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^0.3.3", "@emmetio/math-expression": "^1.0.5", "@vscode/emmet-helper": "^2.8.8", diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 28b8645881aea4..79eaf1ca4ca436 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -354,7 +354,7 @@ export class ApiRepository implements Repository { return this.#repository.createWorktree(options); } - deleteWorktree(path: string, options?: { force?: boolean }): Promise { + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise { return this.#repository.deleteWorktree(path, options); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 725dd360663493..03a06a7794bb7b 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -326,7 +326,7 @@ export interface Repository { dropStash(index?: number): Promise; createWorktree(options?: { path?: string; commitish?: string; branch?: string; noTrack?: boolean }): Promise; - deleteWorktree(path: string, options?: { force?: boolean }): Promise; + deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise; migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; diff --git a/extensions/git/src/repository.ts b/extensions/git/src/repository.ts index 00b6a6de3b1829..ec27cbd57fee4b 100644 --- a/extensions/git/src/repository.ts +++ b/extensions/git/src/repository.ts @@ -2120,7 +2120,7 @@ export class Repository implements Disposable { } } - async deleteWorktree(path: string, options?: { force?: boolean }): Promise { + async deleteWorktree(path: string, options?: { force?: boolean; label?: string }): Promise { await this.run(Operation.Worktree(false), async () => { const worktree = this.repositoryResolver.getRepository(path); @@ -2130,12 +2130,14 @@ export class Repository implements Disposable { }; try { - await deleteWorktree(); + await deleteWorktree(options); } catch (err) { if (err.gitErrorCode === GitErrorCodes.WorktreeContainsChanges) { const forceDelete = l10n.t('Force Delete'); - const message = l10n.t('The worktree contains modified or untracked files. Do you want to force delete?'); - const choice = await window.showWarningMessage(message, { modal: true }, forceDelete); + const message = options?.label + ? l10n.t('The worktree for session "{0}" contains modified or untracked files. Do you want to force delete?', options.label) + : l10n.t('The worktree contains modified or untracked files. Do you want to force delete?'); + const choice = await window.showWarningMessage(message, { modal: true, detail: l10n.t('Worktree: {0}', path) }, forceDelete); if (choice === forceDelete) { await deleteWorktree({ ...options, force: true }); } diff --git a/extensions/markdown-language-features/esbuild.markdownEditor.mts b/extensions/markdown-language-features/esbuild.markdownEditor.mts index 95182bd3067764..bfd86f4923f77b 100644 --- a/extensions/markdown-language-features/esbuild.markdownEditor.mts +++ b/extensions/markdown-language-features/esbuild.markdownEditor.mts @@ -15,6 +15,10 @@ run({ srcDir, outdir: outDir, additionalOptions: { + // `@vscode/diff` has a Node-only code path that dynamically imports + // `node:fs/promises` (guarded by a `process.versions.node` check). It is + // dead code in the webview, so mark it external to avoid a resolve error. + external: ['node:fs/promises'], loader: { '.woff': 'file', '.woff2': 'file', diff --git a/extensions/markdown-language-features/markdown-editor-src/editor.ts b/extensions/markdown-language-features/markdown-editor-src/editor.ts index 8f46a419f06d15..c5281605324321 100644 --- a/extensions/markdown-language-features/markdown-editor-src/editor.ts +++ b/extensions/markdown-language-features/markdown-editor-src/editor.ts @@ -3,12 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EditorController, EditorModel, EditorView, StringEdit, StringValue, findNodeOffsetById, taskCheckboxRange } from '@vscode/markdown-editor'; -import { Disposable, autorun } from '@vscode/markdown-editor/observables'; +import { CommentModeController, CommentsModel, EditorController, EditorModel, EditorView, GutterMarker, OffsetRange, Selection, StringEdit, StringValue, VsCodeV2CommentsView, findNodeOffsetById, taskCheckboxRange } from '@vscode/markdown-editor'; +import { Disposable, autorun, observableValue } from '@vscode/markdown-editor/observables'; import mermaid from 'mermaid'; import 'katex/dist/katex.min.css'; import '@vscode/markdown-editor/editor.css'; -import '@vscode/markdown-editor/themes/vscode.css'; +import '@vscode/markdown-editor/themes/vscode-default.css'; +import '@vscode/markdown-editor/commentInput.css'; +import '@vscode/markdown-editor/vscodeCommentWidgetV2.css'; import './markdownEditor.css'; import { WebviewSyntaxHighlighter } from './syntaxHighlighter'; @@ -20,12 +22,26 @@ interface VsCodeApi { declare function acquireVsCodeApi(): VsCodeApi; +/** + * The editor's view state, persisted as webview state (`getState`/`setState`) so + * the scroll and cursor position are restored when the webview is reloaded or the + * custom editor is re-created (e.g. after switching sessions and back). + */ +interface PersistedViewState { + scrollTop?: number; + selection?: { anchor: number; active: number }; +} + class Editor extends Disposable { readonly model = new EditorModel(); isUpdatingFromExtension = false; + #isUpdatingComments = false; #mermaidCounter = 0; #initialized = false; + readonly #comments = new CommentsModel(); + /** Whether the workbench feedback store currently accepts new comments for this resource. */ + readonly #acceptsComments = observableValue('acceptsComments', false); readonly #vscode = acquireVsCodeApi(); readonly #syntaxHighlighter = new WebviewSyntaxHighlighter((message) => this.#vscode.postMessage(message)); @@ -43,8 +59,7 @@ class Editor extends Disposable { case 'init': { if (!this.#initialized) { this.#initialized = true; - this.#createView(host, !!message.readonly); - this.model.sourceText.set(new StringValue(message.content), undefined); + this.#createView(host, !!message.readonly, message.content); } break; } @@ -54,20 +69,48 @@ class Editor extends Disposable { this.isUpdatingFromExtension = false; break; } + case 'gutterMarkers': { + const markers: GutterMarker[] = message.markers.map((marker: { start: number; endExclusive: number; type: GutterMarker['type'] }) => ({ + range: OffsetRange.fromTo(marker.start, marker.endExclusive), + type: marker.type, + })); + this.model.gutterMarkers.set(markers, undefined); + break; + } + case 'comments': { + this.#isUpdatingComments = true; + this.#comments.set(message.comments.map((comment: { id: string; start: number; endExclusive: number; body: string; author?: string }) => ({ + id: comment.id, + range: OffsetRange.fromTo(comment.start, comment.endExclusive), + body: comment.body, + author: comment.author, + }))); + this.#isUpdatingComments = false; + this.#acceptsComments.set(!!message.acceptsComments, undefined); + break; + } } }); this.#vscode.postMessage({ type: 'ready' }); } - #createView(host: HTMLElement, readonly: boolean): void { + #createView(host: HTMLElement, readonly: boolean, content: string): void { const model = this.model; + // The scroll + cursor position last persisted for this document, captured + // before any listener below can overwrite it, so it survives the editor being + // re-created (e.g. after a session switch). + const savedViewState = this.#getViewState(); + + // Start in the last globally chosen edit/read-only mode. The lock toggle in + // the editor drives `readonlyMode` from here on; changes are persisted below. + model.readonlyMode.set(readonly, undefined); const view = this._register(new EditorView(model, { - classNames: ['md-theme-vscode'], + classNames: ['md-theme-vscode-default'], syntaxHighlighter: this.#syntaxHighlighter, onToggleCheckbox: (item, newChecked) => { - if (readonly) { + if (model.readonlyMode.get()) { return; } const doc = model.document.get(); @@ -100,19 +143,149 @@ class Editor extends Disposable { return div; }, })); + this._register(new EditorController(model, view)); host.appendChild(view.element); - if (!readonly) { - let firstTime = true; - this._register(autorun((reader) => { - const text = reader.readObservable(this.model.sourceText).value; - if (!this.isUpdatingFromExtension && !firstTime) { - this.#vscode.postMessage({ type: 'edit', content: text }); + // Render comments as the VS Code V2 markdown cards. The card colours come + // from the webview's own `--vscode-*` theme variables; `theme` only picks + // the light/dark token wrapper. `resolveLine` maps a comment's start offset + // to a 1-based line for the card header. + const isLight = document.body.classList.contains('vscode-light'); + this._register(new VsCodeV2CommentsView(this.#comments, view, { + theme: isLight ? 'light' : 'dark', + resolveLine: (offset) => model.sourceText.get().value.slice(0, offset).split('\n').length, + })); + // The comment input (the gdocs-style "add a comment" affordance) is only + // useful when the workbench feedback store will actually accept the comment; + // otherwise submitting is a no-op. Mount the controller only while the + // resource is in scope for a session, and tear it down when it leaves scope. + let commentController: CommentModeController | undefined; + this._register(autorun((reader) => { + const accepts = reader.readObservable(this.#acceptsComments); + if (accepts && !commentController) { + commentController = new CommentModeController(model, view, { + onSubmit: ({ text, range }) => { + this.#vscode.postMessage({ type: 'addComment', start: range.start, endExclusive: range.endExclusive, text }); + }, + }); + } else if (!accepts && commentController) { + commentController.dispose(); + commentController = undefined; + } + })); + this._register({ dispose: () => commentController?.dispose() }); + + // The comment card's delete button mutates the local CommentsModel + // directly. Mirror those removals back to the extension so the shared + // store (and the code editor) stay in sync. Removals coming from an + // extension-driven update set `#isUpdatingComments`, so they are not + // echoed back. + let knownCommentIds = new Set(this.#comments.comments.get().map(comment => comment.id)); + this._register(autorun((reader) => { + const currentIds = new Set(reader.readObservable(this.#comments.comments).map(comment => comment.id)); + if (!this.#isUpdatingComments) { + for (const id of knownCommentIds) { + if (!currentIds.has(id)) { + this.#vscode.postMessage({ type: 'deleteComment', id }); + } } - firstTime = false; - })); + } + knownCommentIds = currentIds; + })); + + // Load the document content, then restore the persisted cursor so it lands on + // the same text. Offsets are clamped defensively in case the document shifted. + model.sourceText.set(new StringValue(content), undefined); + if (savedViewState.selection) { + const max = content.length; + const anchor = Math.min(savedViewState.selection.anchor, max); + const active = Math.min(savedViewState.selection.active, max); + model.selection.set(new Selection(anchor, active), undefined); + } + + // Persist scroll as webview state (throttled to a frame). Registered after the + // restore above so it never clobbers the values we are about to restore. + let scrollSaveScheduled = false; + const saveScroll = (): void => { + scrollSaveScheduled = false; + this.#patchViewState({ scrollTop: host.scrollTop }); + }; + const onScroll = (): void => { + if (scrollSaveScheduled) { return; } + scrollSaveScheduled = true; + requestAnimationFrame(saveScroll); + }; + host.addEventListener('scroll', onScroll, { passive: true }); + this._register({ dispose: () => host.removeEventListener('scroll', onScroll) }); + + // Flush the latest scroll synchronously before the webview is hidden or torn + // down, since the frame-throttled save above may not have run yet. + const onHide = (): void => { + if (document.visibilityState === 'hidden') { + this.#patchViewState({ scrollTop: host.scrollTop }); + } + }; + document.addEventListener('visibilitychange', onHide); + window.addEventListener('pagehide', saveScroll); + this._register({ dispose: () => { document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', saveScroll); } }); + + // Persist the cursor whenever it moves. + this._register(autorun((reader) => { + const sel = reader.readObservable(this.model.selection); + this.#patchViewState({ selection: sel ? { anchor: sel.anchor, active: sel.active } : undefined }); + })); + + // Persist the edit/read-only mode as the global default whenever the lock + // toggle flips it, so the next Markdown editor opens in the same mode. The + // initial (restored) value is skipped so opening an editor doesn't re-write it. + let firstReadonly = true; + this._register(autorun((reader) => { + const isReadonly = reader.readObservable(this.model.readonlyMode); + if (!firstReadonly) { + this.#vscode.postMessage({ type: 'setReadonly', readonly: isReadonly }); + } + firstReadonly = false; + })); + + // Forward user edits to the extension. Edits are ignored by the model while + // read-only, so this is a no-op in that mode; keeping it always registered + // means unlocking a read-only editor immediately resumes edit forwarding. + let firstTime = true; + this._register(autorun((reader) => { + const text = reader.readObservable(this.model.sourceText).value; + if (!this.isUpdatingFromExtension && !firstTime) { + this.#vscode.postMessage({ type: 'edit', content: text }); + } + firstTime = false; + })); + + // Restore scroll last: content height settles over a few frames (async parse, + // syntax highlighting, mermaid), so re-apply until it sticks. + // TODO@copilot: Consider using a more robust method for restoring scroll position, e.g. by waiting for the editor to stabilize + this.#restoreScroll(host, savedViewState.scrollTop); + } + + #getViewState(): PersistedViewState { + return (this.#vscode.getState() as PersistedViewState | undefined) ?? {}; + } + + #patchViewState(patch: PersistedViewState): void { + this.#vscode.setState({ ...this.#getViewState(), ...patch }); + } + + #restoreScroll(host: HTMLElement, scrollTop: number | undefined): void { + if (typeof scrollTop !== 'number' || scrollTop <= 0) { + return; } + let tries = 0; + const apply = (): void => { + host.scrollTop = scrollTop; + if (++tries < 6 && Math.abs(host.scrollTop - scrollTop) > 1) { + requestAnimationFrame(apply); + } + }; + requestAnimationFrame(apply); } } diff --git a/extensions/markdown-language-features/markdown-editor-src/syntaxHighlighter.ts b/extensions/markdown-language-features/markdown-editor-src/syntaxHighlighter.ts index 0b740fc3801ea4..5e83a6060a5450 100644 --- a/extensions/markdown-language-features/markdown-editor-src/syntaxHighlighter.ts +++ b/extensions/markdown-language-features/markdown-editor-src/syntaxHighlighter.ts @@ -118,7 +118,7 @@ class HighlighterDocument { this.#request(initialText); } - update(edit: StringEdit, tx: ITransaction | undefined): void { + update(edit: StringEdit, tx: ITransaction): void { const newText = edit.apply(this.#text); const previousLength = this.#text.length; this.#tokens = adjustTokens(this.#tokens, previousLength, newText.length); diff --git a/extensions/markdown-language-features/markdown-editor-src/tsconfig.json b/extensions/markdown-language-features/markdown-editor-src/tsconfig.json index 31f5ff9b8f8087..5c9e369debbe1b 100644 --- a/extensions/markdown-language-features/markdown-editor-src/tsconfig.json +++ b/extensions/markdown-language-features/markdown-editor-src/tsconfig.json @@ -16,6 +16,15 @@ "typeRoots": [ "../node_modules/@types" ], + "preserveSymlinks": true, + "paths": { + "@vscode/markdown-editor": [ + "../node_modules/@vscode/markdown-editor/dist/index.d.ts" + ], + "@vscode/markdown-editor/observables": [ + "../node_modules/@vscode/markdown-editor/dist/observables.d.ts" + ] + }, "skipLibCheck": true, "noEmit": true }, diff --git a/extensions/markdown-language-features/package-lock.json b/extensions/markdown-language-features/package-lock.json index 60e0349caf9edf..6a54b2c9a8cb7e 100644 --- a/extensions/markdown-language-features/package-lock.json +++ b/extensions/markdown-language-features/package-lock.json @@ -10,11 +10,11 @@ "license": "MIT", "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-8", + "@vscode/markdown-editor": "0.0.2-13", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", - "markdown-it": "^12.3.2", + "markdown-it": "^14.2.0", "mermaid": "^11.15.0", "morphdom": "^2.7.7", "picomatch": "^2.3.2", @@ -600,6 +600,12 @@ "d3-transition": "^3.0.1" } }, + "node_modules/@vscode/diff": { + "version": "0.0.2-0", + "resolved": "https://registry.npmjs.org/@vscode/diff/-/diff-0.0.2-0.tgz", + "integrity": "sha512-gmwM9W6mLnqNxcCd0u9WTuL3JJjaAuicoNcPWNEbHFe8OS8SvdQ6q+txVQTwLT6ezUnXQ6e8sQwmjPSE384yxQ==", + "license": "MIT" + }, "node_modules/@vscode/extension-telemetry": { "version": "0.9.8", "resolved": "https://registry.npmjs.org/@vscode/extension-telemetry/-/extension-telemetry-0.9.8.tgz", @@ -620,11 +626,12 @@ "integrity": "sha512-ukOMWnCg1tCvT7WnDfsUKQOFDQGsyR5tNgRpwmqi+5/vzU3ghdDXzvIM4IOPdSb3OeSsBNvmSL8nxIVOqi2WXA==" }, "node_modules/@vscode/markdown-editor": { - "version": "0.0.2-8", - "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-8.tgz", - "integrity": "sha512-mmsUYF3ffmunmR9Q9cShVYIUq9orbClFNm9tMNCJ7n1Vh/8EFLn3QJQdoyqeGL53O0gc2VCqPAqwgwOPKadLFA==", + "version": "0.0.2-13", + "resolved": "https://registry.npmjs.org/@vscode/markdown-editor/-/markdown-editor-0.0.2-13.tgz", + "integrity": "sha512-nYzI7yu5GOQ3VT77/k7RviBLs2ZWW8ZWS9BOFOhGMIKtfZx3eo9YbUtWoqVXCsBtKDhU1CdFb3NsAgzyAP4cHw==", "license": "MIT", "dependencies": { + "@vscode/diff": "0.0.2-0", "katex": "^0.16.33", "micromark": "^4.0.1", "micromark-extension-gfm": "^3.0.0", @@ -1348,9 +1355,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -1474,11 +1481,22 @@ "license": "MIT" }, "node_modules/linkify-it": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", - "integrity": "sha512-ynTsyrFSdE5oZ/O9GEf00kPngmOfVwazR5GKDq6EYfhlpFug3J2zybX56a2PRRpc9P+FuSoGNAwjlbDs9jJBPQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { - "uc.micro": "^1.0.1" + "uc.micro": "^2.0.0" } }, "node_modules/lodash-es": { @@ -1505,26 +1523,30 @@ } }, "node_modules/markdown-it": { - "version": "12.3.2", - "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-12.3.2.tgz", - "integrity": "sha512-TchMembfxfNVpHkbtriWltGWc+m3xszaRD0CZup7GFFhzIgQqxIfn3eGj1yZpfuflzPvfkt611B2Q/Bsk1YnGg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-1TGiQiJVRQ3NPmZH6sx5Cfnmg6GQm9jvC1ch4TK511NjSJvjzKLzn5pPfZRNZkRPZP0HqCioSndqH8v2nRaWVQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", "dependencies": { "argparse": "^2.0.1", - "entities": "~2.1.0", - "linkify-it": "^3.0.1", - "mdurl": "^1.0.1", - "uc.micro": "^1.0.5" + "entities": "^4.4.0", + "linkify-it": "^5.0.1", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" }, "bin": { - "markdown-it": "bin/markdown-it.js" - } - }, - "node_modules/markdown-it/node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "markdown-it": "bin/markdown-it.mjs" } }, "node_modules/marked": { @@ -1540,9 +1562,10 @@ } }, "node_modules/mdurl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", - "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==" + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" }, "node_modules/mermaid": { "version": "11.15.0", @@ -2227,6 +2250,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -2296,9 +2328,10 @@ } }, "node_modules/uc.micro": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz", - "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" }, "node_modules/undici-types": { "version": "7.16.0", diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index 87ab67029912a6..b0b089196ca9d1 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -8,9 +8,12 @@ "license": "MIT", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "enabledApiProposals": [ + "agentEditorComments", "customEditorDiffs", "documentDiff", - "documentSyntaxHighlighting" + "documentSyntaxHighlighting", + "textEditorDiffInformation", + "customEditorPriority" ], "engines": { "vscode": "^1.70.0" @@ -859,7 +862,11 @@ { "viewType": "vscode.markdown.preview.editor", "displayName": "Markdown Preview", - "priority": "option", + "priority": { + "diffEditor": "option", + "textEditor": "option", + "mergeEditor": "never" + }, "selector": [ { "filenamePattern": "*.md" @@ -869,7 +876,11 @@ { "viewType": "vscode.markdown.editor", "displayName": "Markdown Editor (Experimental)", - "priority": "option", + "priority": { + "diffEditor": "never", + "textEditor": "option", + "mergeEditor": "never" + }, "selector": [ { "filenamePattern": "*.md" @@ -898,11 +909,11 @@ }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", - "@vscode/markdown-editor": "^0.0.2-8", + "@vscode/markdown-editor": "0.0.2-13", "dompurify": "^3.4.10", "highlight.js": "^11.8.0", "katex": "^0.16.33", - "markdown-it": "^12.3.2", + "markdown-it": "^14.2.0", "mermaid": "^11.15.0", "morphdom": "^2.7.7", "picomatch": "^2.3.2", diff --git a/extensions/markdown-language-features/src/extension.shared.ts b/extensions/markdown-language-features/src/extension.shared.ts index 55b8d3002374a9..70da143ae74d3e 100644 --- a/extensions/markdown-language-features/src/extension.shared.ts +++ b/extensions/markdown-language-features/src/extension.shared.ts @@ -48,7 +48,7 @@ export function activateShared( context.subscriptions.push(vscode.window.registerCustomEditorProvider( MarkdownEditorProvider.viewType, - new MarkdownEditorProvider(context.extensionUri), + new MarkdownEditorProvider(context.extensionUri, context.globalState), { webviewOptions: { retainContextWhenHidden: true }, supportsMultipleEditorsPerDocument: true, diff --git a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts index 3f008ca23a8550..48117bbe285366 100644 --- a/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts +++ b/extensions/markdown-language-features/src/preview/markdownEditorProvider.ts @@ -16,12 +16,21 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT public static readonly viewType = 'vscode.markdown.editor'; + /** + * Memento key under which the last chosen edit/read-only mode is remembered. + * The value is a single global default shared by every Markdown editor, so + * flipping the lock in one editor becomes the initial mode for the next. + */ + static readonly #readonlyStateKey = 'markdown.editor.readonly'; + readonly #mediaRoot: vscode.Uri; readonly #extensionUri: vscode.Uri; + readonly #globalState: vscode.Memento; - constructor(extensionUri: vscode.Uri) { + constructor(extensionUri: vscode.Uri, globalState: vscode.Memento) { super(); this.#extensionUri = extensionUri; + this.#globalState = globalState; this.#mediaRoot = vscode.Uri.joinPath(this.#extensionUri, 'markdown-editor-out'); } @@ -43,7 +52,13 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT const onMessage = webview.onDidReceiveMessage(async (message) => { switch (message.type) { case 'ready': { - webview.postMessage({ type: 'init', content: document.getText(), readonly: false }); + webview.postMessage({ type: 'init', content: document.getText(), readonly: this.#globalState.get(MarkdownEditorProvider.#readonlyStateKey, false) }); + break; + } + case 'setReadonly': { + // Remember the edit/read-only choice as the global default for the + // next Markdown editor. + await this.#globalState.update(MarkdownEditorProvider.#readonlyStateKey, !!message.readonly); break; } case 'edit': { @@ -76,14 +91,98 @@ export class MarkdownEditorProvider extends Disposable implements vscode.CustomT }); const highlight = this.#wireHighlight(webview); + const quickDiff = this.#wireQuickDiff(document, webview); + const comments = this.#wireComments(document, webview); webviewPanel.onDidDispose(() => { onMessage.dispose(); onDocumentChange.dispose(); highlight.dispose(); + quickDiff.dispose(); + comments.dispose(); + }); + } + + /** + * Forwards the source-control change information for the document (the same + * added/modified/deleted line changes shown in the editor gutter) to the + * webview, where it is painted in the Markdown editor's gutter. Line ranges + * are converted to source character offsets here, since the webview works in + * offsets. + */ + #wireQuickDiff(document: vscode.TextDocument, webview: vscode.Webview): vscode.Disposable { + const diffProvider = vscode.window.createSourceControlDiffInformation(document.uri); + + const postMarkers = () => { + const diffInformation = diffProvider.diffInformation; + // The changes are computed asynchronously against a specific document + // version. Only map them to offsets while that version still matches the + // document we hold, otherwise the line positions could be stale. A newer + // diff for the current version will arrive via onDidChange. + if (!diffInformation || diffInformation.isStale) { + return; + } + webview.postMessage({ type: 'gutterMarkers', markers: toGutterMarkers(document, diffInformation.changes) }); + }; + + const onChange = diffProvider.onDidChange(postMarkers); + // Re-send once the webview has (re)initialized its model, and whenever the + // document settles on the version the changes were computed for. + const onMessage = webview.onDidReceiveMessage((message) => { + if (message.type === 'ready') { + postMarkers(); + } + }); + const onDocumentChange = vscode.workspace.onDidChangeTextDocument((e) => { + if (e.document.uri.toString() === document.uri.toString()) { + postMarkers(); + } + }); + + return vscode.Disposable.from(diffProvider, onChange, onMessage, onDocumentChange); + } + + /** + * Bridges the workbench's agent/session comments (the same store the code + * editor renders its comments from) to the webview: existing comments are + * forwarded for rendering, and comments the user adds in the Markdown editor + * are written back to the shared store so they appear in the code editor too. + * Comment ranges are converted between {@link vscode.Range} and the source + * character offsets the webview works in. + */ + #wireComments(document: vscode.TextDocument, webview: vscode.Webview): vscode.Disposable { + const commentsProvider = vscode.window.createAgentEditorComments(document.uri); + + const postComments = () => { + const comments = commentsProvider.comments.map(comment => ({ + id: comment.id, + start: document.offsetAt(comment.range.start), + endExclusive: document.offsetAt(comment.range.end), + body: comment.body, + author: comment.author, + })); + webview.postMessage({ type: 'comments', comments, acceptsComments: commentsProvider.acceptsComments }); + }; + + const onChange = commentsProvider.onDidChange(postComments); + const onMessage = webview.onDidReceiveMessage((message) => { + if (message.type === 'ready') { + postComments(); + } else if (message.type === 'addComment') { + const range = new vscode.Range( + document.positionAt(message.start), + document.positionAt(message.endExclusive), + ); + commentsProvider.addComment(range, message.text); + } else if (message.type === 'deleteComment') { + commentsProvider.deleteComment(message.id); + } }); + + return vscode.Disposable.from(commentsProvider, onChange, onMessage); } + /** * Proxies the webview's syntax highlighting requests to the * `documentSyntaxHighlighting` proposed API, since the webview cannot call @@ -143,3 +242,41 @@ function getNonce(): string { } return text; } + +interface GutterMarkerMessage { + readonly start: number; + readonly endExclusive: number; + readonly type: 'added' | 'modified' | 'deleted'; +} + +/** + * Converts the line-based source control changes into source character offset + * ranges understood by the Markdown editor's `gutterMarkers`. Added/modified + * changes map to the offset span of their modified lines; deleted changes map to + * an empty range at the boundary where the removed text used to be. + * + * Line ranges use {@link vscode.TextEditorLineRange} semantics: 1-based + * `startLineNumber` and exclusive `endLineNumberExclusive`. + */ +function toGutterMarkers(document: vscode.TextDocument, changes: readonly vscode.TextEditorChange[]): GutterMarkerMessage[] { + const markers: GutterMarkerMessage[] = []; + for (const change of changes) { + if (change.kind === vscode.TextEditorChangeKind.Deletion) { + // The modified range is empty; place an empty marker at the start of the + // line where the removed content used to be. + const line = Math.max(0, change.modified.startLineNumber - 1); + const offset = document.offsetAt(new vscode.Position(line, 0)); + markers.push({ start: offset, endExclusive: offset, type: 'deleted' }); + continue; + } + + const start = document.offsetAt(new vscode.Position(change.modified.startLineNumber - 1, 0)); + const endExclusive = document.offsetAt(document.lineAt(change.modified.endLineNumberExclusive - 2).range.end); + markers.push({ + start, + endExclusive, + type: change.kind === vscode.TextEditorChangeKind.Addition ? 'added' : 'modified', + }); + } + return markers; +} diff --git a/extensions/markdown-language-features/tsconfig.json b/extensions/markdown-language-features/tsconfig.json index 682b9727806ba5..a401cfa097788d 100644 --- a/extensions/markdown-language-features/tsconfig.json +++ b/extensions/markdown-language-features/tsconfig.json @@ -11,8 +11,10 @@ "include": [ "src/**/*", "../../src/vscode-dts/vscode.d.ts", + "../../src/vscode-dts/vscode.proposed.agentEditorComments.d.ts", "../../src/vscode-dts/vscode.proposed.customEditorDiffs.d.ts", "../../src/vscode-dts/vscode.proposed.documentDiff.d.ts", - "../../src/vscode-dts/vscode.proposed.documentSyntaxHighlighting.d.ts" + "../../src/vscode-dts/vscode.proposed.documentSyntaxHighlighting.d.ts", + "../../src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts" ] } diff --git a/extensions/mermaid-markdown-features/package-lock.json b/extensions/mermaid-markdown-features/package-lock.json index b88d22950afd86..3ffd72c4c3f65e 100644 --- a/extensions/mermaid-markdown-features/package-lock.json +++ b/extensions/mermaid-markdown-features/package-lock.json @@ -1845,9 +1845,9 @@ "license": "MIT" }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" diff --git a/extensions/microsoft-authentication/package.json b/extensions/microsoft-authentication/package.json index b13f68099337fe..9705f1c94f906a 100644 --- a/extensions/microsoft-authentication/package.json +++ b/extensions/microsoft-authentication/package.json @@ -21,7 +21,11 @@ "capabilities": { "virtualWorkspaces": true, "untrustedWorkspaces": { - "supported": true + "supported": "limited", + "restrictedConfigurations": [ + "microsoft-sovereign-cloud.environment", + "microsoft-sovereign-cloud.customEnvironment" + ] } }, "extensionKind": [ diff --git a/extensions/notebook-renderers/package-lock.json b/extensions/notebook-renderers/package-lock.json index e5c39d11af174f..98aa3ee4360a6f 100644 --- a/extensions/notebook-renderers/package-lock.json +++ b/extensions/notebook-renderers/package-lock.json @@ -605,9 +605,9 @@ } }, "node_modules/undici": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", - "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { diff --git a/extensions/npm/package-lock.json b/extensions/npm/package-lock.json index ca856d1c5ada5d..1b77aeac60dd45 100644 --- a/extensions/npm/package-lock.json +++ b/extensions/npm/package-lock.json @@ -150,9 +150,9 @@ } }, "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -332,9 +332,10 @@ } }, "node_modules/which-pm": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.1.1.tgz", - "integrity": "sha512-xzzxNw2wMaoCWXiGE8IJ9wuPMU+EYhFksjHxrRT8kMT5SnocBPRg69YAMtyV4D12fP582RA+k3P8H9J5EMdIxQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-2.2.0.tgz", + "integrity": "sha512-MOiaDbA5ZZgUjkeMWM5EkJp4loW5ZRoa5bc3/aeMox/PJelMhE6t7S/mLuiY43DBupyxH+S0U1bTui9kWUlmsw==", + "license": "MIT", "dependencies": { "load-yaml-file": "^0.2.0", "path-exists": "^4.0.0" diff --git a/extensions/terminal-suggest/src/completions/azd.ts b/extensions/terminal-suggest/src/completions/azd.ts index 6e73b4580044fb..b75b8d72504d9c 100644 --- a/extensions/terminal-suggest/src/completions/azd.ts +++ b/extensions/terminal-suggest/src/completions/azd.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { filepaths } from '../helpers/filepaths'; + interface AzdEnvListItem { Name: string; DotEnvPath: string; @@ -228,157 +230,342 @@ const completionSpec: Fig.Spec = { description: 'Ship agents with Microsoft Foundry from your terminal. (Preview)', subcommands: [ { - name: ['files'], - description: 'Manage files in a hosted agent session.', + name: ['code'], + description: 'Manage agent source code. (Preview)', subcommands: [ { - name: ['delete', 'remove', 'rm'], - description: 'Delete a file or directory from a hosted agent session.', + name: ['download'], + description: 'Download agent source code from Foundry. (Preview)', options: [ { - name: ['--agent-name', '-n'], - description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + name: ['--dest', '-d'], + description: 'Destination path (default: .// or ./.zip)', args: [ { - name: 'agent-name', + name: 'dest', }, ], }, { - name: ['--file', '-f'], - description: 'Remote file or directory path to delete', + name: ['--version', '-v'], + description: 'Agent version to download (default: latest)', args: [ { - name: 'file', + name: 'version', }, ], }, { - name: ['--recursive'], - description: 'Recursively delete directories and their contents', + name: ['--zip'], + description: 'Save as zip file instead of extracting', }, + ], + }, + ], + }, + { + name: ['delete'], + description: 'Delete a hosted agent.', + options: [ + { + name: ['--force'], + description: 'Force deletion even if the agent has active sessions', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ { - name: ['--session-id', '-s'], - description: 'Session ID override (defaults to last invoke session)', + name: 'output', + suggestions: ['json', 'none'], + }, + ], + }, + { + name: ['--version'], + description: 'Delete a specific version only (the agent itself remains)', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['doctor'], + description: 'Diagnose problems with an azd ai agent project.', + options: [ + { + name: ['--local-only'], + description: 'Skip remote (network-dependent) checks. Useful when offline, behind a proxy, or for a fast local triage.', + }, + { + name: ['--unredacted'], + description: 'Show raw principal IDs, scope ARNs, and UPNs in the report.', + }, + ], + }, + { + name: ['endpoint'], + description: 'Manage agent endpoint and card configuration.', + subcommands: [ + { + name: ['show'], + description: 'Show the current endpoint and card configuration of an agent.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'session-id', + name: 'output', + suggestions: ['json', 'table'], }, ], }, ], }, { - name: ['download'], - description: 'Download a file from a hosted agent session.', + name: ['update'], + description: 'Update an agent\'s endpoint and card configuration without deploying a new version.', options: [ { - name: ['--agent-name', '-n'], - description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + name: ['--force'], + description: 'Skip confirmation prompts for breaking changes', + isDangerous: true, + }, + ], + }, + ], + }, + { + name: ['eval'], + description: 'Create and run quick evals for an agent.', + subcommands: [ + { + name: ['generate'], + description: 'Generate a local eval suite for a deployed agent.', + options: [ + { + name: ['--agent'], + description: 'Target agent name', args: [ { - name: 'agent-name', + name: 'agent', }, ], }, { - name: ['--file', '-f'], - description: 'Remote file path to download', + name: ['--dataset'], + description: 'Existing local file or registered dataset name to use for evaluation (instead of generating a new dataset)', args: [ { - name: 'file', + name: 'dataset', }, ], }, { - name: ['--session-id', '-s'], - description: 'Session ID override (defaults to last invoke session)', + name: ['--eval-model'], + description: 'Model used for evaluation and generation', args: [ { - name: 'session-id', + name: 'eval-model', }, ], }, { - name: ['--target-path', '-t'], - description: 'Local destination path (defaults to remote filename)', + name: ['--evaluator'], + description: 'Built-in or custom evaluator name', + isRepeatable: true, args: [ { - name: 'target-path', + name: 'evaluator', + }, + ], + }, + { + name: ['--gen-instruction', '-g'], + description: 'Agent instruction used for dataset and evaluator generation', + args: [ + { + name: 'gen-instruction', + }, + ], + }, + { + name: ['--gen-instruction-file'], + description: 'Path to a file containing the agent instruction', + args: [ + { + name: 'gen-instruction-file', + }, + ], + }, + { + name: ['--max-samples'], + description: 'Number of samples to generate (15-1000)', + args: [ + { + name: 'max-samples', + }, + ], + }, + { + name: ['--name'], + description: 'Name for the eval suite', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Submit generation jobs and return immediately', + }, + { + name: ['--out-file'], + description: 'Eval config path', + args: [ + { + name: 'out-file', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Microsoft Foundry project endpoint URL', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--reset-defaults'], + description: 'Overwrite an existing eval config', + }, + { + name: ['--trace-days'], + description: 'Include agent traces from the last N days for evaluator generation (0 = no traces)', + args: [ + { + name: 'trace-days', }, ], }, ], }, { - name: ['list', 'ls'], - description: 'List files in a hosted agent session.', + name: ['list'], + description: 'List evaluations for the current project.', options: [ { - name: ['--agent-name', '-n'], - description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + name: ['--limit'], + description: 'Maximum number of evals to return', args: [ { - name: 'agent-name', + name: 'limit', }, ], }, + ], + }, + { + name: ['run'], + description: 'Execute an evaluation run from eval.yaml.', + options: [ { - name: ['--output'], - description: 'Output format (json or table)', + name: ['--config'], + description: 'Local eval config YAML', args: [ { - name: 'output', + name: 'config', }, ], }, { - name: ['--session-id', '-s'], - description: 'Session ID override (defaults to last invoke session)', + name: ['--name'], + description: 'Name for the eval run (defaults to eval config name)', args: [ { - name: 'session-id', + name: 'name', }, ], }, + { + name: ['--no-wait'], + description: 'Start the run and return immediately without waiting for results', + }, ], }, { - name: ['mkdir'], - description: 'Create a directory in a hosted agent session.', + name: ['show'], + description: 'Show an eval definition, run history, or run details.', options: [ { - name: ['--agent-name', '-n'], - description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + name: ['--eval-run-id'], + description: 'Show details for a specific eval run', args: [ { - name: 'agent-name', + name: 'eval-run-id', }, ], }, { - name: ['--dir', '-d'], - description: 'Remote directory path to create', + name: ['--limit'], + description: 'Maximum number of runs to show', args: [ { - name: 'dir', + name: 'limit', }, ], }, { - name: ['--session-id', '-s'], - description: 'Session ID override (defaults to last invoke session)', + name: ['--out-file', '-O'], + description: 'Export full run results to a JSON file', args: [ { - name: 'session-id', + name: 'out-file', }, ], }, ], }, { - name: ['stat'], - description: 'Get file or directory metadata in a hosted agent session.', + name: ['update'], + description: 'Update evaluators and datasets from local files.', + options: [ + { + name: ['--config'], + description: 'Local eval config YAML', + args: [ + { + name: 'config', + }, + ], + }, + { + name: ['--dataset-only'], + description: 'Only update the dataset', + }, + { + name: ['--evaluator-only'], + description: 'Only update evaluators', + }, + ], + }, + ], + }, + { + name: ['files'], + description: 'Manage files in a hosted agent session.', + subcommands: [ + { + name: ['delete', 'remove', 'rm'], + description: 'Delete a file or directory from a hosted agent session.', options: [ { name: ['--agent-name', '-n'], @@ -390,14 +577,27 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--output', '-o'], - description: 'Output format (json or table)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'output', + name: 'chat-isolation-key', + }, + ], + }, + { + name: ['--file', '-f'], + description: 'Remote file or directory path to delete', + args: [ + { + name: 'file', }, ], }, + { + name: ['--recursive'], + description: 'Recursively delete directories and their contents', + }, { name: ['--session-id', '-s'], description: 'Session ID override (defaults to last invoke session)', @@ -407,11 +607,20 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', + }, + ], + }, ], }, { - name: ['upload'], - description: 'Upload a file to a hosted agent session.', + name: ['download'], + description: 'Download a file from a hosted agent session.', options: [ { name: ['--agent-name', '-n'], @@ -422,9 +631,18 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'chat-isolation-key', + }, + ], + }, { name: ['--file', '-f'], - description: 'Local file path to upload', + description: 'Remote file path to download', args: [ { name: 'file', @@ -442,227 +660,28 @@ const completionSpec: Fig.Spec = { }, { name: ['--target-path', '-t'], - description: 'Remote destination path (defaults to local filename)', + description: 'Local destination path (defaults to remote filename)', args: [ { name: 'target-path', }, ], }, - ], - }, - ], - }, - { - name: ['init'], - description: 'Initialize a new AI agent project. (Preview)', - options: [ - { - name: ['--manifest', '-m'], - description: 'Path or URI to an agent manifest to add to your azd project', - args: [ { - name: 'manifest', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', + }, + ], }, ], }, { - name: ['--model'], - description: 'Name of the AI model to use (e.g., \'gpt-4o\'). If not specified, defaults to \'gpt-4.1-mini\'. Mutually exclusive with --model-deployment, with --model-deployment being used if both are provided', - args: [ - { - name: 'model', - }, - ], - }, - { - name: ['--model-deployment', '-d'], - description: 'Name of an existing model deployment to use from the Foundry project. Only used when paired with an existing Foundry project, either via --project-id or interactive prompts', - args: [ - { - name: 'model-deployment', - }, - ], - }, - { - name: ['--project-id', '-p'], - description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', - args: [ - { - name: 'project-id', - }, - ], - }, - { - name: ['--protocol'], - description: 'Protocols supported by the agent (e.g., \'responses\', \'invocations\'). Can be specified multiple times.', - isRepeatable: true, - args: [ - { - name: 'protocol', - }, - ], - }, - { - name: ['--src', '-s'], - description: 'Directory to download the agent definition to (defaults to \'src/\')', - args: [ - { - name: 'src', - }, - ], - }, - ], - }, - { - name: ['invoke'], - description: 'Send a message to your agent.', - options: [ - { - name: ['--conversation-id'], - description: 'Explicit conversation ID override', - args: [ - { - name: 'conversation-id', - }, - ], - }, - { - name: ['--input-file', '-f'], - description: 'Path to a file whose contents are sent as the request body', - args: [ - { - name: 'input-file', - }, - ], - }, - { - name: ['--local', '-l'], - description: 'Invoke on localhost instead of Foundry', - }, - { - name: ['--new-conversation'], - description: 'Force a new conversation (discard saved one)', - }, - { - name: ['--new-session'], - description: 'Force a new session (discard saved one)', - }, - { - name: ['--port'], - description: 'Local server port', - args: [ - { - name: 'port', - }, - ], - }, - { - name: ['--protocol', '-p'], - description: 'Protocol to use: responses (default) or invocations', - args: [ - { - name: 'protocol', - }, - ], - }, - { - name: ['--session-id', '-s'], - description: 'Explicit session ID override', - args: [ - { - name: 'session-id', - }, - ], - }, - { - name: ['--timeout', '-t'], - description: 'Request timeout in seconds (0 for no timeout)', - args: [ - { - name: 'timeout', - }, - ], - }, - ], - }, - { - name: ['monitor'], - description: 'Monitor logs from a hosted agent.', - options: [ - { - name: ['--follow', '-f'], - description: 'Stream logs in real-time', - }, - { - name: ['--raw'], - description: 'Print the raw SSE stream without formatting', - }, - { - name: ['--session-id', '-s'], - description: 'Session ID to stream logs for', - args: [ - { - name: 'session-id', - }, - ], - }, - { - name: ['--tail', '-l'], - description: 'Number of trailing log lines to fetch (1-300)', - args: [ - { - name: 'tail', - }, - ], - }, - { - name: ['--type', '-t'], - description: 'Type of logs: \'console\' (stdout/stderr) or \'system\' (container events)', - args: [ - { - name: 'type', - }, - ], - }, - { - name: ['--utc'], - description: 'Display timestamps in UTC instead of local time', - }, - ], - }, - { - name: ['run'], - description: 'Run your agent locally for development.', - options: [ - { - name: ['--port', '-p'], - description: 'Port to listen on', - args: [ - { - name: 'port', - }, - ], - }, - { - name: ['--start-command', '-c'], - description: 'Explicit startup command (overrides azure.yaml and auto-detection)', - args: [ - { - name: 'start-command', - }, - ], - }, - ], - }, - { - name: ['sessions'], - description: 'Manage sessions for a hosted agent endpoint.', - subcommands: [ - { - name: ['create'], - description: 'Create a new session for a hosted agent.', - options: [ + name: ['list', 'ls'], + description: 'List files in a hosted agent session.', + options: [ { name: ['--agent-name', '-n'], description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', @@ -673,26 +692,27 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--isolation-key'], - description: 'Isolation key for session ownership (derived from Entra token by default)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'isolation-key', + name: 'chat-isolation-key', }, ], }, { name: ['--output', '-o'], - description: 'Output format (json or table)', + description: 'The output format', args: [ { name: 'output', + suggestions: ['json', 'table'], }, ], }, { - name: ['--session-id'], - description: 'Optional caller-provided session ID (auto-generated if omitted)', + name: ['--session-id', '-s'], + description: 'Session ID override (defaults to last invoke session)', args: [ { name: 'session-id', @@ -700,19 +720,19 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--version'], - description: 'Agent version to back the session (auto-resolved from azd environment if omitted)', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'version', + name: 'user-isolation-key', }, ], }, ], }, { - name: ['delete'], - description: 'Delete a session.', + name: ['mkdir'], + description: 'Create a directory in a hosted agent session.', options: [ { name: ['--agent-name', '-n'], @@ -724,19 +744,46 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--isolation-key'], - description: 'Isolation key for session ownership (derived from Entra token by default)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'isolation-key', + name: 'chat-isolation-key', + }, + ], + }, + { + name: ['--dir', '-d'], + description: 'Remote directory path to create', + args: [ + { + name: 'dir', + }, + ], + }, + { + name: ['--session-id', '-s'], + description: 'Session ID override (defaults to last invoke session)', + args: [ + { + name: 'session-id', + }, + ], + }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', }, ], }, ], }, { - name: ['list'], - description: 'List sessions for a hosted agent.', + name: ['stat'], + description: 'Get file or directory metadata in a hosted agent session.', options: [ { name: ['--agent-name', '-n'], @@ -748,37 +795,47 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--limit'], - description: 'Maximum number of sessions to return', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'limit', + name: 'chat-isolation-key', }, ], }, { name: ['--output', '-o'], - description: 'Output format (json or table)', + description: 'The output format', args: [ { name: 'output', + suggestions: ['json', 'table'], }, ], }, { - name: ['--pagination-token'], - description: 'Continuation token from a previous list response', + name: ['--session-id', '-s'], + description: 'Session ID override (defaults to last invoke session)', args: [ { - name: 'pagination-token', + name: 'session-id', + }, + ], + }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', }, ], }, ], }, { - name: ['show'], - description: 'Show details of a session.', + name: ['upload'], + description: 'Upload a file to a hosted agent session.', options: [ { name: ['--agent-name', '-n'], @@ -790,415 +847,483 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--output', '-o'], - description: 'Output format (json or table)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'output', + name: 'chat-isolation-key', }, ], }, - ], - }, - ], - }, - { - name: ['show'], - description: 'Show the status of a hosted agent.', - options: [ - { - name: ['--output', '-o'], - description: 'Output format (json or table)', - args: [ { - name: 'output', + name: ['--file', '-f'], + description: 'Local file path to upload', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--session-id', '-s'], + description: 'Session ID override (defaults to last invoke session)', + args: [ + { + name: 'session-id', + }, + ], + }, + { + name: ['--target-path', '-t'], + description: 'Remote destination path (defaults to local filename)', + args: [ + { + name: 'target-path', + }, + ], + }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', + }, + ], }, ], }, ], }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - { - name: ['finetuning'], - description: 'Extension for Foundry Fine Tuning. (Preview)', - subcommands: [ { name: ['init'], - description: 'Initialize a new AI Fine-tuning project. (Preview)', + description: 'Initialize a new AI agent project. (Preview)', options: [ { - name: ['--from-job', '-j'], - description: 'Clone configuration from an existing job ID', + name: ['--agent-name'], + description: 'Foundry agent name to write to agent.yaml. Reusing a name creates a new version of the existing agent.', args: [ { - name: 'from-job', + name: 'agent-name', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--dep-resolution'], + description: 'Dependency resolution for code deploy: \'remote_build\' or \'bundled\'. Defaults to \'remote_build\'.', args: [ { - name: 'project-endpoint', + name: 'dep-resolution', }, ], }, { - name: ['--project-resource-id', '-p'], - description: 'ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})', + name: ['--deploy-mode'], + description: 'Deployment mode: \'container\' (Docker image) or \'code\' (ZIP upload). Defaults to \'container\' in --no-prompt.', args: [ { - name: 'project-resource-id', + name: 'deploy-mode', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--entry-point'], + description: 'Entry point file for code deploy (e.g., \'app.py\', \'MyAgent.dll\'). Required with --deploy-mode code --no-prompt.', args: [ { - name: 'subscription', + name: 'entry-point', }, ], }, { - name: ['--template', '-t'], - description: 'URL or path to a fine-tune job template', + name: ['--force'], + description: 'Overwrite an input manifest that already lives inside the generated src tree without prompting. Required together with --no-prompt when init would otherwise need confirmation.', + isDangerous: true, + }, + { + name: ['--manifest', '-m'], + description: 'Path or URI to an agent manifest to add to your azd project', args: [ { - name: 'template', + name: 'manifest', }, ], }, { - name: ['--working-directory', '-w'], - description: 'Local path for project output', + name: ['--model'], + description: 'Name of the AI model to use (e.g., \'gpt-4o\'). If not specified, defaults to \'gpt-4.1-mini\'. Mutually exclusive with --model-deployment, with --model-deployment being used if both are provided', args: [ { - name: 'working-directory', + name: 'model', }, ], }, - ], - }, - { - name: ['jobs'], - description: 'Manage fine-tuning jobs', - subcommands: [ { - name: ['cancel'], - description: 'Cancels a running or queued fine-tuning job.', - options: [ - { - name: ['--force'], - description: 'Skip confirmation prompt', - isDangerous: true, - }, - { - name: ['--id', '-i'], - description: 'Job ID (required)', - args: [ - { - name: 'id', - }, - ], - }, + name: ['--model-deployment', '-d'], + description: 'Name of an existing model deployment to use from the Foundry project. Only used when paired with an existing Foundry project, either via --project-id or interactive prompts', + args: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', - args: [ - { - name: 'project-endpoint', - }, - ], + name: 'model-deployment', }, + ], + }, + { + name: ['--project-id', '-p'], + description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', + args: [ { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', - args: [ - { - name: 'subscription', - }, - ], + name: 'project-id', }, ], }, { - name: ['deploy'], - description: 'Deploy a fine-tuned model to Azure Cognitive Services', - options: [ + name: ['--protocol'], + description: 'Protocols supported by the agent (e.g., \'responses\', \'invocations\'). Can be specified multiple times.', + isRepeatable: true, + args: [ { - name: ['--capacity', '-c'], - description: 'Capacity units', - args: [ - { - name: 'capacity', - }, - ], + name: 'protocol', }, + ], + }, + { + name: ['--runtime'], + description: 'Runtime for code deploy (e.g., \'python_3_13\', \'python_3_14\', \'dotnet_10\'). Required with --deploy-mode code --no-prompt.', + args: [ { - name: ['--deployment-name', '-d'], - description: 'Deployment name (required)', - args: [ - { - name: 'deployment-name', - }, - ], + name: 'runtime', }, + ], + }, + { + name: ['--src', '-s'], + description: 'Directory to download the agent definition to (defaults to \'src/\')', + args: [ { - name: ['--job-id', '-i'], - description: 'Fine-tuning job ID (required)', - args: [ - { - name: 'job-id', - }, - ], + name: 'src', }, + ], + }, + ], + }, + { + name: ['invoke'], + description: 'Send a message to your agent.', + options: [ + { + name: ['--agent-endpoint'], + description: 'Full endpoint URL of a deployed agent (run \'azd ai agent show\' to see it). Invokes without requiring an azd project; protocol is derived from the URL.', + args: [ { - name: ['--model-format', '-m'], - description: 'Model format', - args: [ - { - name: 'model-format', - }, - ], + name: 'agent-endpoint', }, + ], + }, + { + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + args: [ { - name: ['--no-wait'], - description: 'Do not wait for deployment to complete', + name: 'chat-isolation-key', }, + ], + }, + { + name: ['--conversation-id'], + description: 'Explicit conversation ID override', + args: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', - args: [ - { - name: 'project-endpoint', - }, - ], + name: 'conversation-id', }, + ], + }, + { + name: ['--input-file', '-f'], + description: 'Path to a file whose contents are sent as the request body', + args: [ { - name: ['--sku', '-k'], - description: 'SKU for deployment', - args: [ - { - name: 'sku', - }, - ], + name: 'input-file', }, + ], + }, + { + name: ['--local', '-l'], + description: 'Invoke on localhost instead of Foundry', + }, + { + name: ['--new-conversation'], + description: 'Force a new conversation (discard saved one)', + }, + { + name: ['--new-session'], + description: 'Force a new session (discard saved one)', + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', - args: [ - { - name: 'subscription', - }, - ], + name: 'output', + suggestions: ['default', 'raw'], }, + ], + }, + { + name: ['--port'], + description: 'Local server port', + args: [ { - name: ['--version', '-v'], - description: 'Model version', - args: [ - { - name: 'version', - }, - ], + name: 'port', }, ], }, { - name: ['list'], - description: 'List fine-tuning jobs.', - options: [ + name: ['--protocol', '-p'], + description: 'Protocol to use: responses (default) or invocations', + args: [ { - name: ['--after'], - description: 'Pagination cursor', - args: [ - { - name: 'after', - }, - ], + name: 'protocol', }, + ], + }, + { + name: ['--session-id', '-s'], + description: 'Explicit session ID override', + args: [ { - name: ['--output', '-o'], - description: 'Output format: table, json', - args: [ - { - name: 'output', - }, - ], + name: 'session-id', }, + ], + }, + { + name: ['--timeout', '-t'], + description: 'Request timeout in seconds (0 for no timeout)', + args: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', - args: [ - { - name: 'project-endpoint', - }, - ], + name: 'timeout', }, + ], + }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', - args: [ - { - name: 'subscription', - }, - ], + name: 'user-isolation-key', }, + ], + }, + { + name: ['--version'], + description: 'Agent version to invoke (creates or reuses a session backed by that version)', + args: [ { - name: ['--top', '-t'], - description: 'Number of jobs to return', - args: [ - { - name: 'top', - }, - ], + name: 'version', + }, + ], + }, + ], + }, + { + name: ['monitor'], + description: 'Monitor logs from a hosted agent.', + options: [ + { + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'chat-isolation-key', }, ], }, { - name: ['pause'], - description: 'Pauses a running fine-tuning job.', + name: ['--follow', '-f'], + description: 'Stream logs in real-time', + }, + { + name: ['--raw'], + description: 'Print the raw SSE stream without formatting', + }, + { + name: ['--session-id', '-s'], + description: 'Session ID to stream logs for', + args: [ + { + name: 'session-id', + }, + ], + }, + { + name: ['--tail', '-l'], + description: 'Number of trailing log lines to fetch (1-300)', + args: [ + { + name: 'tail', + }, + ], + }, + { + name: ['--type', '-t'], + description: 'Type of logs: \'console\' (stdout/stderr) or \'system\' (container events)', + args: [ + { + name: 'type', + }, + ], + }, + { + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'user-isolation-key', + }, + ], + }, + { + name: ['--utc'], + description: 'Display timestamps in UTC instead of local time', + }, + ], + }, + { + name: ['optimize'], + description: 'Evaluate and optimize AI agents.', + subcommands: [ + { + name: ['apply'], + description: 'Apply optimized candidate configuration locally to your azd project.', options: [ { - name: ['--id', '-i'], - description: 'Job ID (required)', + name: ['--agent'], + description: 'Agent service name (auto-detected from azure.yaml)', args: [ { - name: 'id', + name: 'agent', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--candidate'], + description: 'Candidate ID from optimization results (required)', args: [ { - name: 'project-endpoint', + name: 'candidate', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', args: [ { - name: 'subscription', + name: 'endpoint', }, ], }, - ], - }, - { - name: ['resume'], - description: 'Resumes a paused fine-tuning job.', - options: [ { - name: ['--id', '-i'], - description: 'Job ID (required)', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', args: [ { - name: 'id', + name: 'project-endpoint', }, ], }, + ], + }, + { + name: ['cancel'], + description: 'Cancel a running optimization job.', + options: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', args: [ { - name: 'project-endpoint', + name: 'endpoint', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', args: [ { - name: 'subscription', + name: 'project-endpoint', }, ], }, ], }, { - name: ['show'], - description: 'Shows detailed information about a specific job.', + name: ['deploy'], + description: 'Deploy a winning optimization candidate as a new agent version via the API.', options: [ { - name: ['--id', '-i'], - description: 'Job ID (required)', + name: ['--agent'], + description: 'Agent name to deploy to (auto-detected from agent.yaml)', args: [ { - name: 'id', + name: 'agent', }, ], }, { - name: ['--logs'], - description: 'Include recent training logs', - }, - { - name: ['--output', '-o'], - description: 'Output format: table, json, yaml', + name: ['--candidate'], + description: 'Candidate ID from optimization results (required)', args: [ { - name: 'output', + name: 'candidate', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', args: [ { - name: 'project-endpoint', + name: 'endpoint', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', args: [ { - name: 'subscription', + name: 'project-endpoint', }, ], }, ], }, { - name: ['submit'], - description: 'Submit fine-tuning job.', + name: ['list'], + description: 'List recent optimization runs.', options: [ { - name: ['--file', '-f'], - description: 'Path to the config file.', + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', args: [ { - name: 'file', + name: 'endpoint', }, ], }, { - name: ['--model', '-m'], - description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', + name: ['--limit'], + description: 'Maximum number of results', args: [ { - name: 'model', + name: 'limit', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', args: [ { name: 'project-endpoint', @@ -1206,180 +1331,290 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['--seed', '-r'], - description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', + name: ['--status'], + description: 'Filter by status (pending/running/completed/failed/cancelled)', args: [ { - name: 'seed', + name: 'status', }, ], }, + ], + }, + { + name: ['status'], + description: 'Check the status of an optimization job.', + options: [ { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', args: [ { - name: 'subscription', + name: 'endpoint', }, ], }, { - name: ['--suffix', '-x'], - description: 'An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.', + name: ['--poll-interval'], + description: 'Polling interval in seconds', args: [ { - name: 'suffix', + name: 'poll-interval', }, ], }, { - name: ['--training-file', '-t'], - description: 'Training file ID or local path. Use \'local:\' prefix for local paths. Required if --file is not provided', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', args: [ { - name: 'training-file', + name: 'project-endpoint', }, ], }, { - name: ['--validation-file', '-v'], - description: 'Validation file ID or local path. Use \'local:\' prefix for local paths.', - args: [ - { - name: 'validation-file', - }, - ], + name: ['--watch'], + description: 'Poll until job completes', }, ], }, ], options: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--agent', '-a'], + description: 'Agent name (auto-detected from azd project if omitted)', args: [ { - name: 'project-endpoint', + name: 'agent', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID (enables implicit init if environment not configured)', + name: ['--config', '-c'], + description: 'Path to YAML config file (optional — values are prompted interactively if omitted)', args: [ { - name: 'subscription', + name: 'config', }, ], }, - ], - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - { - name: ['models'], - description: 'Extension for managing custom models in Azure AI Foundry. (Preview)', - subcommands: [ - { - name: ['custom'], - description: 'Manage custom models in Azure AI Foundry', - subcommands: [ { - name: ['create'], - description: 'Upload and register a custom model', - options: [ + name: ['--dataset', '-d'], + description: 'Existing local file or registered dataset name', + args: [ { - name: ['--azcopy-path'], - description: 'Path to azcopy binary (auto-detected if not provided)', - args: [ - { - name: 'azcopy-path', - }, - ], + name: 'dataset', }, + ], + }, + { + name: ['--endpoint'], + description: 'Optimization service endpoint (for local dev)', + args: [ { - name: ['--base-model'], - description: 'Base model identifier (e.g., FW-GPT-OSS-120B or full azureml:// URI)', - args: [ - { - name: 'base-model', - }, + name: 'endpoint', + }, + ], + }, + { + name: ['--eval-model', '-m'], + description: 'Model for evaluation (required)', + args: [ + { + name: 'eval-model', + }, + ], + }, + { + name: ['--evaluator'], + description: 'Built-in or custom evaluator name (repeatable; required when not set in config)', + isRepeatable: true, + args: [ + { + name: 'evaluator', + }, + ], + }, + { + name: ['--max-candidates'], + description: 'Maximum number of optimization candidates to generate (must be >= 1; default: 5)', + args: [ + { + name: 'max-candidates', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Submit job and return immediately without waiting for completion', + }, + { + name: ['--optimize-model'], + description: 'Model for optimization reasoning (gpt-5 family recommended; required)', + args: [ + { + name: 'optimize-model', + }, + ], + }, + { + name: ['--poll-interval'], + description: 'Polling interval in seconds', + args: [ + { + name: 'poll-interval', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['run'], + description: 'Run your agent locally for development.', + options: [ + { + name: ['--no-inspector'], + description: 'Do not open Agent Inspector', + }, + { + name: ['--port', '-p'], + description: 'Port to listen on', + args: [ + { + name: 'port', + }, + ], + }, + { + name: ['--start-command', '-c'], + description: 'Explicit startup command (overrides azure.yaml and auto-detection)', + args: [ + { + name: 'start-command', + }, + ], + }, + ], + }, + { + name: ['sample'], + description: 'Browse the curated catalog of agent samples and azd templates.', + subcommands: [ + { + name: ['list', 'ls'], + description: 'List available agent samples that can be used with `azd ai agent init -m`.', + options: [ + { + name: ['--featured-only'], + description: 'Only include samples tagged \'featured\' (the curated starter list).', + }, + { + name: ['--language'], + description: 'Filter by language token. Supported values: python, dotnetCsharp.', + args: [ + { + name: 'language', + }, ], }, { - name: ['--description'], - description: 'Model description', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'description', + name: 'output', + suggestions: ['json', 'text'], }, ], }, { - name: ['--name', '-n'], - description: 'Model name (required)', + name: ['--type'], + description: 'Filter by template type. Supported values: agent, azd.', args: [ { - name: 'name', + name: 'type', }, ], }, + ], + }, + ], + }, + { + name: ['sessions'], + description: 'Manage sessions for a hosted agent endpoint.', + subcommands: [ + { + name: ['create'], + description: 'Create a new session for a hosted agent.', + options: [ { - name: ['--no-wait'], - description: 'Start async registration and return immediately with the operation URL', + name: ['--agent-name', '-n'], + description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + args: [ + { + name: 'agent-name', + }, + ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'project-endpoint', + name: 'chat-isolation-key', }, ], }, { - name: ['--publisher'], - description: 'Model publisher ID for catalog info', + name: ['--isolation-key'], + description: 'Session ownership isolation key header value (x-session-isolation-key; derived from Entra token by default)', args: [ { - name: 'publisher', + name: 'isolation-key', }, ], }, { - name: ['--source'], - description: 'Local path or remote URL to model files', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'source', + name: 'output', + suggestions: ['json', 'table'], }, ], }, { - name: ['--source-file'], - description: 'Path to a file containing the source URL (useful for URLs with special characters)', + name: ['--session-id'], + description: 'Optional caller-provided session ID (auto-generated if omitted)', args: [ { - name: 'source-file', + name: 'session-id', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'subscription', + name: 'user-isolation-key', }, ], }, { name: ['--version'], - description: 'Model version', + description: 'Agent version to back the session (auto-resolved from azd environment if omitted)', args: [ { name: 'version', @@ -1390,46 +1625,41 @@ const completionSpec: Fig.Spec = { }, { name: ['delete'], - description: 'Delete a custom model', + description: 'Delete a session.', options: [ { - name: ['--force', '-f'], - description: 'Skip confirmation prompt', - isDangerous: true, - }, - { - name: ['--name', '-n'], - description: 'Model name (required)', + name: ['--agent-name', '-n'], + description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', args: [ { - name: 'name', + name: 'agent-name', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'project-endpoint', + name: 'chat-isolation-key', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--isolation-key'], + description: 'Session ownership isolation key header value (x-session-isolation-key; derived from Entra token by default)', args: [ { - name: 'subscription', + name: 'isolation-key', }, ], }, { - name: ['--version'], - description: 'Model version', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'version', + name: 'user-isolation-key', }, ], }, @@ -1437,32 +1667,60 @@ const completionSpec: Fig.Spec = { }, { name: ['list'], - description: 'List all custom models', + description: 'List sessions for a hosted agent.', options: [ + { + name: ['--agent-name', '-n'], + description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', + args: [ + { + name: 'agent-name', + }, + ], + }, + { + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', + args: [ + { + name: 'chat-isolation-key', + }, + ], + }, + { + name: ['--limit'], + description: 'Maximum number of sessions to return', + args: [ + { + name: 'limit', + }, + ], + }, { name: ['--output', '-o'], - description: 'Output format (table, json)', + description: 'The output format', args: [ { name: 'output', + suggestions: ['json', 'table'], }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--pagination-token'], + description: 'Continuation token from a previous list response', args: [ { - name: 'project-endpoint', + name: 'pagination-token', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'subscription', + name: 'user-isolation-key', }, ], }, @@ -1470,654 +1728,3315 @@ const completionSpec: Fig.Spec = { }, { name: ['show'], - description: 'Show details of a custom model', + description: 'Show details of a session.', options: [ { - name: ['--name', '-n'], - description: 'Model name (required)', - args: [ - { - name: 'name', - }, - ], - }, - { - name: ['--output', '-o'], - description: 'Output format (table, json)', + name: ['--agent-name', '-n'], + description: 'Agent name (matches azure.yaml service name; auto-detected when only one exists)', args: [ { - name: 'output', + name: 'agent-name', }, ], }, { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--chat-isolation-key'], + description: 'Foundry chat isolation key header value (x-agent-chat-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'project-endpoint', + name: 'chat-isolation-key', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'subscription', + name: 'output', + suggestions: ['json', 'table'], }, ], }, { - name: ['--version'], - description: 'Model version (defaults to latest)', + name: ['--user-isolation-key'], + description: 'Foundry user isolation key header value (x-agent-user-isolation-key); independent of --isolation-key (session ownership)', args: [ { - name: 'version', + name: 'user-isolation-key', }, ], }, ], }, ], + }, + { + name: ['show'], + description: 'Show the status of a hosted agent.', options: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'project-endpoint', + name: 'output', + suggestions: ['json', 'table'], }, ], }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + { + name: ['connection'], + description: 'Manage Microsoft Foundry Connections from your terminal. (Preview)', + subcommands: [ + { + name: ['context'], + description: 'Get the context of the azd project & environment.', + options: [ { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', args: [ { - name: 'subscription', + name: 'project-endpoint', }, ], }, ], }, { - name: ['init'], - description: 'Initialize a new AI models project. (Preview)', + name: ['create'], + description: 'Create a new Foundry project connection.', options: [ { - name: ['--project-endpoint', '-e'], - description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + name: ['--audience'], + description: 'Token audience for user-entra-token/agentic-identity/project-managed-identity auth', args: [ { - name: 'project-endpoint', + name: 'audience', }, ], }, { - name: ['--project-resource-id', '-p'], - description: 'ARM resource ID of the Foundry project', + name: ['--auth-type'], + description: 'Auth type: api-key, custom-keys, none, oauth2, user-entra-token, project-managed-identity, agentic-identity', args: [ { - name: 'project-resource-id', + name: 'auth-type', }, ], }, { - name: ['--subscription', '-s'], - description: 'Azure subscription ID', + name: ['--authorization-url'], + description: 'OAuth2 authorization endpoint URL', args: [ { - name: 'subscription', + name: 'authorization-url', }, ], }, - ], - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - ], - }, - { - name: ['appservice'], - description: 'Extension for managing Azure App Service resources.', - subcommands: [ - { - name: ['swap'], - description: 'Swap deployment slots for an App Service.', - options: [ - { - name: ['--dst'], - description: 'The destination slot name. Use \'production\' for main app.', - args: [ { - name: 'dst', + name: ['--client-id'], + description: 'OAuth2 client ID (required for BYO OAuth2)', + args: [ + { + name: 'client-id', + }, + ], }, - ], - }, - { - name: ['--service'], - description: 'The name of the service to swap slots for.', - args: [ { - name: 'service', + name: ['--client-secret'], + description: 'OAuth2 client secret (required for BYO OAuth2)', + args: [ + { + name: 'client-secret', + }, + ], }, - ], - }, - { - name: ['--src'], - description: 'The source slot name. Use \'production\' for main app.', - args: [ { - name: 'src', + name: ['--connector-name'], + description: 'Managed connector name (for OAuth2 connectors)', + args: [ + { + name: 'connector-name', + }, + ], }, - ], - }, - ], - }, - { - name: ['version'], - description: 'Display the version of the extension.', - }, - ], - }, - { - name: ['auth'], - description: 'Authenticate with Azure.', - subcommands: [ - { - name: ['login'], - description: 'Log in to Azure.', - options: [ - { - name: ['--check-status'], - description: 'Checks the log-in status instead of logging in.', - }, - { - name: ['--client-certificate'], - description: 'The path to the client certificate for the service principal to authenticate with.', - args: [ { - name: 'client-certificate', + name: ['--custom-key'], + description: 'Custom key=value (repeatable, for custom-keys auth)', + isRepeatable: true, + args: [ + { + name: 'custom-key', + }, + ], + }, + { + name: ['--force'], + description: 'Replace existing connection (upsert)', + isDangerous: true, + }, + { + name: ['--key'], + description: 'API key (for api-key auth)', + args: [ + { + name: 'key', + }, + ], + }, + { + name: ['--kind'], + description: 'Connection kind (e.g., remote-tool, remote-a2a, cognitive-search)', + args: [ + { + name: 'kind', + }, + ], + }, + { + name: ['--metadata'], + description: 'Metadata key=value (repeatable)', + isRepeatable: true, + args: [ + { + name: 'metadata', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--refresh-url'], + description: 'OAuth2 token refresh URL', + args: [ + { + name: 'refresh-url', + }, + ], + }, + { + name: ['--scopes'], + description: 'OAuth2 scopes (repeatable or comma-separated, e.g. --scopes read:user,user:email)', + isRepeatable: true, + args: [ + { + name: 'scopes', + }, + ], + }, + { + name: ['--target'], + description: 'Target URL or ARM resource ID', + args: [ + { + name: 'target', + }, + ], + }, + { + name: ['--token-url'], + description: 'OAuth2 token endpoint URL', + args: [ + { + name: 'token-url', + }, + ], }, ], }, { - name: ['--client-id'], - description: 'The client id for the service principal to authenticate with.', - args: [ + name: ['delete'], + description: 'Delete a connection.', + options: [ { - name: 'client-id', + name: ['--force'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, { - name: ['--client-secret'], - description: 'The client secret for the service principal to authenticate with. Set to the empty string to read the value from the console.', - args: [ + name: ['list'], + description: 'List connections in the Foundry project.', + options: [ { - name: 'client-secret', + name: ['--kind'], + description: 'Filter by connection kind (e.g., remote-tool)', + args: [ + { + name: 'kind', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, { - name: ['--federated-credential-provider'], - description: 'The provider to use to acquire a federated token to authenticate with. Supported values: github, azure-pipelines, oidc', - args: [ + name: ['show'], + description: 'Show connection details.', + options: [ { - name: 'federated-credential-provider', - suggestions: ['github', 'azure-pipelines', 'oidc'], + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--show-credentials'], + description: 'Fetch credential values from the data plane', }, ], }, { - name: ['--managed-identity'], - description: 'Use a managed identity to authenticate.', - }, - { - name: ['--redirect-port'], - description: 'Choose the port to be used as part of the redirect URI during interactive login.', - args: [ + name: ['update'], + description: 'Update a connection\'s target, credentials, or metadata.', + options: [ { - name: 'redirect-port', + name: ['--custom-key'], + description: 'Update custom key=value (repeatable, for custom-keys auth)', + isRepeatable: true, + args: [ + { + name: 'custom-key', + }, + ], + }, + { + name: ['--key'], + description: 'New API key value (for api-key auth)', + args: [ + { + name: 'key', + }, + ], + }, + { + name: ['--metadata'], + description: 'Set metadata key=value (repeatable, merged with existing metadata)', + isRepeatable: true, + args: [ + { + name: 'metadata', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--target'], + description: 'New target URL or ARM resource ID', + args: [ + { + name: 'target', + }, + ], }, ], }, { - name: ['--tenant-id'], - description: 'The tenant id or domain name to authenticate with.', - args: [ + name: ['version'], + description: 'Display the extension version', + options: [ { - name: 'tenant-id', + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, - { - name: ['--use-device-code'], - description: 'When true, log in by using a device code instead of a browser.', - }, ], }, { - name: ['logout'], - description: 'Log out of Azure.', + name: ['finetuning'], + description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + options: [ + { + name: ['--from-job', '-j'], + description: 'Clone configuration from an existing job ID', + args: [ + { + name: 'from-job', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--project-resource-id', '-p'], + description: 'ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})', + args: [ + { + name: 'project-resource-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--template', '-t'], + description: 'URL or path to a fine-tune job template', + args: [ + { + name: 'template', + }, + ], + }, + { + name: ['--working-directory', '-w'], + description: 'Local path for project output', + args: [ + { + name: 'working-directory', + }, + ], + }, + ], + }, + { + name: ['jobs'], + description: 'Manage fine-tuning jobs', + subcommands: [ + { + name: ['cancel'], + description: 'Cancels a running or queued fine-tuning job.', + options: [ + { + name: ['--force'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + options: [ + { + name: ['--capacity', '-c'], + description: 'Capacity units', + args: [ + { + name: 'capacity', + }, + ], + }, + { + name: ['--deployment-name', '-d'], + description: 'Deployment name (required)', + args: [ + { + name: 'deployment-name', + }, + ], + }, + { + name: ['--job-id', '-i'], + description: 'Fine-tuning job ID (required)', + args: [ + { + name: 'job-id', + }, + ], + }, + { + name: ['--model-format', '-m'], + description: 'Model format', + args: [ + { + name: 'model-format', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Do not wait for deployment to complete', + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--sku', '-k'], + description: 'SKU for deployment', + args: [ + { + name: 'sku', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List fine-tuning jobs.', + options: [ + { + name: ['--after'], + description: 'Pagination cursor', + args: [ + { + name: 'after', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--top', '-t'], + description: 'Number of jobs to return', + args: [ + { + name: 'top', + }, + ], + }, + ], + }, + { + name: ['pause'], + description: 'Pauses a running fine-tuning job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--logs'], + description: 'Include recent training logs', + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json, yaml', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['submit'], + description: 'Submit fine-tuning job.', + options: [ + { + name: ['--file', '-f'], + description: 'Path to the config file.', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--model', '-m'], + description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', + args: [ + { + name: 'model', + }, + ], + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--seed', '-r'], + description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', + args: [ + { + name: 'seed', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--suffix', '-x'], + description: 'An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.', + args: [ + { + name: 'suffix', + }, + ], + }, + { + name: ['--training-file', '-t'], + description: 'Training file ID or local path. Use \'local:\' prefix for local paths. Required if --file is not provided', + args: [ + { + name: 'training-file', + }, + ], + }, + { + name: ['--validation-file', '-v'], + description: 'Validation file ID or local path. Use \'local:\' prefix for local paths.', + args: [ + { + name: 'validation-file', + }, + ], + }, + ], + }, + ], + options: [ + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID (enables implicit init if environment not configured)', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + { + name: ['inspector'], + description: 'Browser-based inspector UI for locally running Foundry agents. (Preview)', + subcommands: [ + { + name: ['launch'], + description: 'Launch the Agent Inspector UI in a browser, pointed at a local agent.', + options: [ + { + name: ['--conversation-id'], + description: 'Optional explicit conversation ID for the SPA. If omitted, the SPA mints a fresh UUID.', + args: [ + { + name: 'conversation-id', + }, + ], + }, + { + name: ['--inspector-port'], + description: 'Port the Agent Inspector UI listens on (default: 8087)', + args: [ + { + name: 'inspector-port', + }, + ], + }, + { + name: ['--port'], + description: 'Localhost port of the agent the inspector targets (default: 8088)', + args: [ + { + name: 'port', + }, + ], + }, + { + name: ['--session-id'], + description: 'Optional explicit session ID for the SPA. If omitted, the SPA mints a fresh UUID.', + args: [ + { + name: 'session-id', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Display the extension version', + }, + ], + }, + { + name: ['models'], + description: 'Extension for managing custom models in Azure AI Foundry. (Preview)', + subcommands: [ + { + name: ['create'], + description: 'Upload and register a custom model', + options: [ + { + name: ['--azcopy-path'], + description: 'Path to azcopy binary (auto-detected if not provided)', + args: [ + { + name: 'azcopy-path', + }, + ], + }, + { + name: ['--base-model'], + description: 'Base model identifier (e.g., FW-GPT-OSS-120B or full azureml:// URI)', + args: [ + { + name: 'base-model', + }, + ], + }, + { + name: ['--description'], + description: 'Model description', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--lora-alpha'], + description: 'LoRA scaling factor (alpha) — required when --weight-type is LoRA', + args: [ + { + name: 'lora-alpha', + }, + ], + }, + { + name: ['--lora-dropout'], + description: 'LoRA dropout rate used during training (informational)', + args: [ + { + name: 'lora-dropout', + }, + ], + }, + { + name: ['--lora-rank'], + description: 'LoRA rank (r) — required when --weight-type is LoRA', + args: [ + { + name: 'lora-rank', + }, + ], + }, + { + name: ['--lora-target-modules'], + description: 'Comma-separated list of target modules (e.g., "q_proj,v_proj,k_proj,o_proj")', + args: [ + { + name: 'lora-target-modules', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Start async registration and return immediately with the operation URL', + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--publisher'], + description: 'Model publisher ID for catalog info (e.g., Fireworks)', + args: [ + { + name: 'publisher', + }, + ], + }, + { + name: ['--source'], + description: 'Local path or remote URL to model files', + args: [ + { + name: 'source', + }, + ], + }, + { + name: ['--source-file'], + description: 'Path to a file containing the source URL (useful for URLs with special characters)', + args: [ + { + name: 'source-file', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + { + name: ['--weight-type'], + description: 'Weight type (e.g., FullWeight, LoRA)', + args: [ + { + name: 'weight-type', + }, + ], + }, + ], + }, + { + name: ['custom'], + description: 'Manage custom models in Azure AI Foundry', + subcommands: [ + { + name: ['create'], + description: 'Upload and register a custom model', + options: [ + { + name: ['--azcopy-path'], + description: 'Path to azcopy binary (auto-detected if not provided)', + args: [ + { + name: 'azcopy-path', + }, + ], + }, + { + name: ['--base-model'], + description: 'Base model identifier (e.g., FW-GPT-OSS-120B or full azureml:// URI)', + args: [ + { + name: 'base-model', + }, + ], + }, + { + name: ['--description'], + description: 'Model description', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--lora-alpha'], + description: 'LoRA scaling factor (alpha) — required when --weight-type is LoRA', + args: [ + { + name: 'lora-alpha', + }, + ], + }, + { + name: ['--lora-dropout'], + description: 'LoRA dropout rate used during training (informational)', + args: [ + { + name: 'lora-dropout', + }, + ], + }, + { + name: ['--lora-rank'], + description: 'LoRA rank (r) — required when --weight-type is LoRA', + args: [ + { + name: 'lora-rank', + }, + ], + }, + { + name: ['--lora-target-modules'], + description: 'Comma-separated list of target modules (e.g., "q_proj,v_proj,k_proj,o_proj")', + args: [ + { + name: 'lora-target-modules', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--no-wait'], + description: 'Start async registration and return immediately with the operation URL', + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--publisher'], + description: 'Model publisher ID for catalog info (e.g., Fireworks)', + args: [ + { + name: 'publisher', + }, + ], + }, + { + name: ['--source'], + description: 'Local path or remote URL to model files', + args: [ + { + name: 'source', + }, + ], + }, + { + name: ['--source-file'], + description: 'Path to a file containing the source URL (useful for URLs with special characters)', + args: [ + { + name: 'source-file', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + { + name: ['--weight-type'], + description: 'Weight type (e.g., FullWeight, LoRA)', + args: [ + { + name: 'weight-type', + }, + ], + }, + ], + }, + { + name: ['delete'], + description: 'Delete a custom model', + options: [ + { + name: ['--force', '-f'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List all custom models', + options: [ + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--source-job-id'], + description: 'Filter models by the training job that created them', + args: [ + { + name: 'source-job-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details of a custom model', + options: [ + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version (defaults to latest)', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['update'], + description: 'Update a custom model', + options: [ + { + name: ['--description'], + description: 'New model description', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--remove-tag'], + description: 'Remove a tag by key; can be specified multiple times', + isRepeatable: true, + args: [ + { + name: 'remove-tag', + }, + ], + }, + { + name: ['--set-tag'], + description: 'Set a tag (key=value); can be specified multiple times', + isRepeatable: true, + args: [ + { + name: 'set-tag', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + ], + options: [ + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['delete'], + description: 'Delete a custom model', + options: [ + { + name: ['--force', '-f'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new AI models project. (Preview)', + options: [ + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--project-resource-id', '-p'], + description: 'ARM resource ID of the Foundry project', + args: [ + { + name: 'project-resource-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List all custom models', + options: [ + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--source-job-id'], + description: 'Filter models by the training job that created them', + args: [ + { + name: 'source-job-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details of a custom model', + options: [ + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version (defaults to latest)', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['update'], + description: 'Update a custom model', + options: [ + { + name: ['--description'], + description: 'New model description', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Model name (required)', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Output format (table, json)', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--remove-tag'], + description: 'Remove a tag by key; can be specified multiple times', + isRepeatable: true, + args: [ + { + name: 'remove-tag', + }, + ], + }, + { + name: ['--set-tag'], + description: 'Set a tag (key=value); can be specified multiple times', + isRepeatable: true, + args: [ + { + name: 'set-tag', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--version'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], + }, + { + name: ['project'], + description: 'Manage Microsoft Foundry Project resources from your terminal. (Preview)', + subcommands: [ + { + name: ['context'], + description: 'Get the context of the azd project & environment.', + }, + { + name: ['set'], + description: 'Persist a default Foundry project endpoint.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Display the currently resolved Foundry project endpoint.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + ], + }, + { + name: ['unset'], + description: 'Clear the persisted Foundry project endpoint.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Display the extension version', + }, + ], + }, + { + name: ['routine'], + description: 'Manage Microsoft Foundry Routines from your terminal. (Preview)', + subcommands: [ + { + name: ['context'], + description: 'Get the context of the azd project & environment.', + options: [ + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['create'], + description: 'Create a new routine.', + options: [ + { + name: ['--action'], + description: 'Action type: agent-response (default), agent-invoke', + args: [ + { + name: 'action', + }, + ], + }, + { + name: ['--agent-endpoint-id'], + description: 'Agent endpoint ID (for agent-response or agent-invoke action)', + args: [ + { + name: 'agent-endpoint-id', + }, + ], + }, + { + name: ['--agent-name'], + description: 'Project-scoped agent name (for agent-response or agent-invoke action)', + args: [ + { + name: 'agent-name', + }, + ], + }, + { + name: ['--at'], + description: 'ISO 8601 datetime for timer trigger (e.g. \'2026-04-24T15:00:00Z\')', + args: [ + { + name: 'at', + }, + ], + }, + { + name: ['--connection-id'], + description: 'Workspace connection ID (for github-issue trigger)', + args: [ + { + name: 'connection-id', + }, + ], + }, + { + name: ['--conversation-id'], + description: 'Existing conversation to continue (for agent-response action, preview)', + args: [ + { + name: 'conversation-id', + }, + ], + }, + { + name: ['--cron'], + description: '5-field cron expression for recurring trigger (minimum interval 5 minutes)', + args: [ + { + name: 'cron', + }, + ], + }, + { + name: ['--description'], + description: 'Description for the routine', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--enabled'], + description: 'Whether the routine is enabled on creation', + }, + { + name: ['--event-name'], + description: 'Provider-specific event name (for custom trigger)', + args: [ + { + name: 'event-name', + }, + ], + }, + { + name: ['--file'], + description: 'Path to a YAML or JSON routine manifest file', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--force'], + description: 'Overwrite an existing routine with the same name (upsert)', + isDangerous: true, + }, + { + name: ['--issue-event'], + description: 'GitHub issue event: opened or closed (for github-issue trigger)', + args: [ + { + name: 'issue-event', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--owner'], + description: 'GitHub owner or organization (for github-issue trigger)', + args: [ + { + name: 'owner', + }, + ], + }, + { + name: ['--parameters'], + description: 'Provider-specific trigger parameters as a JSON object (for custom trigger)', + args: [ + { + name: 'parameters', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--provider'], + description: 'External event provider (for custom trigger)', + args: [ + { + name: 'provider', + }, + ], + }, + { + name: ['--repository'], + description: 'GitHub repository name (for github-issue trigger)', + args: [ + { + name: 'repository', + }, + ], + }, + { + name: ['--session-id'], + description: 'Existing session to continue (for agent-invoke action)', + args: [ + { + name: 'session-id', + }, + ], + }, + { + name: ['--time-zone'], + description: 'Time zone for the recurring trigger (e.g. \'America/New_York\')', + args: [ + { + name: 'time-zone', + }, + ], + }, + { + name: ['--trigger'], + description: 'Trigger type: timer, recurring, github-issue, or custom (required unless --file is used)', + args: [ + { + name: 'trigger', + }, + ], + }, + ], + }, + { + name: ['delete'], + description: 'Delete a routine.', + options: [ + { + name: ['--force'], + description: 'Skip confirmation prompt (required in --no-prompt mode)', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['disable'], + description: 'Disable a routine.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['dispatch'], + description: 'Manually trigger a routine.', + options: [ + { + name: ['--async'], + description: 'Suppress descriptive output; useful for scripting', + }, + { + name: ['--input'], + description: 'Payload sent to the routine dispatch. If the value parses as JSON, it is forwarded as that JSON value (object, array, number, boolean, or null); otherwise it is forwarded as a plain string.', + args: [ + { + name: 'input', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['enable'], + description: 'Enable a routine.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List all routines in the Foundry project.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['run'], + description: 'Manage routine run history.', + subcommands: [ + { + name: ['list'], + description: 'List runs for a routine.', + options: [ + { + name: ['--filter'], + description: 'OData filter expression', + args: [ + { + name: 'filter', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--top'], + description: 'Maximum total number of runs to return (0 = no cap)', + args: [ + { + name: 'top', + }, + ], + }, + ], + }, + ], + options: [ + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details of a routine.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['update'], + description: 'Update an existing routine.', + options: [ + { + name: ['--agent-endpoint-id'], + description: 'New agent endpoint ID', + args: [ + { + name: 'agent-endpoint-id', + }, + ], + }, + { + name: ['--agent-name'], + description: 'New project-scoped agent name', + args: [ + { + name: 'agent-name', + }, + ], + }, + { + name: ['--at'], + description: 'New ISO 8601 datetime (timer trigger only)', + args: [ + { + name: 'at', + }, + ], + }, + { + name: ['--connection-id'], + description: 'New workspace connection ID (github-issue trigger only)', + args: [ + { + name: 'connection-id', + }, + ], + }, + { + name: ['--conversation-id'], + description: 'New conversation to continue (preview)', + args: [ + { + name: 'conversation-id', + }, + ], + }, + { + name: ['--cron'], + description: 'New cron expression (recurring trigger only)', + args: [ + { + name: 'cron', + }, + ], + }, + { + name: ['--description'], + description: 'New description for the routine', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--event-name'], + description: 'New event name (custom trigger only)', + args: [ + { + name: 'event-name', + }, + ], + }, + { + name: ['--file'], + description: 'Path to a YAML/JSON manifest; merged fields win unless overridden by flags', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--issue-event'], + description: 'New GitHub issue event: opened or closed (github-issue trigger only)', + args: [ + { + name: 'issue-event', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--owner'], + description: 'New GitHub owner (github-issue trigger only)', + args: [ + { + name: 'owner', + }, + ], + }, + { + name: ['--parameters'], + description: 'New parameters JSON object (custom trigger only)', + args: [ + { + name: 'parameters', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--provider'], + description: 'New external provider (custom trigger only)', + args: [ + { + name: 'provider', + }, + ], + }, + { + name: ['--repository'], + description: 'New GitHub repository (github-issue trigger only)', + args: [ + { + name: 'repository', + }, + ], + }, + { + name: ['--session-id'], + description: 'New session to continue', + args: [ + { + name: 'session-id', + }, + ], + }, + { + name: ['--time-zone'], + description: 'New time zone (recurring trigger only)', + args: [ + { + name: 'time-zone', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Display the extension version', + options: [ + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env var and config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + ], }, { - name: ['status'], - description: 'Show the current authentication status.', + name: ['skill'], + description: 'Manage Microsoft Foundry skills (reusable agent behavioral guidelines) from your terminal. (Preview)', + subcommands: [ + { + name: ['context'], + description: 'Get the context of the azd project & environment.', + options: [ + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['create'], + description: 'Create a new Foundry skill.', + options: [ + { + name: ['--description'], + description: 'Inline mode: human-readable summary of the skill', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--file'], + description: 'Path to SKILL.md (.md), a ZIP package (.zip), or a directory containing SKILL.md at its root', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--force'], + description: 'Delete an existing skill of the same name before creating', + isDangerous: true, + }, + { + name: ['--instructions'], + description: 'Inline mode: Markdown body defining skill behavior', + args: [ + { + name: 'instructions', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['delete'], + description: 'Delete a Foundry skill.', + options: [ + { + name: ['--force'], + description: 'Skip the confirmation prompt', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['download'], + description: 'Download a Foundry skill package.', + options: [ + { + name: ['--force'], + description: 'Overwrite existing files in --output-dir', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--output-dir'], + description: 'Directory to write the extracted skill (default: ./.agents/skills//)', + args: [ + { + name: 'output-dir', + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--raw'], + description: 'Skip extraction; write the zip archive as-is to --output-dir', + }, + { + name: ['--version'], + description: 'Download a specific version (defaults to the skill\'s default_version)', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List Foundry skills in the project.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show metadata for a Foundry skill.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + { + name: ['update'], + description: 'Create a new default version for a Foundry skill.', + options: [ + { + name: ['--description'], + description: 'New human-readable summary for the next version', + args: [ + { + name: 'description', + }, + ], + }, + { + name: ['--file'], + description: 'Path to a SKILL.md file, a .zip archive, or a directory whose contents become the next version. Archives and directories must contain a SKILL.md at the root.', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--instructions'], + description: 'New Markdown instructions body for the next version', + args: [ + { + name: 'instructions', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['json', 'table'], + }, + ], + }, + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--set-default-version'], + description: 'Set the skill\'s default_version to an existing version without uploading new content', + args: [ + { + name: 'set-default-version', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Print the extension version.', + options: [ + { + name: ['--project-endpoint', '-p'], + description: 'Foundry project endpoint URL (overrides env vars and global config)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, + ], }, - ], - }, - { - name: ['coding-agent'], - description: 'This extension configures GitHub Copilot Coding Agent access to Azure', - subcommands: [ { - name: ['config'], - description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', - options: [ + name: ['toolbox'], + description: 'Manage Microsoft Foundry Toolboxes from your terminal. (Preview)', + subcommands: [ { - name: ['--branch-name'], - description: 'The branch name to use when pushing changes to the copilot-setup-steps.yml', - args: [ + name: ['connection'], + description: 'Manage the connection-backed tools attached to a toolbox.', + subcommands: [ + { + name: ['add'], + description: 'Attach one or more connections to a toolbox.', + options: [ + { + name: ['--from-file'], + description: 'Path to a JSON/YAML file describing the connections to add (see --help for the file shape).', + args: [ + { + name: 'from-file', + }, + ], + }, + { + name: ['--index'], + description: 'Search index name. Only valid for CognitiveSearch (Azure AI Search) connections; required there.', + args: [ + { + name: 'index', + }, + ], + }, + { + name: ['--instance-name'], + description: 'Bing custom-search configuration name. Only valid for GroundingWithCustomSearch connections; required there.', + args: [ + { + name: 'instance-name', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], + }, { - name: 'branch-name', + name: ['list'], + description: 'List the connection-backed tools attached to a toolbox.', + options: [ + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], }, - ], - }, - { - name: ['--github-host-name'], - description: 'The hostname to use with GitHub commands', - args: [ { - name: 'github-host-name', + name: ['remove'], + description: 'Detach one or more connections from a toolbox.', + options: [ + { + name: ['--force'], + description: 'Skip confirmation prompts and apply the removal immediately.', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + ], }, ], - }, - { - name: ['--managed-identity-name'], - description: 'The name to use for the managed identity, if created.', - args: [ + options: [ { - name: 'managed-identity-name', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, { - name: ['--remote-name'], - description: 'The name of the git remote where the Copilot Coding Agent will run (ex: /)', - args: [ + name: ['create'], + description: 'Create a toolbox and its initial version from a file.', + options: [ { - name: 'remote-name', + name: ['--from-file'], + description: 'Path to a JSON/YAML file describing the initial version (see --help for the file shape).', + args: [ + { + name: 'from-file', + }, + ], }, - ], - }, - { - name: ['--roles'], - description: 'The roles to assign to the service principal or managed identity. By default, the service principal or managed identity will be granted the Reader role.', - isRepeatable: true, - args: [ { - name: 'roles', + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, - ], - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - { - name: ['completion'], - description: 'Generate shell completion scripts.', - subcommands: [ - { - name: ['bash'], - description: 'Generate bash completion script.', - }, - { - name: ['fig'], - description: 'Generate Fig autocomplete spec.', - }, - { - name: ['fish'], - description: 'Generate fish completion script.', - }, - { - name: ['powershell'], - description: 'Generate PowerShell completion script.', - }, - { - name: ['zsh'], - description: 'Generate zsh completion script.', - }, - ], - }, - { - name: ['concurx'], - description: 'Concurrent execution for azd deployment', - subcommands: [ - { - name: ['up'], - description: 'Runs azd up in concurrent mode', - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - { - name: ['config'], - description: 'Manage azd configurations (ex: default Azure subscription, location).', - subcommands: [ - { - name: ['get'], - description: 'Gets a configuration.', - args: { - name: 'path', - generators: azdGenerators.listConfigKeys, - }, - }, - { - name: ['list-alpha'], - description: 'Display the list of available features in alpha stage.', - }, - { - name: ['options'], - description: 'List all available configuration settings.', - }, - { - name: ['reset'], - description: 'Resets configuration to default.', - options: [ - { - name: ['--force', '-f'], - description: 'Force reset without confirmation.', - isDangerous: true, - }, - ], - }, - { - name: ['set'], - description: 'Sets a configuration.', - args: [ - { - name: 'path', - generators: azdGenerators.listConfigKeys, - }, { - name: 'value', - }, - ], - }, - { - name: ['show'], - description: 'Show all the configuration values.', - }, - { - name: ['unset'], - description: 'Unsets a configuration.', - args: { - name: 'path', - generators: azdGenerators.listConfigKeys, - }, - }, - ], - }, - { - name: ['copilot'], - description: 'Manage GitHub Copilot agent settings. (Preview)', - subcommands: [ - { - name: ['consent'], - description: 'Manage tool consent.', - subcommands: [ + name: ['delete'], + description: 'Delete a toolbox or a single version.', + options: [ + { + name: ['--force'], + description: 'Skip confirmation prompts and override safety checks where allowed.', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--version'], + description: 'Delete a single version instead of the whole toolbox.', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, { - name: ['grant'], - description: 'Grant consent trust rules.', + name: ['list'], + description: 'List toolboxes on the project.', options: [ { - name: ['--action'], - description: 'Action type: \'all\' or \'readonly\'', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'action', - suggestions: ['all', 'readonly'], + name: 'output', + suggestions: ['table', 'json'], }, ], }, { - name: ['--global'], - description: 'Apply globally to all servers', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, + ], + }, + { + name: ['publish'], + description: 'Set the default version for a toolbox.', + options: [ { - name: ['--operation'], - description: 'Operation type: \'tool\' or \'sampling\'', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'operation', - suggestions: ['tool', 'sampling'], + name: 'output', + suggestions: ['table', 'json'], }, ], }, { - name: ['--permission'], - description: 'Permission: \'allow\', \'deny\', or \'prompt\'', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', args: [ { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], + name: 'project-endpoint', }, ], }, + ], + }, + { + name: ['show'], + description: 'Show a toolbox version, including its computed MCP endpoint.', + options: [ { - name: ['--scope'], - description: 'Rule scope: \'global\', or \'project\'', + name: ['--output', '-o'], + description: 'The output format', args: [ { - name: 'scope', - suggestions: ['global', 'project'], + name: 'output', + suggestions: ['table', 'json'], }, ], }, { - name: ['--server'], - description: 'Server name', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', args: [ { - name: 'server', + name: 'project-endpoint', }, ], }, { - name: ['--tool'], - description: 'Specific tool name (requires --server)', + name: ['--version'], + description: 'Specific version to show. Defaults to the server\'s default version.', args: [ { - name: 'tool', + name: 'version', }, ], }, ], }, { - name: ['list'], - description: 'List consent rules.', - options: [ + name: ['skill'], + description: 'Manage skill references attached to a toolbox.', + subcommands: [ { - name: ['--action'], - description: 'Action type to filter by (all, readonly)', - args: [ + name: ['add'], + description: 'Attach one or more skill references to a toolbox.', + options: [ { - name: 'action', - suggestions: ['all', 'readonly'], + name: ['--from-file'], + description: 'Path to a JSON/YAML file listing skills to attach (skills[] block).', + args: [ + { + name: 'from-file', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, { - name: ['--operation'], - description: 'Operation to filter by (tool, sampling)', - args: [ + name: ['list'], + description: 'List the skill references attached to a toolbox.', + options: [ { - name: 'operation', - suggestions: ['tool', 'sampling'], + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, { - name: ['--permission'], - description: 'Permission to filter by (allow, deny, prompt)', - args: [ + name: ['remove'], + description: 'Detach one or more skill references from a toolbox.', + options: [ { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], + name: ['--force'], + description: 'Skip confirmation prompts and apply the removal immediately.', + isDangerous: true, + }, + { + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, + ], + options: [ { - name: ['--scope'], - description: 'Consent scope to filter by (global, project). If not specified, lists rules from all scopes.', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', args: [ { - name: 'scope', - suggestions: ['global', 'project'], + name: 'project-endpoint', }, ], }, + ], + }, + { + name: ['version'], + description: 'Display the extension version', + options: [ { - name: ['--target'], - description: 'Specific target to operate on (server/tool format)', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', args: [ { - name: 'target', + name: 'project-endpoint', }, ], }, ], }, { - name: ['revoke'], - description: 'Revoke consent rules.', - options: [ + name: ['versions'], + description: 'Inspect toolbox versions.', + subcommands: [ { - name: ['--action'], - description: 'Action type to filter by (all, readonly)', - args: [ + name: ['list'], + description: 'List published versions for a toolbox.', + options: [ { - name: 'action', - suggestions: ['all', 'readonly'], + name: ['--output', '-o'], + description: 'The output format', + args: [ + { + name: 'output', + suggestions: ['table', 'json'], + }, + ], + }, + { + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', + args: [ + { + name: 'project-endpoint', + }, + ], }, ], }, + ], + options: [ { - name: ['--operation'], - description: 'Operation to filter by (tool, sampling)', + name: ['--project-endpoint'], + description: 'Foundry project endpoint URL. When unset, falls back to the active azd environment, azd user config, then FOUNDRY_PROJECT_ENDPOINT.', args: [ { - name: 'operation', - suggestions: ['tool', 'sampling'], + name: 'project-endpoint', }, ], }, + ], + }, + ], + }, + ], + }, + { + name: ['appservice'], + description: 'Extension for managing Azure App Service resources.', + subcommands: [ + { + name: ['swap'], + description: 'Swap deployment slots for an App Service.', + options: [ + { + name: ['--dst'], + description: 'The destination slot name. Use \'production\' for main app.', + args: [ + { + name: 'dst', + }, + ], + }, + { + name: ['--service'], + description: 'The name of the service to swap slots for.', + args: [ + { + name: 'service', + }, + ], + }, + { + name: ['--src'], + description: 'The source slot name. Use \'production\' for main app.', + args: [ + { + name: 'src', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Display the version of the extension.', + }, + ], + }, + { + name: ['auth'], + description: 'Authenticate with Azure.', + subcommands: [ + { + name: ['login'], + description: 'Log in to Azure.', + options: [ + { + name: ['--check-status'], + description: 'Checks the log-in status instead of logging in.', + }, + { + name: ['--client-certificate'], + description: 'The path to the client certificate for the service principal to authenticate with.', + args: [ + { + name: 'client-certificate', + }, + ], + }, + { + name: ['--client-id'], + description: 'The client id for the service principal to authenticate with.', + args: [ + { + name: 'client-id', + }, + ], + }, + { + name: ['--client-secret'], + description: 'The client secret for the service principal to authenticate with. Set to the empty string to read the value from the console.', + args: [ + { + name: 'client-secret', + }, + ], + }, + { + name: ['--federated-credential-provider'], + description: 'The provider to use to acquire a federated token to authenticate with. Supported values: github, azure-pipelines, oidc', + args: [ { - name: ['--permission'], - description: 'Permission to filter by (allow, deny, prompt)', - args: [ - { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], - }, - ], + name: 'federated-credential-provider', + suggestions: ['github', 'azure-pipelines', 'oidc'], }, + ], + }, + { + name: ['--managed-identity'], + description: 'Use a managed identity to authenticate.', + }, + { + name: ['--redirect-port'], + description: 'Choose the port to be used as part of the redirect URI during interactive login.', + args: [ { - name: ['--scope'], - description: 'Consent scope to filter by (global, project). If not specified, revokes rules from all scopes.', - args: [ - { - name: 'scope', - suggestions: ['global', 'project'], - }, - ], + name: 'redirect-port', }, + ], + }, + { + name: ['--tenant-id'], + description: 'The tenant id or domain name to authenticate with.', + args: [ { - name: ['--target'], - description: 'Specific target to operate on (server/tool format)', - args: [ - { - name: 'target', - }, - ], + name: 'tenant-id', }, ], }, + { + name: ['--use-device-code'], + description: 'When true, log in by using a device code instead of a browser.', + }, ], }, + { + name: ['logout'], + description: 'Log out of Azure.', + }, + { + name: ['status'], + description: 'Show the current authentication status.', + }, ], }, { - name: ['demo'], - description: 'This extension provides examples of the azd extension framework.', + name: ['coding-agent'], + description: 'This extension configures GitHub Copilot Coding Agent access to Azure', subcommands: [ { - name: ['ai'], - description: 'Interactive AI model discovery, deployment, and quota demos.', - subcommands: [ + name: ['config'], + description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', + options: [ { - name: ['deployment'], - description: 'Select model/version/SKU/capacity and resolve a valid deployment configuration.', + name: ['--branch-name'], + description: 'The branch name to use when pushing changes to the copilot-setup-steps.yml', + args: [ + { + name: 'branch-name', + }, + ], }, { - name: ['models'], - description: 'Browse available AI models interactively.', + name: ['--github-host-name'], + description: 'The hostname to use with GitHub commands', + args: [ + { + name: 'github-host-name', + }, + ], }, { - name: ['quota'], - description: 'View usage meters and limits for a selected location.', + name: ['--managed-identity-name'], + description: 'The name to use for the managed identity, if created.', + args: [ + { + name: 'managed-identity-name', + }, + ], }, - ], - }, - { - name: ['colors', 'colours'], - description: 'Displays all ASCII colors with their standard and high-intensity variants.', - }, - { - name: ['config'], - description: 'Set up monitoring configuration for the project and services', - }, - { - name: ['context'], - description: 'Get the context of the azd project & environment.', - }, - { - name: ['gh-url-parse'], - description: 'Parse a GitHub URL and extract repository information.', - }, - { - name: ['listen'], - description: 'Starts the extension and listens for events.', - }, - { - name: ['mcp'], - description: 'MCP server commands for demo extension', - subcommands: [ { - name: ['start'], - description: 'Start MCP server with demo tools', + name: ['--remote-name'], + description: 'The name of the git remote where the Copilot Coding Agent will run (ex: /)', + args: [ + { + name: 'remote-name', + }, + ], + }, + { + name: ['--roles'], + description: 'The roles to assign to the service principal or managed identity. By default, the service principal or managed identity will be granted the Reader role.', + isRepeatable: true, + args: [ + { + name: 'roles', + }, + ], }, ], }, - { - name: ['prompt'], - description: 'Examples of prompting the user for input.', - }, { name: ['version'], description: 'Prints the version of the application', @@ -2125,836 +5044,718 @@ const completionSpec: Fig.Spec = { ], }, { - name: ['deploy'], - description: 'Deploy your project code to Azure.', - options: [ + name: ['completion'], + description: 'Generate shell completion scripts.', + subcommands: [ { - name: ['--all'], - description: 'Deploys all services that are listed in azure.yaml', + name: ['bash'], + description: 'Generate bash completion script.', }, { - name: ['--from-package'], - description: 'Deploys the packaged service located at the provided path. Supports zipped file packages (file path) or container images (image tag).', - args: [ + name: ['fig'], + description: 'Generate Fig autocomplete spec.', + options: [ { - name: 'file-path|image-tag', + name: ['--include-help-subcommands'], + description: 'Include subcommands under the help command in the Fig spec', }, ], }, { - name: ['--timeout'], - description: 'Maximum time in seconds for azd to wait for each service deployment. This stops azd from waiting but does not cancel the Azure-side deployment. (default: 1200)', - args: [ - { - name: 'timeout', - }, - ], + name: ['fish'], + description: 'Generate fish completion script.', + }, + { + name: ['powershell'], + description: 'Generate PowerShell completion script.', + }, + { + name: ['zsh'], + description: 'Generate zsh completion script.', }, ], - args: { - name: 'service', - isOptional: true, - }, }, { - name: ['down'], - description: 'Delete your project\'s Azure resources.', - options: [ + name: ['concurx'], + description: 'Concurrent execution for azd deployment', + subcommands: [ { - name: ['--force'], - description: 'Does not require confirmation before it deletes resources.', - isDangerous: true, + name: ['up'], + description: 'Runs azd up in concurrent mode', }, { - name: ['--purge'], - description: 'Does not require confirmation before it permanently deletes resources that are soft-deleted by default (for example, key vaults).', - isDangerous: true, + name: ['version'], + description: 'Prints the version of the application', }, ], - args: { - name: 'layer', - isOptional: true, - }, }, { - name: ['env'], - description: 'Manage environments (ex: default environment, environment variables).', + name: ['config'], + description: 'Manage azd configurations (ex: default Azure subscription, location).', subcommands: [ { - name: ['config'], - description: 'Manage environment configuration (ex: stored in .azure//config.json).', - subcommands: [ - { - name: ['get'], - description: 'Gets a configuration value from the environment.', - args: { - name: 'path', - }, - }, - { - name: ['set'], - description: 'Sets a configuration value in the environment.', - args: [ - { - name: 'path', - }, - { - name: 'value', - }, - ], - }, - { - name: ['unset'], - description: 'Unsets a configuration value in the environment.', - args: { - name: 'path', - }, - }, - ], - }, - { - name: ['get-value'], - description: 'Get specific environment value.', + name: ['get'], + description: 'Gets a configuration.', args: { - name: 'keyName', - generators: azdGenerators.listEnvironmentVariables, + name: 'path', + generators: azdGenerators.listConfigKeys, }, }, { - name: ['get-values'], - description: 'Get all environment values.', + name: ['list-alpha'], + description: 'Display the list of available features in alpha stage.', }, { - name: ['list', 'ls'], - description: 'List environments.', + name: ['options'], + description: 'List all available configuration settings.', }, { - name: ['new'], - description: 'Create a new environment and set it as the default.', + name: ['reset'], + description: 'Resets configuration to default.', options: [ { - name: ['--location', '-l'], - description: 'Azure location for the new environment', - args: [ - { - name: 'location', - }, - ], - }, - { - name: ['--subscription'], - description: 'ID of an Azure subscription to use for the new environment', - args: [ - { - name: 'subscription', - }, - ], + name: ['--force', '-f'], + description: 'Force reset without confirmation.', + isDangerous: true, }, ], - args: { - name: 'environment', - }, }, { - name: ['refresh'], - description: 'Refresh environment values by using information from a previous infrastructure provision.', - options: [ - { - name: ['--hint'], - description: 'Hint to help identify the environment to refresh', - args: [ - { - name: 'hint', - }, - ], - }, + name: ['set'], + description: 'Sets a configuration.', + args: [ { - name: ['--layer'], - description: 'Provisioning layer to refresh the environment from.', - args: [ - { - name: 'layer', - }, - ], + name: 'path', + generators: azdGenerators.listConfigKeys, }, - ], - args: { - name: 'environment', - }, - }, - { - name: ['remove', 'rm'], - description: 'Remove an environment.', - options: [ { - name: ['--force'], - description: 'Skips confirmation before performing removal.', - isDangerous: true, + name: 'value', }, ], - args: { - name: 'environment', - }, }, { - name: ['select'], - description: 'Set the default environment.', - args: { - name: 'environment', - isOptional: true, - generators: azdGenerators.listEnvironments, - }, + name: ['show'], + description: 'Show all the configuration values.', }, { - name: ['set'], - description: 'Set one or more environment values.', - options: [ - { - name: ['--file'], - description: 'Path to .env formatted file to load environment values from.', - args: [ - { - name: 'file', - }, - ], - }, - ], - args: [ + name: ['sub-filter'], + description: 'Manage subscription filters for tenant-scoped subscription prompts.', + subcommands: [ { - name: 'key', - isOptional: true, + name: ['remove'], + description: 'Remove a saved subscription filter for a tenant.', }, { - name: 'value', - isOptional: true, + name: ['set'], + description: 'Set a subscription filter for a tenant.', }, ], }, { - name: ['set-secret'], - description: 'Set a name as a reference to a Key Vault secret in the environment.', + name: ['unset'], + description: 'Unsets a configuration.', args: { - name: 'name', + name: 'path', + generators: azdGenerators.listConfigKeys, }, }, ], }, { - name: ['extension', 'ext'], - description: 'Manage azd extensions.', + name: ['copilot'], + description: 'Manage GitHub Copilot agent settings. (Preview)', subcommands: [ { - name: ['install'], - description: 'Installs specified extensions.', - options: [ - { - name: ['--force', '-f'], - description: 'Force installation, including downgrades and reinstalls', - isDangerous: true, - }, + name: ['consent'], + description: 'Manage tool consent.', + subcommands: [ { - name: ['--source', '-s'], - description: 'The extension source to use for installs', - args: [ + name: ['grant'], + description: 'Grant consent trust rules.', + options: [ { - name: 'source', + name: ['--action'], + description: 'Action type: \'all\' or \'readonly\'', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], }, - ], - }, - { - name: ['--version', '-v'], - description: 'The version of the extension to install', - args: [ { - name: 'version', + name: ['--global'], + description: 'Apply globally to all servers', }, - ], - }, - ], - args: { - name: 'extension-id', - generators: azdGenerators.listExtensions, - }, - }, - { - name: ['list'], - description: 'List available extensions.', - options: [ - { - name: ['--installed'], - description: 'List installed extensions', - }, - { - name: ['--source'], - description: 'Filter extensions by source', - args: [ { - name: 'source', + name: ['--operation'], + description: 'Operation type: \'tool\' or \'sampling\'', + args: [ + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, + ], }, - ], - }, - { - name: ['--tags'], - description: 'Filter extensions by tags', - isRepeatable: true, - args: [ { - name: 'tags', + name: ['--permission'], + description: 'Permission: \'allow\', \'deny\', or \'prompt\'', + args: [ + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, + ], }, - ], - }, - ], - }, - { - name: ['show'], - description: 'Show details for a specific extension.', - options: [ - { - name: ['--source', '-s'], - description: 'The extension source to use.', - args: [ { - name: 'source', + name: ['--scope'], + description: 'Rule scope: \'global\', or \'project\'', + args: [ + { + name: 'scope', + suggestions: ['global', 'project'], + }, + ], + }, + { + name: ['--server'], + description: 'Server name', + args: [ + { + name: 'server', + }, + ], + }, + { + name: ['--tool'], + description: 'Specific tool name (requires --server)', + args: [ + { + name: 'tool', + }, + ], }, ], }, - ], - args: { - name: 'extension-id', - generators: azdGenerators.listExtensions, - }, - }, - { - name: ['source'], - description: 'View and manage extension sources', - subcommands: [ { - name: ['add'], - description: 'Add an extension source with the specified name', + name: ['list'], + description: 'List consent rules.', options: [ { - name: ['--location', '-l'], - description: 'The location of the extension source', + name: ['--action'], + description: 'Action type to filter by (all, readonly)', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], + }, + { + name: ['--operation'], + description: 'Operation to filter by (tool, sampling)', args: [ { - name: 'location', + name: 'operation', + suggestions: ['tool', 'sampling'], }, ], }, { - name: ['--name', '-n'], - description: 'The name of the extension source', + name: ['--permission'], + description: 'Permission to filter by (allow, deny, prompt)', args: [ { - name: 'name', + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], }, ], }, { - name: ['--type', '-t'], - description: 'The type of the extension source. Supported types are \'file\' and \'url\'', + name: ['--scope'], + description: 'Consent scope to filter by (global, project). If not specified, lists rules from all scopes.', args: [ { - name: 'type', + name: 'scope', + suggestions: ['global', 'project'], }, ], }, - ], - }, - { - name: ['list'], - description: 'List extension sources', - }, - { - name: ['remove'], - description: 'Remove an extension source with the specified name', - args: { - name: 'name', - }, - }, - { - name: ['validate'], - description: 'Validate an extension source\'s registry.json file.', - options: [ { - name: ['--strict'], - description: 'Enable strict validation (require checksums)', + name: ['--target'], + description: 'Specific target to operate on (server/tool format)', + args: [ + { + name: 'target', + }, + ], }, ], - args: { - name: 'name-or-path-or-url', - }, - }, - ], - }, - { - name: ['uninstall'], - description: 'Uninstall specified extensions.', - options: [ - { - name: ['--all'], - description: 'Uninstall all installed extensions', - }, - ], - args: { - name: 'extension-id', - isOptional: true, - generators: azdGenerators.listInstalledExtensions, - }, - }, - { - name: ['upgrade'], - description: 'Upgrade installed extensions to the latest version.', - options: [ - { - name: ['--all'], - description: 'Upgrade all installed extensions', }, { - name: ['--source', '-s'], - description: 'The extension source to use for upgrades', - args: [ + name: ['revoke'], + description: 'Revoke consent rules.', + options: [ { - name: 'source', + name: ['--action'], + description: 'Action type to filter by (all, readonly)', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], }, - ], - }, - { - name: ['--version', '-v'], - description: 'The version of the extension to upgrade to', - args: [ { - name: 'version', + name: ['--operation'], + description: 'Operation to filter by (tool, sampling)', + args: [ + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, + ], }, - ], - }, - ], - args: { - name: 'extension-id', - isOptional: true, - generators: azdGenerators.listInstalledExtensions, - }, - }, - ], - }, - { - name: ['hooks'], - description: 'Develop, test and run hooks for a project.', - subcommands: [ - { - name: ['run'], - description: 'Runs the specified hook for the project, provisioning layers, and services', - options: [ - { - name: ['--layer'], - description: 'Only runs hooks for the specified provisioning layer.', - args: [ { - name: 'layer', + name: ['--permission'], + description: 'Permission to filter by (allow, deny, prompt)', + args: [ + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, + ], }, - ], - }, - { - name: ['--platform'], - description: 'Forces hooks to run for the specified platform.', - args: [ { - name: 'platform', + name: ['--scope'], + description: 'Consent scope to filter by (global, project). If not specified, revokes rules from all scopes.', + args: [ + { + name: 'scope', + suggestions: ['global', 'project'], + }, + ], }, - ], - }, - { - name: ['--service'], - description: 'Only runs hooks for the specified service.', - args: [ { - name: 'service', + name: ['--target'], + description: 'Specific target to operate on (server/tool format)', + args: [ + { + name: 'target', + }, + ], }, ], }, ], - args: { - name: 'name', - suggestions: [ - 'prebuild', - 'postbuild', - 'predeploy', - 'postdeploy', - 'predown', - 'postdown', - 'prepackage', - 'postpackage', - 'preprovision', - 'postprovision', - 'prepublish', - 'postpublish', - 'prerestore', - 'postrestore', - 'preup', - 'postup', - ], - }, }, ], }, { - name: ['infra'], - description: 'Manage your Infrastructure as Code (IaC).', + name: ['demo'], + description: 'This extension provides examples of the azd extension framework.', subcommands: [ { - name: ['generate', 'gen', 'synth'], - description: 'Write IaC for your project to disk, allowing you to manually manage it.', - options: [ + name: ['ai'], + description: 'Interactive AI model discovery, deployment, and quota demos.', + subcommands: [ { - name: ['--force'], - description: 'Overwrite any existing files without prompting', - isDangerous: true, + name: ['deployment'], + description: 'Select model/version/SKU/capacity and resolve a valid deployment configuration.', }, - ], - }, - ], - }, - { - name: ['init'], - description: 'Initialize a new application.', - options: [ - { - name: ['--branch', '-b'], - description: 'The template branch to initialize from. Must be used with a template argument (--template or -t).', - args: [ { - name: 'branch', + name: ['models'], + description: 'Browse available AI models interactively.', }, - ], - }, - { - name: ['--filter', '-f'], - description: 'The tag(s) used to filter template results. Supports comma-separated values.', - isRepeatable: true, - args: [ { - name: 'filter', - generators: azdGenerators.listTemplateTags, + name: ['quota'], + description: 'View usage meters and limits for a selected location.', }, ], }, { - name: ['--from-code'], - description: 'Initializes a new application from your existing code.', + name: ['colors', 'colours'], + description: 'Displays all ASCII colors with their standard and high-intensity variants.', }, { - name: ['--location', '-l'], - description: 'Azure location for the new environment', - args: [ - { - name: 'location', - }, - ], + name: ['config'], + description: 'Set up monitoring configuration for the project and services', }, { - name: ['--minimal', '-m'], - description: 'Initializes a minimal project.', + name: ['context'], + description: 'Get the context of the azd project & environment.', }, { - name: ['--subscription', '-s'], - description: 'ID of an Azure subscription to use for the new environment', - args: [ - { - name: 'subscription', - }, - ], + name: ['gh-url-parse'], + description: 'Parse a GitHub URL and extract repository information.', + }, + { + name: ['listen'], + description: 'Starts the extension and listens for events.', }, { - name: ['--template', '-t'], - description: 'Initializes a new application from a template. You can use a Full URI, /, if it\'s part of the azure-samples organization, or a local directory path (./dir, ../dir, or absolute path).', - args: [ + name: ['mcp'], + description: 'MCP server commands for demo extension', + subcommands: [ { - name: 'template', - generators: azdGenerators.listTemplatesFiltered, + name: ['start'], + description: 'Start MCP server with demo tools', }, ], }, { - name: ['--up'], - description: 'Provision and deploy to Azure after initializing the project from a template.', + name: ['prompt'], + description: 'Examples of prompting the user for input.', }, - ], - }, - { - name: ['mcp'], - description: 'Manage Model Context Protocol (MCP) server. (Alpha)', - subcommands: [ { - name: ['start'], - description: 'Starts the MCP server.', + name: ['version'], + description: 'Prints the version of the application', }, ], }, { - name: ['monitor'], - description: 'Monitor a deployed project.', + name: ['deploy'], + description: 'Deploy your project code to Azure.', options: [ { - name: ['--live'], - description: 'Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.', + name: ['--all'], + description: 'Deploys all services that are listed in azure.yaml', }, { - name: ['--logs'], - description: 'Open a browser to Application Insights Logs.', + name: ['--from-package'], + description: 'Deploys the packaged service located at the provided path. Supports zipped file packages (file path) or container images (image tag).', + args: [ + { + name: 'file-path|image-tag', + }, + ], }, { - name: ['--overview'], - description: 'Open a browser to Application Insights Overview Dashboard.', + name: ['--timeout'], + description: 'Maximum time in seconds for azd to wait for each service deployment. This stops azd from waiting but does not cancel the Azure-side deployment. (default: 1200)', + args: [ + { + name: 'timeout', + }, + ], }, ], + args: { + name: 'service', + isOptional: true, + }, }, { - name: ['package'], - description: 'Packages the project\'s code to be deployed to Azure.', + name: ['down'], + description: 'Delete your project\'s Azure resources.', options: [ { - name: ['--all'], - description: 'Packages all services that are listed in azure.yaml', + name: ['--force'], + description: 'Does not require confirmation before it deletes resources.', + isDangerous: true, }, { - name: ['--output-path'], - description: 'File or folder path where the generated packages will be saved.', - args: [ - { - name: 'output-path', - }, - ], + name: ['--purge'], + description: 'Does not require confirmation before it permanently deletes resources that are soft-deleted by default (for example, key vaults).', + isDangerous: true, }, ], args: { - name: 'service', + name: 'layer', isOptional: true, }, }, { - name: ['pipeline'], - description: 'Manage and configure your deployment pipelines.', + name: ['env'], + description: 'Manage environments (ex: default environment, environment variables).', subcommands: [ { name: ['config'], - description: 'Configure your deployment pipeline to connect securely to Azure. (Beta)', - options: [ + description: 'Manage environment configuration (ex: stored in .azure//config.json).', + subcommands: [ { - name: ['--applicationServiceManagementReference', '-m'], - description: 'Service Management Reference. References application or service contact information from a Service or Asset Management database. This value must be a Universally Unique Identifier (UUID). You can set this value globally by running azd config set pipeline.config.applicationServiceManagementReference .', - args: [ - { - name: 'applicationServiceManagementReference', - }, - ], + name: ['get'], + description: 'Gets a configuration value from the environment.', + args: { + name: 'path', + }, }, { - name: ['--auth-type'], - description: 'The authentication type used between the pipeline provider and Azure for deployment (Only valid for GitHub provider). Valid values: federated, client-credentials.', + name: ['set'], + description: 'Sets a configuration value in the environment.', args: [ { - name: 'auth-type', - suggestions: ['federated', 'client-credentials'], + name: 'path', }, - ], - }, - { - name: ['--principal-id'], - description: 'The client id of the service principal to use to grant access to Azure resources as part of the pipeline.', - args: [ { - name: 'principal-id', + name: 'value', }, ], }, { - name: ['--principal-name'], - description: 'The name of the service principal to use to grant access to Azure resources as part of the pipeline.', + name: ['unset'], + description: 'Unsets a configuration value in the environment.', + args: { + name: 'path', + }, + }, + ], + }, + { + name: ['get-value'], + description: 'Get specific environment value.', + args: { + name: 'keyName', + generators: azdGenerators.listEnvironmentVariables, + }, + }, + { + name: ['get-values'], + description: 'Get all environment values.', + }, + { + name: ['list', 'ls'], + description: 'List environments.', + }, + { + name: ['new'], + description: 'Create a new environment and set it as the default.', + options: [ + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', args: [ { - name: 'principal-name', + name: 'location', }, ], }, { - name: ['--principal-role'], - description: 'The roles to assign to the service principal. By default the service principal will be granted the Contributor and User Access Administrator roles.', - isRepeatable: true, + name: ['--subscription'], + description: 'ID of an Azure subscription to use for the new environment', args: [ { - name: 'principal-role', + name: 'subscription', }, ], }, + ], + args: { + name: 'environment', + }, + }, + { + name: ['refresh'], + description: 'Refresh environment values by using information from a previous infrastructure provision.', + options: [ { - name: ['--provider'], - description: 'The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).', + name: ['--hint'], + description: 'Hint to help identify the environment to refresh', args: [ { - name: 'provider', - suggestions: ['github', 'azdo'], + name: 'hint', }, ], }, { - name: ['--remote-name'], - description: 'The name of the git remote to configure the pipeline to run on.', + name: ['--layer'], + description: 'Provisioning layer to refresh the environment from.', args: [ { - name: 'remote-name', + name: 'layer', }, ], }, ], + args: { + name: 'environment', + }, }, - ], - }, - { - name: ['provision'], - description: 'Provision Azure resources for your project.', - options: [ { - name: ['--location', '-l'], - description: 'Azure location for the new environment', - args: [ + name: ['remove', 'rm'], + description: 'Remove an environment.', + options: [ { - name: 'location', + name: ['--force'], + description: 'Skips confirmation before performing removal.', + isDangerous: true, }, ], + args: { + name: 'environment', + }, }, { - name: ['--no-state'], - description: '(Bicep only) Forces a fresh deployment based on current Bicep template files, ignoring any stored deployment state.', - }, - { - name: ['--preview'], - description: 'Preview changes to Azure resources.', + name: ['select'], + description: 'Set the default environment.', + args: { + name: 'environment', + isOptional: true, + generators: azdGenerators.listEnvironments, + }, }, { - name: ['--subscription'], - description: 'ID of an Azure subscription to use for the new environment', - args: [ + name: ['set'], + description: 'Set one or more environment values.', + options: [ { - name: 'subscription', + name: ['--file'], + description: 'Path to .env formatted file to load environment values from.', + args: [ + { + name: 'file', + }, + ], }, ], - }, - ], - args: { - name: 'layer', - isOptional: true, - }, - }, - { - name: ['publish'], - description: 'Publish a service to a container registry.', - options: [ - { - name: ['--all'], - description: 'Publishes all services that are listed in azure.yaml', - }, - { - name: ['--from-package'], - description: 'Publishes the service from a container image (image tag).', args: [ { - name: 'image-tag', + name: 'key', + isOptional: true, + }, + { + name: 'value', + isOptional: true, }, ], }, { - name: ['--to'], - description: 'The target container image in the form \'[registry/]repository[:tag]\' to publish to.', + name: ['set-secret'], + description: 'Set a name as a reference to a Key Vault secret in the environment.', + args: { + name: 'name', + }, + }, + ], + }, + { + name: ['exec'], + description: 'Execute commands and scripts with azd environment context.', + options: [ + { + name: ['--interactive', '-i'], + description: 'Run in interactive mode (connect stdin)', + }, + { + name: ['--shell', '-s'], + description: 'Shell to use (bash, sh, zsh, pwsh, powershell, cmd). Auto-detected if not specified.', args: [ { - name: 'image-tag', + name: 'shell', }, ], }, ], - args: { - name: 'service', - isOptional: true, - }, - }, - { - name: ['restore'], - description: 'Restores the project\'s dependencies.', - options: [ + args: [ { - name: ['--all'], - description: 'Restores all services that are listed in azure.yaml', + name: 'command', + isOptional: true, }, - ], - args: { - name: 'service', - isOptional: true, - }, - }, - { - name: ['show'], - description: 'Display information about your project and its resources.', - options: [ { - name: ['--show-secrets'], - description: 'Unmask secrets in output.', - isDangerous: true, + name: 'args...', + isOptional: true, + }, + { + name: 'script-args...', }, ], - args: { - name: 'resource-name|resource-id', - isOptional: true, - }, }, { - name: ['template'], - description: 'Find and view template details.', + name: ['extension', 'ext'], + description: 'Manage azd extensions.', subcommands: [ { - name: ['list', 'ls'], - description: 'Show list of sample azd templates. (Beta)', + name: ['install'], + description: 'Installs specified extensions.', options: [ { - name: ['--filter', '-f'], - description: 'The tag(s) used to filter template results. Supports comma-separated values.', - isRepeatable: true, + name: ['--force', '-f'], + description: 'Force installation, including downgrades and reinstalls', + isDangerous: true, + }, + { + name: ['--source', '-s'], + description: 'The extension source to use for installs', args: [ { - name: 'filter', - generators: azdGenerators.listTemplateTags, + name: 'source', }, ], }, { - name: ['--source', '-s'], - description: 'Filters templates by source.', + name: ['--version', '-v'], + description: 'The version of the extension to install', + args: [ + { + name: 'version', + }, + ], + }, + ], + args: { + name: 'extension-id|extension-bundle.zip', + generators: [azdGenerators.listExtensions, filepaths({ extensions: ['zip'] })], + }, + }, + { + name: ['list'], + description: 'List available extensions.', + options: [ + { + name: ['--installed'], + description: 'List installed extensions', + }, + { + name: ['--source'], + description: 'Filter extensions by source', args: [ { name: 'source', }, ], }, + { + name: ['--tags'], + description: 'Filter extensions by tags', + isRepeatable: true, + args: [ + { + name: 'tags', + }, + ], + }, ], }, { name: ['show'], - description: 'Show details for a given template. (Beta)', + description: 'Show details for a specific extension.', + options: [ + { + name: ['--source', '-s'], + description: 'The extension source to use.', + args: [ + { + name: 'source', + }, + ], + }, + ], args: { - name: 'template', - generators: azdGenerators.listTemplates, + name: 'extension-id', + generators: azdGenerators.listExtensions, }, }, { name: ['source'], - description: 'View and manage template sources. (Beta)', + description: 'View and manage extension sources', subcommands: [ { name: ['add'], - description: 'Adds an azd template source with the specified key. (Beta)', + description: 'Add an extension source with the specified name', options: [ { name: ['--location', '-l'], - description: 'Location of the template source. Required when using type flag.', + description: 'The location of the extension source', args: [ { name: 'location', @@ -2963,7 +5764,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--name', '-n'], - description: 'Display name of the template source.', + description: 'The name of the extension source', args: [ { name: 'name', @@ -2972,7 +5773,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--type', '-t'], - description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', + description: 'The type of the extension source. Supported types are \'file\' and \'url\'', args: [ { name: 'type', @@ -2980,1043 +5781,971 @@ const completionSpec: Fig.Spec = { ], }, ], - args: { - name: 'key', - }, - }, - { - name: ['list', 'ls'], - description: 'Lists the configured azd template sources. (Beta)', - }, - { - name: ['remove'], - description: 'Removes the specified azd template source (Beta)', - args: { - name: 'key', - }, - }, - ], - }, - ], - }, - { - name: ['tool'], - description: 'Manage Azure development tools.', - subcommands: [ - { - name: ['check'], - description: 'Check for tool updates.', - }, - { - name: ['install'], - description: 'Install specified tools.', - options: [ - { - name: ['--all'], - description: 'Install all recommended tools', - }, - { - name: ['--dry-run'], - description: 'Preview what would be installed without making changes', - }, - ], - args: { - name: 'tool-name...', - isOptional: true, - }, - }, - { - name: ['list'], - description: 'List all tools with status.', - }, - { - name: ['show'], - description: 'Show details for a specific tool.', - args: { - name: 'tool-name', - }, - }, - { - name: ['upgrade'], - description: 'Upgrade installed tools.', - options: [ - { - name: ['--dry-run'], - description: 'Preview what would be upgraded without making changes', - }, - ], - args: { - name: 'tool-name...', - isOptional: true, - }, - }, - ], - }, - { - name: ['up'], - description: 'Provision and deploy your project to Azure with a single command.', - options: [ - { - name: ['--location', '-l'], - description: 'Azure location for the new environment', - args: [ - { - name: 'location', - }, - ], - }, - { - name: ['--subscription'], - description: 'ID of an Azure subscription to use for the new environment', - args: [ - { - name: 'subscription', - }, - ], - }, - ], - }, - { - name: ['update'], - description: 'Updates azd to the latest version.', - options: [ - { - name: ['--channel'], - description: 'Update channel: stable or daily.', - args: [ - { - name: 'channel', }, - ], - }, - { - name: ['--check-interval-hours'], - description: 'Override the update check interval in hours.', - args: [ { - name: 'check-interval-hours', + name: ['list'], + description: 'List extension sources', }, - ], - }, - ], - }, - { - name: ['version'], - description: 'Print the version number of Azure Developer CLI.', - }, - { - name: ['x'], - description: 'This extension provides a set of tools for azd extension developers to test and debug their extensions.', - subcommands: [ - { - name: ['build'], - description: 'Build the azd extension project', - options: [ { - name: ['--all'], - description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', + name: ['remove'], + description: 'Remove an extension source with the specified name', + args: { + name: 'name', + }, }, { - name: ['--output', '-o'], - description: 'Path to the output directory. Defaults to ./bin folder.', - args: [ + name: ['validate'], + description: 'Validate an extension source\'s registry.json file.', + options: [ { - name: 'output', + name: ['--strict'], + description: 'Enable strict validation (require checksums)', }, ], + args: { + name: 'name-or-path-or-url', + }, }, + ], + }, + { + name: ['uninstall'], + description: 'Uninstall specified extensions.', + options: [ { - name: ['--skip-install'], - description: 'When set skips reinstalling extension after successful build.', + name: ['--all'], + description: 'Uninstall all installed extensions', }, ], + args: { + name: 'extension-id', + isOptional: true, + generators: azdGenerators.listInstalledExtensions, + }, }, { - name: ['init'], - description: 'Initialize a new azd extension project', + name: ['upgrade'], + description: 'Upgrade installed extensions to the latest version.', options: [ { - name: ['--capabilities'], - description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider).', - isRepeatable: true, + name: ['--all'], + description: 'Upgrade all installed extensions', + }, + { + name: ['--no-dependency-upgrades'], + description: 'Do not upgrade dependencies when upgrading an extension that has dependencies', + }, + { + name: ['--source', '-s'], + description: 'The extension source to use for upgrades', args: [ { - name: 'capabilities', + name: 'source', }, ], }, { - name: ['--id'], - description: 'The extension identifier (e.g., company.extension).', + name: ['--version', '-v'], + description: 'The version of the extension to upgrade to', args: [ { - name: 'id', + name: 'version', }, ], }, + ], + args: { + name: 'extension-id', + isOptional: true, + generators: azdGenerators.listInstalledExtensions, + }, + }, + ], + }, + { + name: ['hooks'], + description: 'Develop, test and run hooks for a project.', + subcommands: [ + { + name: ['run'], + description: 'Runs the specified hook for the project, provisioning layers, and services', + options: [ { - name: ['--language'], - description: 'The programming language for the extension (go, dotnet, javascript, python).', + name: ['--layer'], + description: 'Only runs hooks for the specified provisioning layer.', args: [ { - name: 'language', + name: 'layer', }, ], }, { - name: ['--name'], - description: 'The display name for the extension.', + name: ['--platform'], + description: 'Forces hooks to run for the specified platform.', args: [ { - name: 'name', + name: 'platform', }, ], }, { - name: ['--namespace'], - description: 'The namespace for the extension commands.', + name: ['--service'], + description: 'Only runs hooks for the specified service.', args: [ { - name: 'namespace', + name: 'service', }, ], }, - { - name: ['--registry', '-r'], - description: 'When set will create a local extension source registry.', - }, ], + args: { + name: 'name', + suggestions: [ + 'prebuild', + 'postbuild', + 'predeploy', + 'postdeploy', + 'predown', + 'postdown', + 'prepackage', + 'postpackage', + 'preprovision', + 'postprovision', + 'prepublish', + 'postpublish', + 'prerestore', + 'postrestore', + 'preup', + 'postup', + ], + }, }, + ], + }, + { + name: ['infra'], + description: 'Manage your Infrastructure as Code (IaC).', + subcommands: [ { - name: ['pack'], - description: 'Build and pack extension artifacts', + name: ['generate', 'gen', 'synth'], + description: 'Write IaC for your project to disk, allowing you to manually manage it.', options: [ { - name: ['--input', '-i'], - description: 'Path to the input directory.', - args: [ - { - name: 'input', - }, - ], + name: ['--force'], + description: 'Overwrite any existing files without prompting', + isDangerous: true, }, + ], + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new application.', + options: [ + { + name: ['--branch', '-b'], + description: 'The template branch to initialize from. Must be used with a template argument (--template or -t).', + args: [ { - name: ['--output', '-o'], - description: 'Path to the artifacts output directory. If not provided, will use local registry artifacts path.', - args: [ - { - name: 'output', - }, - ], + name: 'branch', }, + ], + }, + { + name: ['--filter', '-f'], + description: 'The tag(s) used to filter template results. Supports comma-separated values.', + isRepeatable: true, + args: [ { - name: ['--rebuild'], - description: 'Rebuild the extension before packaging.', + name: 'filter', + generators: azdGenerators.listTemplateTags, }, ], }, { - name: ['publish'], - description: 'Publish the extension to the extension source', - options: [ + name: ['--from-code'], + description: 'Initializes a new application from your existing code.', + }, + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ { - name: ['--artifacts'], - description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', - isRepeatable: true, - args: [ - { - name: 'artifacts', - }, - ], + name: 'location', }, + ], + }, + { + name: ['--minimal', '-m'], + description: 'Initializes a minimal project.', + }, + { + name: ['--subscription', '-s'], + description: 'ID of an Azure subscription to use for the new environment', + args: [ { - name: ['--registry', '-r'], - description: 'Path to the extension source registry', - args: [ - { - name: 'registry', - }, - ], + name: 'subscription', }, + ], + }, + { + name: ['--template', '-t'], + description: 'Initializes a new application from a template. You can use a Full URI, /, if it\'s part of the azure-samples organization, or a local directory path (./dir, ../dir, or absolute path).', + args: [ { - name: ['--repo'], - description: 'GitHub repository to create the release in (e.g. owner/repo)', - args: [ - { - name: 'repo', - }, - ], + name: 'template', + generators: azdGenerators.listTemplatesFiltered, }, + ], + }, + { + name: ['--up'], + description: 'Provision and deploy to Azure after initializing the project from a template.', + }, + ], + }, + { + name: ['mcp'], + description: 'Manage Model Context Protocol (MCP) server. (Alpha)', + subcommands: [ + { + name: ['start'], + description: 'Starts the MCP server.', + }, + ], + }, + { + name: ['monitor'], + description: 'Monitor a deployed project.', + options: [ + { + name: ['--live'], + description: 'Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.', + }, + { + name: ['--logs'], + description: 'Open a browser to Application Insights Logs.', + }, + { + name: ['--overview'], + description: 'Open a browser to Application Insights Overview Dashboard.', + }, + ], + }, + { + name: ['package'], + description: 'Packages the project\'s code to be deployed to Azure.', + options: [ + { + name: ['--all'], + description: 'Packages all services that are listed in azure.yaml', + }, + { + name: ['--output-path'], + description: 'File or folder path where the generated packages will be saved.', + args: [ { - name: ['--version', '-v'], - description: 'Version of the release', - args: [ - { - name: 'version', - }, - ], + name: 'output-path', }, ], }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['pipeline'], + description: 'Manage and configure your deployment pipelines.', + subcommands: [ { - name: ['release'], - description: 'Create a new extension release from the packaged artifacts', + name: ['config'], + description: 'Configure your deployment pipeline to connect securely to Azure. (Beta)', options: [ { - name: ['--artifacts'], - description: 'Path to artifacts to upload to the release (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', - isRepeatable: true, + name: ['--applicationServiceManagementReference', '-m'], + description: 'Service Management Reference. References application or service contact information from a Service or Asset Management database. This value must be a Universally Unique Identifier (UUID). You can set this value globally by running azd config set pipeline.config.applicationServiceManagementReference .', args: [ { - name: 'artifacts', + name: 'applicationServiceManagementReference', }, ], }, { - name: ['--confirm'], - description: 'Skip confirmation prompt', - }, - { - name: ['--draft', '-d'], - description: 'Create a draft release', - }, - { - name: ['--notes', '-n'], - description: 'Release notes', + name: ['--auth-type'], + description: 'The authentication type used between the pipeline provider and Azure for deployment (Only valid for GitHub provider). Valid values: federated, client-credentials.', args: [ { - name: 'notes', + name: 'auth-type', + suggestions: ['federated', 'client-credentials'], }, ], }, { - name: ['--notes-file', '-F'], - description: 'Read release notes from file (use "-" to read from standard input)', + name: ['--principal-id'], + description: 'The client id of the service principal to use to grant access to Azure resources as part of the pipeline.', args: [ { - name: 'notes-file', + name: 'principal-id', }, ], }, { - name: ['--prerelease'], - description: 'Create a pre-release version', + name: ['--principal-name'], + description: 'The name of the service principal to use to grant access to Azure resources as part of the pipeline.', + args: [ + { + name: 'principal-name', + }, + ], }, { - name: ['--repo', '-r'], - description: 'GitHub repository to create the release in (e.g. owner/repo)', + name: ['--principal-role'], + description: 'The roles to assign to the service principal. By default the service principal will be granted the Contributor and User Access Administrator roles.', + isRepeatable: true, args: [ { - name: 'repo', + name: 'principal-role', }, ], }, { - name: ['--title', '-t'], - description: 'Title of the release', + name: ['--provider'], + description: 'The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).', args: [ { - name: 'title', + name: 'provider', + suggestions: ['github', 'azdo'], }, ], }, { - name: ['--version', '-v'], - description: 'Version of the release', + name: ['--remote-name'], + description: 'The name of the git remote to configure the pipeline to run on.', args: [ { - name: 'version', + name: 'remote-name', }, ], }, ], }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - { - name: ['watch'], - description: 'Watches the azd extension project for file changes and rebuilds it.', - }, ], }, { - name: ['help'], - description: 'Help about any command', - subcommands: [ - { - name: ['add'], - description: 'Add a component to your project.', - }, + name: ['provision'], + description: 'Provision Azure resources for your project.', + options: [ { - name: ['ai'], - description: 'Commands for the ai extension namespace.', - subcommands: [ - { - name: ['agent'], - description: 'Ship agents with Microsoft Foundry from your terminal. (Preview)', - subcommands: [ - { - name: ['files'], - description: 'Manage files in a hosted agent session.', - subcommands: [ - { - name: ['delete', 'remove', 'rm'], - description: 'Delete a file or directory from a hosted agent session.', - }, - { - name: ['download'], - description: 'Download a file from a hosted agent session.', - }, - { - name: ['list', 'ls'], - description: 'List files in a hosted agent session.', - }, - { - name: ['mkdir'], - description: 'Create a directory in a hosted agent session.', - }, - { - name: ['stat'], - description: 'Get file or directory metadata in a hosted agent session.', - }, - { - name: ['upload'], - description: 'Upload a file to a hosted agent session.', - }, - ], - }, - { - name: ['init'], - description: 'Initialize a new AI agent project. (Preview)', - }, - { - name: ['invoke'], - description: 'Send a message to your agent.', - }, - { - name: ['monitor'], - description: 'Monitor logs from a hosted agent.', - }, - { - name: ['run'], - description: 'Run your agent locally for development.', - }, - { - name: ['sessions'], - description: 'Manage sessions for a hosted agent endpoint.', - subcommands: [ - { - name: ['create'], - description: 'Create a new session for a hosted agent.', - }, - { - name: ['delete'], - description: 'Delete a session.', - }, - { - name: ['list'], - description: 'List sessions for a hosted agent.', - }, - { - name: ['show'], - description: 'Show details of a session.', - }, - ], - }, - { - name: ['show'], - description: 'Show the status of a hosted agent.', - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, - { - name: ['finetuning'], - description: 'Extension for Foundry Fine Tuning. (Preview)', - subcommands: [ - { - name: ['init'], - description: 'Initialize a new AI Fine-tuning project. (Preview)', - }, - { - name: ['jobs'], - description: 'Manage fine-tuning jobs', - subcommands: [ - { - name: ['cancel'], - description: 'Cancels a running or queued fine-tuning job.', - }, - { - name: ['deploy'], - description: 'Deploy a fine-tuned model to Azure Cognitive Services', - }, - { - name: ['list'], - description: 'List fine-tuning jobs.', - }, - { - name: ['pause'], - description: 'Pauses a running fine-tuning job.', - }, - { - name: ['resume'], - description: 'Resumes a paused fine-tuning job.', - }, - { - name: ['show'], - description: 'Shows detailed information about a specific job.', - }, - { - name: ['submit'], - description: 'Submit fine-tuning job.', - }, - ], - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], - }, + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ { - name: ['models'], - description: 'Extension for managing custom models in Azure AI Foundry. (Preview)', - subcommands: [ - { - name: ['custom'], - description: 'Manage custom models in Azure AI Foundry', - subcommands: [ - { - name: ['create'], - description: 'Upload and register a custom model', - }, - { - name: ['delete'], - description: 'Delete a custom model', - }, - { - name: ['list'], - description: 'List all custom models', - }, - { - name: ['show'], - description: 'Show details of a custom model', - }, - ], - }, - { - name: ['init'], - description: 'Initialize a new AI models project. (Preview)', - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, - ], + name: 'location', }, ], }, { - name: ['appservice'], - description: 'Extension for managing Azure App Service resources.', - subcommands: [ - { - name: ['swap'], - description: 'Swap deployment slots for an App Service.', - }, - { - name: ['version'], - description: 'Display the version of the extension.', - }, - ], + name: ['--no-state'], + description: '(Bicep only) Forces a fresh deployment based on current Bicep template files, ignoring any stored deployment state.', }, { - name: ['auth'], - description: 'Authenticate with Azure.', - subcommands: [ - { - name: ['login'], - description: 'Log in to Azure.', - }, - { - name: ['logout'], - description: 'Log out of Azure.', - }, - { - name: ['status'], - description: 'Show the current authentication status.', - }, - ], + name: ['--preview'], + description: 'Preview changes to Azure resources.', }, { - name: ['coding-agent'], - description: 'This extension configures GitHub Copilot Coding Agent access to Azure', - subcommands: [ - { - name: ['config'], - description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', - }, + name: ['--subscription'], + description: 'ID of an Azure subscription to use for the new environment', + args: [ { - name: ['version'], - description: 'Prints the version of the application', + name: 'subscription', }, ], }, + ], + args: { + name: 'layer', + isOptional: true, + }, + }, + { + name: ['publish'], + description: 'Publish a service to a container registry.', + options: [ { - name: ['completion'], - description: 'Generate shell completion scripts.', - subcommands: [ - { - name: ['bash'], - description: 'Generate bash completion script.', - }, - { - name: ['fig'], - description: 'Generate Fig autocomplete spec.', - }, - { - name: ['fish'], - description: 'Generate fish completion script.', - }, - { - name: ['powershell'], - description: 'Generate PowerShell completion script.', - }, - { - name: ['zsh'], - description: 'Generate zsh completion script.', - }, - ], + name: ['--all'], + description: 'Publishes all services that are listed in azure.yaml', }, { - name: ['concurx'], - description: 'Concurrent execution for azd deployment', - subcommands: [ - { - name: ['up'], - description: 'Runs azd up in concurrent mode', - }, + name: ['--from-package'], + description: 'Publishes the service from a container image (image tag).', + args: [ { - name: ['version'], - description: 'Prints the version of the application', + name: 'image-tag', }, ], }, { - name: ['config'], - description: 'Manage azd configurations (ex: default Azure subscription, location).', - subcommands: [ - { - name: ['get'], - description: 'Gets a configuration.', - }, - { - name: ['list-alpha'], - description: 'Display the list of available features in alpha stage.', - }, - { - name: ['options'], - description: 'List all available configuration settings.', - }, - { - name: ['reset'], - description: 'Resets configuration to default.', - }, - { - name: ['set'], - description: 'Sets a configuration.', - }, - { - name: ['show'], - description: 'Show all the configuration values.', - }, + name: ['--to'], + description: 'The target container image in the form \'[registry/]repository[:tag]\' to publish to.', + args: [ { - name: ['unset'], - description: 'Unsets a configuration.', + name: 'image-tag', }, ], }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['restore'], + description: 'Restores the project\'s dependencies.', + options: [ + { + name: ['--all'], + description: 'Restores all services that are listed in azure.yaml', + }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['show'], + description: 'Display information about your project and its resources.', + options: [ { - name: ['copilot'], - description: 'Manage GitHub Copilot agent settings. (Preview)', - subcommands: [ + name: ['--show-secrets'], + description: 'Unmask secrets in output.', + isDangerous: true, + }, + ], + args: { + name: 'resource-name|resource-id', + isOptional: true, + }, + }, + { + name: ['template'], + description: 'Find and view template details.', + subcommands: [ + { + name: ['list', 'ls'], + description: 'Show list of sample azd templates. (Beta)', + options: [ { - name: ['consent'], - description: 'Manage tool consent.', - subcommands: [ - { - name: ['grant'], - description: 'Grant consent trust rules.', - }, + name: ['--filter', '-f'], + description: 'The tag(s) used to filter template results. Supports comma-separated values.', + isRepeatable: true, + args: [ { - name: ['list'], - description: 'List consent rules.', + name: 'filter', + generators: azdGenerators.listTemplateTags, }, + ], + }, + { + name: ['--source', '-s'], + description: 'Filters templates by source.', + args: [ { - name: ['revoke'], - description: 'Revoke consent rules.', + name: 'source', }, ], }, ], }, { - name: ['demo'], - description: 'This extension provides examples of the azd extension framework.', + name: ['show'], + description: 'Show details for a given template. (Beta)', + args: { + name: 'template', + generators: azdGenerators.listTemplates, + }, + }, + { + name: ['source'], + description: 'View and manage template sources. (Beta)', subcommands: [ { - name: ['ai'], - description: 'Interactive AI model discovery, deployment, and quota demos.', - subcommands: [ + name: ['add'], + description: 'Adds an azd template source with the specified key. (Beta)', + options: [ { - name: ['deployment'], - description: 'Select model/version/SKU/capacity and resolve a valid deployment configuration.', + name: ['--location', '-l'], + description: 'Location of the template source. Required when using type flag.', + args: [ + { + name: 'location', + }, + ], }, { - name: ['models'], - description: 'Browse available AI models interactively.', + name: ['--name', '-n'], + description: 'Display name of the template source.', + args: [ + { + name: 'name', + }, + ], }, { - name: ['quota'], - description: 'View usage meters and limits for a selected location.', + name: ['--type', '-t'], + description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', + args: [ + { + name: 'type', + }, + ], }, ], + args: { + name: 'key', + }, }, { - name: ['colors', 'colours'], - description: 'Displays all ASCII colors with their standard and high-intensity variants.', - }, - { - name: ['config'], - description: 'Set up monitoring configuration for the project and services', + name: ['list', 'ls'], + description: 'Lists the configured azd template sources. (Beta)', }, { - name: ['context'], - description: 'Get the context of the azd project & environment.', + name: ['remove'], + description: 'Removes the specified azd template source (Beta)', + args: { + name: 'key', + }, }, + ], + }, + ], + }, + { + name: ['tool'], + description: 'Manage Azure development tools.', + subcommands: [ + { + name: ['check'], + description: 'Check for tool updates.', + }, + { + name: ['install'], + description: 'Install specified tools.', + options: [ { - name: ['gh-url-parse'], - description: 'Parse a GitHub URL and extract repository information.', + name: ['--all'], + description: 'Install all recommended tools', }, { - name: ['listen'], - description: 'Starts the extension and listens for events.', + name: ['--dry-run'], + description: 'Preview what would be installed without making changes', }, { - name: ['mcp'], - description: 'MCP server commands for demo extension', - subcommands: [ + name: ['--host'], + description: 'Install the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', + isRepeatable: true, + args: [ { - name: ['start'], - description: 'Start MCP server with demo tools', + name: 'host', }, ], }, - { - name: ['prompt'], - description: 'Examples of prompting the user for input.', - }, - { - name: ['version'], - description: 'Prints the version of the application', - }, ], + args: { + name: 'tool-name...', + isOptional: true, + }, }, { - name: ['deploy'], - description: 'Deploy your project code to Azure.', + name: ['list'], + description: 'List all tools with status.', }, { - name: ['down'], - description: 'Delete your project\'s Azure resources.', + name: ['show'], + description: 'Show details for a specific tool.', + args: { + name: 'tool-name', + }, }, { - name: ['env'], - description: 'Manage environments (ex: default environment, environment variables).', - subcommands: [ + name: ['uninstall'], + description: 'Uninstall installed tools.', + options: [ { - name: ['config'], - description: 'Manage environment configuration (ex: stored in .azure//config.json).', - subcommands: [ - { - name: ['get'], - description: 'Gets a configuration value from the environment.', - }, - { - name: ['set'], - description: 'Sets a configuration value in the environment.', - }, + name: ['--all'], + description: 'Uninstall all installed tools', + }, + { + name: ['--dry-run'], + description: 'Preview what would be uninstalled without making changes', + }, + { + name: ['--host'], + description: 'Uninstall the skill from the specified agent host(s): copilot, claude. Use --host all (or omit --host) to remove the skill from every host it is installed through (skill tools only)', + isRepeatable: true, + args: [ { - name: ['unset'], - description: 'Unsets a configuration value in the environment.', + name: 'host', }, ], }, + ], + args: { + name: 'tool-name...', + isOptional: true, + }, + }, + { + name: ['upgrade'], + description: 'Upgrade installed tools.', + options: [ { - name: ['get-value'], - description: 'Get specific environment value.', + name: ['--dry-run'], + description: 'Preview what would be upgraded without making changes', }, { - name: ['get-values'], - description: 'Get all environment values.', + name: ['--host'], + description: 'Upgrade the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', + isRepeatable: true, + args: [ + { + name: 'host', + }, + ], }, + ], + args: { + name: 'tool-name...', + isOptional: true, + }, + }, + ], + }, + { + name: ['up'], + description: 'Provision and deploy your project to Azure with a single command.', + options: [ + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ { - name: ['list', 'ls'], - description: 'List environments.', + name: 'location', }, + ], + }, + { + name: ['--subscription'], + description: 'ID of an Azure subscription to use for the new environment', + args: [ { - name: ['new'], - description: 'Create a new environment and set it as the default.', + name: 'subscription', }, + ], + }, + ], + }, + { + name: ['update'], + description: 'Updates azd to the latest version.', + options: [ + { + name: ['--channel'], + description: 'Update channel: stable or daily.', + args: [ { - name: ['refresh'], - description: 'Refresh environment values by using information from a previous infrastructure provision.', + name: 'channel', }, + ], + }, + { + name: ['--check-interval-hours'], + description: 'Override the update check interval in hours.', + args: [ { - name: ['remove', 'rm'], - description: 'Remove an environment.', + name: 'check-interval-hours', }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Print the version number of Azure Developer CLI.', + }, + { + name: ['x'], + description: 'This extension provides a set of tools for azd extension developers to test and debug their extensions.', + subcommands: [ + { + name: ['build'], + description: 'Build the azd extension project', + options: [ { - name: ['select'], - description: 'Set the default environment.', + name: ['--all'], + description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', }, { - name: ['set'], - description: 'Set one or more environment values.', + name: ['--output', '-o'], + description: 'Path to the output directory.', + args: [ + { + name: 'output', + }, + ], }, { - name: ['set-secret'], - description: 'Set a name as a reference to a Key Vault secret in the environment.', + name: ['--skip-install'], + description: 'When set skips reinstalling extension after successful build.', }, ], }, { - name: ['extension', 'ext'], - description: 'Manage azd extensions.', - subcommands: [ - { - name: ['install'], - description: 'Installs specified extensions.', - }, + name: ['init'], + description: 'Initialize a new azd extension project', + options: [ { - name: ['list'], - description: 'List available extensions.', + name: ['--capabilities'], + description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider,framework-service-provider,metadata,provisioning-provider).', + isRepeatable: true, + args: [ + { + name: 'capabilities', + }, + ], }, { - name: ['show'], - description: 'Show details for a specific extension.', + name: ['--codeowners'], + description: 'GitHub handles or teams for the generated CODEOWNERS entry when --internal is set.', + isRepeatable: true, + args: [ + { + name: 'codeowners', + }, + ], }, { - name: ['source'], - description: 'View and manage extension sources', - subcommands: [ + name: ['--id'], + description: 'The extension identifier (e.g., company.extension).', + args: [ { - name: ['add'], - description: 'Add an extension source with the specified name', + name: 'id', }, + ], + }, + { + name: ['--internal'], + description: 'Scaffold Azure/azure-dev first-party extension files. Currently supports Go extensions only.', + }, + { + name: ['--language'], + description: 'The programming language for the extension (go, dotnet, javascript, python).', + args: [ { - name: ['list'], - description: 'List extension sources', + name: 'language', }, + ], + }, + { + name: ['--name'], + description: 'The display name for the extension.', + args: [ { - name: ['remove'], - description: 'Remove an extension source with the specified name', + name: 'name', }, + ], + }, + { + name: ['--namespace'], + description: 'The namespace for the extension commands.', + args: [ { - name: ['validate'], - description: 'Validate an extension source\'s registry.json file.', + name: 'namespace', }, ], }, { - name: ['uninstall'], - description: 'Uninstall specified extensions.', + name: ['--registry', '-r'], + description: 'When set will create a local extension source registry.', }, { - name: ['upgrade'], - description: 'Upgrade installed extensions to the latest version.', + name: ['--tags'], + description: 'Optional tags for the extension, comma-separated or repeatable (max 10 tags, 64 characters each).', + isRepeatable: true, + args: [ + { + name: 'tags', + }, + ], }, ], }, { - name: ['hooks'], - description: 'Develop, test and run hooks for a project.', - subcommands: [ + name: ['pack'], + description: 'Build and pack extension artifacts', + options: [ { - name: ['run'], - description: 'Runs the specified hook for the project, provisioning layers, and services', + name: ['--bundle'], + description: 'Produce a single self-contained bundle (.zip) containing a registry.json and the extension artifacts, installable via \'azd extension install \'.', }, - ], - }, - { - name: ['infra'], - description: 'Manage your Infrastructure as Code (IaC).', - subcommands: [ { - name: ['generate', 'gen', 'synth'], - description: 'Write IaC for your project to disk, allowing you to manually manage it.', + name: ['--input', '-i'], + description: 'Path to the input directory.', + args: [ + { + name: 'input', + }, + ], }, - ], - }, - { - name: ['init'], - description: 'Initialize a new application.', - }, - { - name: ['mcp'], - description: 'Manage Model Context Protocol (MCP) server. (Alpha)', - subcommands: [ { - name: ['start'], - description: 'Starts the MCP server.', + name: ['--output', '-o'], + description: 'Path to the artifacts output directory. If omitted, uses the local registry artifacts path.', + args: [ + { + name: 'output', + }, + ], }, - ], - }, - { - name: ['monitor'], - description: 'Monitor a deployed project.', - }, - { - name: ['package'], - description: 'Packages the project\'s code to be deployed to Azure.', - }, - { - name: ['pipeline'], - description: 'Manage and configure your deployment pipelines.', - subcommands: [ { - name: ['config'], - description: 'Configure your deployment pipeline to connect securely to Azure. (Beta)', + name: ['--rebuild'], + description: 'Rebuild the extension before packaging.', }, ], }, - { - name: ['provision'], - description: 'Provision Azure resources for your project.', - }, { name: ['publish'], - description: 'Publish a service to a container registry.', - }, - { - name: ['restore'], - description: 'Restores the project\'s dependencies.', - }, - { - name: ['show'], - description: 'Display information about your project and its resources.', - }, - { - name: ['template'], - description: 'Find and view template details.', - subcommands: [ - { - name: ['list', 'ls'], - description: 'Show list of sample azd templates. (Beta)', - }, + description: 'Publish the extension to the extension source', + options: [ { - name: ['show'], - description: 'Show details for a given template. (Beta)', + name: ['--artifacts'], + description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', + isRepeatable: true, + args: [ + { + name: 'artifacts', + }, + ], }, { - name: ['source'], - description: 'View and manage template sources. (Beta)', - subcommands: [ + name: ['--registry', '-r'], + description: 'Path to the extension source registry', + args: [ { - name: ['add'], - description: 'Adds an azd template source with the specified key. (Beta)', + name: 'registry', }, + ], + }, + { + name: ['--repo'], + description: 'GitHub repository to create the release in (e.g. owner/repo)', + args: [ { - name: ['list', 'ls'], - description: 'Lists the configured azd template sources. (Beta)', + name: 'repo', }, + ], + }, + { + name: ['--version', '-v'], + description: 'Version of the release', + args: [ { - name: ['remove'], - description: 'Removes the specified azd template source (Beta)', + name: 'version', }, ], }, ], }, { - name: ['tool'], - description: 'Manage Azure development tools.', - subcommands: [ - { - name: ['check'], - description: 'Check for tool updates.', - }, - { - name: ['install'], - description: 'Install specified tools.', - }, - { - name: ['list'], - description: 'List all tools with status.', - }, + name: ['release'], + description: 'Create a new extension release from the packaged artifacts', + options: [ { - name: ['show'], - description: 'Show details for a specific tool.', + name: ['--artifacts'], + description: 'Path to artifacts to upload to the release (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', + isRepeatable: true, + args: [ + { + name: 'artifacts', + }, + ], }, { - name: ['upgrade'], - description: 'Upgrade installed tools.', + name: ['--confirm'], + description: 'Skip confirmation prompt', }, - ], - }, - { - name: ['up'], - description: 'Provision and deploy your project to Azure with a single command.', - }, - { - name: ['update'], - description: 'Updates azd to the latest version.', - }, - { - name: ['version'], - description: 'Print the version number of Azure Developer CLI.', - }, - { - name: ['x'], - description: 'This extension provides a set of tools for azd extension developers to test and debug their extensions.', - subcommands: [ { - name: ['build'], - description: 'Build the azd extension project', + name: ['--draft', '-d'], + description: 'Create a draft release', }, { - name: ['init'], - description: 'Initialize a new azd extension project', + name: ['--notes', '-n'], + description: 'Release notes', + args: [ + { + name: 'notes', + }, + ], }, { - name: ['pack'], - description: 'Build and pack extension artifacts', + name: ['--notes-file', '-F'], + description: 'Read release notes from file (use "-" to read from standard input)', + args: [ + { + name: 'notes-file', + }, + ], }, { - name: ['publish'], - description: 'Publish the extension to the extension source', + name: ['--prerelease'], + description: 'Create a pre-release version', }, { - name: ['release'], - description: 'Create a new extension release from the packaged artifacts', + name: ['--repo', '-r'], + description: 'GitHub repository to create the release in (e.g. owner/repo)', + args: [ + { + name: 'repo', + }, + ], }, { - name: ['version'], - description: 'Prints the version of the application', + name: ['--title', '-t'], + description: 'Title of the release', + args: [ + { + name: 'title', + }, + ], }, { - name: ['watch'], - description: 'Watches the azd extension project for file changes and rebuilds it.', + name: ['--version', '-v'], + description: 'Version of the release', + args: [ + { + name: 'version', + }, + ], }, ], }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + { + name: ['watch'], + description: 'Watches the azd extension project for file changes and rebuilds it.', + }, ], }, + { + name: ['help'], + description: 'Help about any command', + }, ], options: [ { diff --git a/extensions/theme-defaults/themes/2026-dark.json b/extensions/theme-defaults/themes/2026-dark.json index 24d91a541fb240..5fa103a9566116 100644 --- a/extensions/theme-defaults/themes/2026-dark.json +++ b/extensions/theme-defaults/themes/2026-dark.json @@ -54,16 +54,15 @@ "badge.background": "#3994BCF0", "badge.foreground": "#FFFFFF", "progressBar.background": "#878889", - "list.activeSelectionBackground": "#3994BC26", + "list.activeSelectionBackground": "#FFFFFF22", "list.activeSelectionForeground": "#ededed", "list.inactiveSelectionBackground": "#2C2D2E", "list.inactiveSelectionForeground": "#ededed", - "list.hoverBackground": "#1E1F20", + "list.hoverBackground": "#FFFFFF14", "list.hoverForeground": "#bfbfbf", "list.dropBackground": "#3994BC1A", - "toolbar.hoverBackground": "#323233", - "toolbar.activeBackground": "#3C3C3D", - "list.focusBackground": "#3994BC26", + "toolbar.activeBackground": "#FFFFFF33", + "list.focusBackground": "#FFFFFF22", "list.focusForeground": "#bfbfbf", "list.focusOutline": "#3994BCB3", "list.highlightForeground": "#48A0C7", @@ -139,7 +138,7 @@ "editorSuggestWidget.border": "#2A2B2CFF", "editorSuggestWidget.foreground": "#bfbfbf", "editorSuggestWidget.highlightForeground": "#bfbfbf", - "editorSuggestWidget.selectedBackground": "#3994BC26", + "editorSuggestWidget.selectedBackground": "#FFFFFF26", "editorHoverWidget.background": "#202122", "editorHoverWidget.border": "#2A2B2CFF", "widget.border": "#2A2B2CFF", @@ -235,7 +234,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#1E1F20", + "quickInputList.hoverBackground": "#FFFFFF14", "terminal.selectionBackground": "#3994BC33", "terminal.background": "#191A1B", "terminal.border": "#2A2B2CFF", diff --git a/extensions/theme-defaults/themes/2026-light.json b/extensions/theme-defaults/themes/2026-light.json index 9d2a97a6534bcc..1cb521076fde97 100644 --- a/extensions/theme-defaults/themes/2026-light.json +++ b/extensions/theme-defaults/themes/2026-light.json @@ -58,20 +58,20 @@ "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", "listFilterWidget.shadow": "#00000000", - "scrollbarSlider.background": "#64646480", - "scrollbarSlider.hoverBackground": "#64646490", - "scrollbarSlider.activeBackground": "#646464A0", + "scrollbarSlider.background": "#646464C0", + "scrollbarSlider.hoverBackground": "#646464D0", + "scrollbarSlider.activeBackground": "#646464E0", "badge.background": "#0069CC", "badge.foreground": "#FFFFFF", "progressBar.background": "#0069CC", - "list.activeSelectionBackground": "#0069CC1A", + "list.activeSelectionBackground": "#00000025", "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#DADADA99", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F1F1F3", + "list.hoverBackground": "#00000014", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", - "list.focusBackground": "#0069CC1A", + "list.focusBackground": "#00000025", "list.focusForeground": "#202020", "list.focusOutline": "#0069CCFF", "list.highlightForeground": "#0069CC", @@ -144,7 +144,7 @@ "editorSuggestWidget.border": "#E4E5E6FF", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", - "editorSuggestWidget.selectedBackground": "#0069CC26", + "editorSuggestWidget.selectedBackground": "#00000025", "editorSuggestWidget.selectedForeground": "#202020", "editorSuggestWidget.selectedIconForeground": "#202020", "editorHoverWidget.background": "#FAFAFD", @@ -226,6 +226,7 @@ "notificationsWarningIcon.foreground": "#B69500", "notificationsErrorIcon.foreground": "#ad0707", "notificationsInfoIcon.foreground": "#0069CC", + "problemsWarningIcon.foreground": "#895503", "activityWarningBadge.foreground": "#202020", "activityWarningBadge.background": "#F2C94C", "activityErrorBadge.foreground": "#FFFFFF", @@ -241,7 +242,7 @@ "quickInputList.focusForeground": "#FFFFFF", "quickInputList.focusIconForeground": "#FFFFFF", "quickInputList.focusHighlightForeground": "#FFFFFF", - "quickInputList.hoverBackground": "#F1F1F3", + "quickInputList.hoverBackground": "#00000014", "terminal.selectionBackground": "#0069CC26", "terminalCursor.foreground": "#202020", "terminalCursor.background": "#FFFFFF", @@ -282,9 +283,9 @@ "charts.purple": "#652D90", "agentStatusIndicator.background": "#FFFFFF", "inlineChat.border": "#00000000", - "minimapSlider.background": "#64646480", - "minimapSlider.hoverBackground": "#64646490", - "minimapSlider.activeBackground": "#646464A0", + "minimapSlider.background": "#646464C0", + "minimapSlider.hoverBackground": "#646464D0", + "minimapSlider.activeBackground": "#646464E0", "agents.background": "#FAFAFD", "agentsPanel.background": "#FFFFFF", diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts index ad62829877cb8d..97ac84afd855bd 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/browser.test.ts @@ -293,7 +293,7 @@ import { assertNoRpc, closeAllEditors } from '../utils'; throw new Error(`Timed out waiting for trust-harness title to update (target=${target}, last title=${tab.title})`); } - test('file:// inside a trusted workspace folder loads', async function () { + test.skip('file:// inside a trusted workspace folder loads', async function () { this.timeout(30_000); const folders = workspace.workspaceFolders!; @@ -303,7 +303,11 @@ import { assertNoRpc, closeAllEditors } from '../utils'; assert.ok(title.startsWith('status:200'), `Expected status 200 for trusted file, got: ${title}`); }); - test('file:// outside any trusted root is blocked with 403', async function () { + // Skipped: the test runner always launches with `--disable-workspace-trust`, + // which makes the browser view trust all `file://` requests (see + // `trustAllFiles` in `IBrowserViewWindowConfiguration`). Re-enable once the + // test infrastructure supports running with Workspace Trust enabled. + test.skip('file:// outside any trusted root is blocked with 403', async function () { this.timeout(30_000); const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'vscode-browser-trust-')); diff --git a/package-lock.json b/package-lock.json index 5ae5fdb83e57cd..86084fcd3f14bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "code-oss-dev", - "version": "1.127.0", + "version": "1.129.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-oss-dev", - "version": "1.127.0", + "version": "1.129.0", "hasInstallScript": true, "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.69-0", + "@github/copilot-sdk": "^1.0.6-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -30,7 +30,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -41,16 +41,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -77,10 +77,10 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.61.1", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/chrome-remote-interface": "^0.33.0", "@types/cookie": "^0.3.3", @@ -113,7 +113,7 @@ "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", "@vscode/test-electron": "^2.4.0", - "@vscode/test-web": "^0.0.76", + "@vscode/test-web": "^0.0.81", "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", @@ -123,7 +123,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "42.2.0", + "electron": "42.5.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", @@ -185,23 +185,23 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.187.tgz", - "integrity": "sha512-HHkuC3he/Wi8fX/WjfS8mJr4TFPGoAd1QyxOuTwjnyOLboGkX1H+UhBYLxHX1ouFvHnYVJglB2wsuWemVQgahQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.198.tgz", + "integrity": "sha512-xt469sSCyclTPtzpLAg0Aschy665GiRMgZKabSmESbGUA5/H56HcILVOiFxclXswkeMUk2fQxfHJUY9UZfiTnA==", "dev": true, "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.187", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.187" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.198", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.198" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -210,9 +210,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.187.tgz", - "integrity": "sha512-0DNzATzaRmjS/Q1T4T9SLP/VT67gRX7J4reYPnEdcTm91lJwjqOPkHr17+sv+7DoerZ421ZG38dPsQd/V67qQw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.198.tgz", + "integrity": "sha512-ZmiAybQKIKcP1qEAE/vfXvfxtKxG9CnJn98QTXC5Zxiwuy7Mllx2ALXh9dfmsf0V87CGEodlZQmMgUJotNIsUw==", "cpu": [ "arm64" ], @@ -224,9 +224,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.187.tgz", - "integrity": "sha512-Va3O8lTDa9IHq/DbSJwU63JJknNV3YCBh4PEznOHXJtyFoXHgRjrks4QQvN3baZm79avVq2sn+6tvpTz9CuY8A==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.198.tgz", + "integrity": "sha512-XwH5vgN46WSwg8aC1OagNofnJpV/G1ciEu118GEKer8ZhVkq/dvK/DqShxMkb6r1jV7u5IJ7zPXu9uKliyNJAw==", "cpu": [ "x64" ], @@ -238,9 +238,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.187.tgz", - "integrity": "sha512-VEvrs3MgwckdWRNa7+2rJdyOBbCWGoRaM6OnL+/n9qi4gKlZnXZlj/90Tw9o8ttcN260Opz120/SE1fYwzu/JA==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.198.tgz", + "integrity": "sha512-qmz8dxEtDIlKntU5qYe0R4aWTxTue5S7zIQknatLX7aJ6HN/nq1aCNXWn5smTH2FViBkUPPR+sCIsNwSk6AT6Q==", "cpu": [ "arm64" ], @@ -255,9 +255,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.187.tgz", - "integrity": "sha512-XQ7w2pX0mjPYUC7ayTKiHeAbePOny/ezBATxTWt5JVDZZPfljzvHA0jyXHsN8aLphbgRUfbUpdrtt5b57Bhx5g==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.198.tgz", + "integrity": "sha512-Q7lKVNjIrUQ2B/AR77OvRf0zeOdEjonFVaR9FYrrwtzGeEqum69WSht5nM7Y7el3wjbNi0/eV0QTUM0DlsTEfw==", "cpu": [ "arm64" ], @@ -272,9 +272,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.187.tgz", - "integrity": "sha512-s/Fmg29tzMrbdeCbseTpL3DlzfWineSQ3bDPvhdKw4jcsgLC1YgQhIkAyE8WlceUyhQyw82NAUgYoPwfGny6hQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.198.tgz", + "integrity": "sha512-Zqxyz2AT1UM5WlOOoLJhLssZDgZo8rBK5ku6daveK12zp+UTJGZhGsjFghz1/ASxH08KqOTbUePNTORnPhHAEQ==", "cpu": [ "x64" ], @@ -289,9 +289,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.187.tgz", - "integrity": "sha512-yi2/L5oztFqTDnAZl4mWOINr+r7WBot70gYUxG2jOoqdYUl6j50vG/ME605X80v+l2qBmKjSGxb5trY/6DkRzQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.198.tgz", + "integrity": "sha512-h1SrWVIMjLInYNPlf+TxXuKTOdoiOfJLBSoQG97315Z2Nh0IpBfqWExlqYTtPCgKE7q2iga31U283QfHpIDlSQ==", "cpu": [ "x64" ], @@ -306,9 +306,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.187.tgz", - "integrity": "sha512-HjEl3cDRLAWKopdlrLI54fxTVWq4lZpWA4bTTBVc9UDdDj6AmJIRHKxaCCYLQRvnoswbAUF1sJmJ8EFJ+b6lfw==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.198.tgz", + "integrity": "sha512-mjIHf1HFiRuXefewWTaNZFlTZlCaEt/xsRjc1nSTCEEpFolZayVhrDKz+O2QFVcDtPl8x8GeYSL0kiikg1DZjQ==", "cpu": [ "arm64" ], @@ -320,9 +320,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.187", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.187.tgz", - "integrity": "sha512-FnrLhoZ8y/+gkZynL12UeQ8pWKCu6B/8myyRrYMNJRZqFw4DZlPvPCywAjFZf1e1hiXCoiSK0Orl5wzKfAeQsQ==", + "version": "0.3.198", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.198.tgz", + "integrity": "sha512-y3HLuCCz1kDwUrhd6OnqO+d5BUpTFSzNUsPT9kf3r1vk9HYKF+eMC9eIlcOhiW2kX491kxEvuEOfqgIkGx15cg==", "cpu": [ "x64" ], @@ -625,13 +625,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -640,9 +640,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -650,21 +650,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -698,14 +698,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -715,14 +715,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -759,9 +759,9 @@ "license": "ISC" }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -769,29 +769,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -801,9 +801,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -811,9 +811,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -821,9 +821,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -831,27 +831,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -870,33 +870,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -904,14 +904,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -923,6 +923,16 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, + "node_modules/@electron-internal/extract-zip": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@electron-internal/extract-zip/-/extract-zip-1.0.3.tgz", + "integrity": "sha512-OjKpjB7gohtEjZiq6nDx1egqjZJhGPN1iFOIED+NFhB/MMkXw/XRcHjh1DGXKT5z2W9eW7Jy2UKU3gpjvusFTQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/@electron/get": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@electron/get/-/get-5.0.0.tgz", @@ -1097,9 +1107,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69-0.tgz", + "integrity": "sha512-Y/ZJBo1dtsP7k2LxDQxQT5pj2ZaN7+dCD4vx5jhX3i2T+YFlOx7ZhfUx8Hpe94wQPDlCs+232TXRHl2Wb5p62w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -1108,20 +1118,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.69-0", + "@github/copilot-darwin-x64": "1.0.69-0", + "@github/copilot-linux-arm64": "1.0.69-0", + "@github/copilot-linux-x64": "1.0.69-0", + "@github/copilot-linuxmusl-arm64": "1.0.69-0", + "@github/copilot-linuxmusl-x64": "1.0.69-0", + "@github/copilot-win32-arm64": "1.0.69-0", + "@github/copilot-win32-x64": "1.0.69-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69-0.tgz", + "integrity": "sha512-RyX31WkNGpXycW5FCAgJ7+MXTmfwSkiohHf7jWShKQPP6m/MEM0CrBuS4XPeWK0gjtWM4MdAwmVe7qXtbzZu1w==", "cpu": [ "arm64" ], @@ -1135,9 +1145,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69-0.tgz", + "integrity": "sha512-NaA44Uq6d1ijcF2eT8/Ql0eacyvjGFGYN8hVFLkD6CsjPmSbupMd39NFt2RWnZDjhg3S68J77OSXH4aj3vcaiA==", "cpu": [ "x64" ], @@ -1151,12 +1161,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69-0.tgz", + "integrity": "sha512-dSFha3BrnGeMKLzqWE8c63tRdKgw3mjV9Apx4/i7L9BMJ0o13oycVVe+D+bEVniWH12gVriIxE1+TQUAAVWLIQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1167,12 +1180,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69-0.tgz", + "integrity": "sha512-2zH3yh7//D7rOriDt4eAc4+tYay+oQj9CcENP0Cb8aNEn27hyZmuKiLoA+xyGSrbhVqA4rzYLC3Hx9RI96xxSQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1183,12 +1199,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69-0.tgz", + "integrity": "sha512-vVezUNBfxp4ej9rTQy3zy8UT23imxFWqzkVu0yFrt6lqr9a7RSmPHhhKkPYkyxCWuzhSxGWLm3Ad6TDTYVVGog==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1199,12 +1218,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69-0.tgz", + "integrity": "sha512-Wlb421yC7iuw6ICMxuNPPL+ItG0f9+vv3wdHUeWhyCMte7+7tqxvhyfxSCzj46V5CXoGo32vUc0oOxmXMfSbyQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -1215,12 +1237,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.4.tgz", - "integrity": "sha512-c6/gpatP7LAuP9stlc2uXXOrB+TJMFSs3Jrzu9FG8lJJYFGmdzbBvGz2UdL1jww84fp6D7cohEZa4UGq1+iuyA==", + "version": "1.0.6-preview.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.6-preview.1.tgz", + "integrity": "sha512-SyBO5GpmCQRhvb7EvDMCsYyMDKm7On+dTHakJrkoKpARvMiCocYEjjAZuRGs8AiLXmyIuy/hqbSxWjIMNNrC0g==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.69-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -1238,9 +1260,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69-0.tgz", + "integrity": "sha512-H0p9kiCO8Rt6tMuZUPzszoxYaipIIvOBbUtqljV56UX+XwBaSnxME/ZjRCNn480jdrA2QbLjaobZWH2w3C4scg==", "cpu": [ "arm64" ], @@ -1254,9 +1276,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69-0.tgz", + "integrity": "sha512-3W/e8Lhw1eStFJogIP4pf9bxg9izcI/c0KErHLFK+56HqfnzK5ZDQqb+oeqRw3+aq24MvzyLzHA421jPHLIoOQ==", "cpu": [ "x64" ], @@ -1578,9 +1600,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz", + "integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==", "dev": true, "license": "MIT", "dependencies": { @@ -1728,19 +1750,27 @@ } }, "node_modules/@koa/router": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@koa/router/-/router-14.0.0.tgz", - "integrity": "sha512-LBSu5K0qAaaQcXX/0WIB9PGDevyCxxpnc1uq13vV/CgObaVxuis5hKl3Eboq/8gcb6ebnkAStW9NB/Em2eYyFA==", + "version": "15.6.0", + "resolved": "https://registry.npmjs.org/@koa/router/-/router-15.6.0.tgz", + "integrity": "sha512-iEOXlvGIBqSNkGXrg0XtMARAOm5zA24oedXxiTGEkrD4JgwVjfRDddCQvW1s4WEcwDYvyecRbf8BikXsuEEj8w==", "dev": true, "license": "MIT", "dependencies": { - "debug": "^4.4.1", - "http-errors": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", "koa-compose": "^4.1.0", - "path-to-regexp": "^8.2.0" + "path-to-regexp": "^8.4.2" }, "engines": { "node": ">= 20" + }, + "peerDependencies": { + "koa": "^2.0.0 || ^3.0.0" + }, + "peerDependenciesMeta": { + "koa": { + "optional": false + } } }, "node_modules/@malept/cross-spawn-promise": { @@ -1804,13 +1834,13 @@ "integrity": "sha512-PoHEgsnmcqruLNHZ/amACqdJ6YYQpED0KSRe6J7gIJTtpZC1FfFU9b1fmDKDKtFoUSrPzEh1qzO3kmRZP0betg==" }, "node_modules/@microsoft/dev-tunnels-connections": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.48.tgz", - "integrity": "sha512-PD7bKg5mCtVMTydKH++Xm0mf5uFPncPhLTmN/LYvoOmA06GLGkR1KLiPkilfibtu3Vyq3vqxfwp2kXoWZ6fEOQ==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-connections/-/dev-tunnels-connections-1.3.50.tgz", + "integrity": "sha512-L3vUE7jiW4tzx1D+sEsuCW5UKK3CjYFIxOtjZF/MN8ZCM2a2MVtIijctjU+/Y6Gi+ohvrOZKoqSZFRD12bpAgA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.48", - "@microsoft/dev-tunnels-management": "1.3.48", + "@microsoft/dev-tunnels-contracts": "1.3.50", + "@microsoft/dev-tunnels-management": "1.3.50", "await-semaphore": "^0.1.3", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -1834,9 +1864,9 @@ } }, "node_modules/@microsoft/dev-tunnels-contracts": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.48.tgz", - "integrity": "sha512-PYj+MuKy03BDtjnM1I3qMAU2MO9tnBPAcj/uP75ZJHJefLcLBeK8TssKmmYjQywD5llp9Y6TDt0wGwsC2XvM0Q==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-contracts/-/dev-tunnels-contracts-1.3.50.tgz", + "integrity": "sha512-R4G/h939dL3UOui/69cKmRNZVf+3IpO6bMWxOgt4NYjF2IvWaSWBoc+u2AdZuZi+0ZHatWXvjoCDLNrV4pUyLQ==", "license": "MIT", "dependencies": { "buffer": "^5.2.1", @@ -1854,12 +1884,12 @@ } }, "node_modules/@microsoft/dev-tunnels-management": { - "version": "1.3.48", - "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.48.tgz", - "integrity": "sha512-6XrYQViWiv6xxueZYDcScj/zYw7dvpyO4mk8q/uSkBWBrSExNZs/rTLTR7hzxUDJFaiq/VybeZNJe/5LKlveMg==", + "version": "1.3.50", + "resolved": "https://registry.npmjs.org/@microsoft/dev-tunnels-management/-/dev-tunnels-management-1.3.50.tgz", + "integrity": "sha512-sWK0CrBcmiNyeb3HztocR+Gd5ROfKjyixhxfSJ+TlGIj9Y4i3DggBDFZeaKGql5LbJN5+v13tOggF2vhn8OEfA==", "license": "MIT", "dependencies": { - "@microsoft/dev-tunnels-contracts": "1.3.48", + "@microsoft/dev-tunnels-contracts": "1.3.50", "axios": "^1.8.4", "buffer": "^5.2.1", "debug": "^4.1.1", @@ -2677,23 +2707,23 @@ } }, "node_modules/@playwright/browser-chromium": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.56.1.tgz", - "integrity": "sha512-n4xzZpOn4qOtZJylpIn8co2QDoWczfJ068sEeky3EE5Vvy+lHX2J3WAcC4MbXzcpfoBee1lJm8JtXuLZ9HBCBA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/browser-chromium/-/browser-chromium-1.61.1.tgz", + "integrity": "sha512-t3/zE0i9gik5R/NpRs7G2Xo/6NPeABW6ReplGdtkeWeAkaV764CgFgoKjJo21D2xgjnvDvRYubqBUu4xl0VCqA==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.61.1" }, "engines": { "node": ">=18" } }, "node_modules/@playwright/browser-chromium/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2752,13 +2782,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.56.1.tgz", - "integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.56.1" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -3654,9 +3684,9 @@ } }, "node_modules/@vscode/component-explorer-cli": { - "version": "0.2.1-59", - "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-59.tgz", - "integrity": "sha512-7ewdfQ2Fre1GR7Ck9hvWVmuFx4Z2JDAtoRLqI4LFJgtxVXIjgqx+2ylkIX4h19/rgyN9AVFYketroOdtTlzsGQ==", + "version": "0.2.1-63", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-cli/-/component-explorer-cli-0.2.1-63.tgz", + "integrity": "sha512-DzPPaUXU/xB4zN2Urx9CP07TG/hItWVEaeWKp9j52BMTev6fWkVdh3YBVQGAxIEvoAVAuQSUlPUGdf8e9IkePw==", "dev": true, "license": "MIT", "dependencies": { @@ -3879,9 +3909,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", - "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz", + "integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -4094,26 +4124,26 @@ } }, "node_modules/@vscode/test-web": { - "version": "0.0.76", - "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.76.tgz", - "integrity": "sha512-hB+GKNmxnaTKemNOOBUcqYsIa5a0uuccCRnNIdCMS+I3RhVlyCtLBl29ZN/RAB2+M+ujjI8L8qL6GLCPqNFIBg==", + "version": "0.0.81", + "resolved": "https://registry.npmjs.org/@vscode/test-web/-/test-web-0.0.81.tgz", + "integrity": "sha512-qAYNX1mf4hE0L3T/186J8AH+Z7Inm81OHMACkkyKE2J6HJZlXou0OgABkSvd8gt0BiPjI+V+xkduAaQ8Kcjexg==", "dev": true, "license": "MIT", "dependencies": { "@koa/cors": "^5.0.0", - "@koa/router": "^14.0.0", - "@playwright/browser-chromium": "^1.56.1", + "@koa/router": "^15.6.0", + "@playwright/browser-chromium": "^1.61.0", "gunzip-maybe": "^1.4.2", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "koa": "^3.1.1", + "http-proxy-agent": "^9.1.0", + "https-proxy-agent": "^9.1.0", + "koa": "^3.2.1", "koa-morgan": "^1.0.1", "koa-mount": "^4.2.0", "koa-static": "^5.0.0", "minimist": "^1.2.8", - "playwright": "^1.56.1", - "tar-fs": "^3.1.1", - "tinyglobby": "^0.2.15", + "playwright": "^1.61.0", + "tar-fs": "^3.1.2", + "tinyglobby": "^0.2.17", "vscode-uri": "^3.1.0" }, "bin": { @@ -4123,6 +4153,46 @@ "node": ">=20" } }, + "node_modules/@vscode/test-web/node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@vscode/test-web/node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@vscode/tree-sitter-wasm": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.3.1.tgz", @@ -4252,27 +4322,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -4282,7 +4352,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -4295,63 +4365,63 @@ } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.285.tgz", - "integrity": "sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.288.tgz", + "integrity": "sha512-b7lKSC8eW2Zk2iwy0BQja4XbBHcai7aJBdVq8jZmPS9GqY8qo6k/rjZiOs5pgqTR9S+/MXYq0c+4szY+0sPBLw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" @@ -5065,10 +5135,19 @@ } }, "node_modules/b4a": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.4.tgz", - "integrity": "sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==", - "dev": true + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", + "integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "react-native-b4a": "*" + }, + "peerDependenciesMeta": { + "react-native-b4a": { + "optional": true + } + } }, "node_modules/bach": { "version": "1.2.0", @@ -5097,24 +5176,32 @@ "dev": true }, "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.1.tgz", + "integrity": "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==", "dev": true, "license": "Apache-2.0", - "optional": true + "peerDependencies": { + "bare-abort-controller": "*" + }, + "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + } + } }, "node_modules/bare-fs": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz", - "integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==", + "version": "4.7.2", + "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.7.2.tgz", + "integrity": "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" + "bare-stream": "^2.6.4", + "bare-url": "^2.2.2", + "fast-fifo": "^1.3.2" }, "engines": { "bare": ">=1.16.0" @@ -5129,42 +5216,45 @@ } }, "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.9.3.tgz", + "integrity": "sha512-fF4Q7QsyKVF5Rj0qvI8BgUNjqzC2JvQlpTaPLjVJVxYVUX5Zr9un+y3w1HmA4nNKdFmRBT8z/WmrjvXzXVerKQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "engines": { "bare": ">=1.14.0" } }, "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.1.tgz", + "integrity": "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "bare-os": "^3.0.1" } }, "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", + "version": "2.13.3", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.13.3.tgz", + "integrity": "sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==", "dev": true, "license": "Apache-2.0", - "optional": true, "dependencies": { - "streamx": "^2.21.0" + "b4a": "^1.8.1", + "streamx": "^2.25.0", + "teex": "^1.0.1" }, "peerDependencies": { + "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "peerDependenciesMeta": { + "bare-abort-controller": { + "optional": true + }, "bare-buffer": { "optional": true }, @@ -5173,6 +5263,16 @@ } } }, + "node_modules/bare-url": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.4.5.tgz", + "integrity": "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-path": "^3.0.0" + } + }, "node_modules/base": { "version": "0.11.2", "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", @@ -5223,9 +5323,9 @@ ] }, "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5378,9 +5478,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", "dev": true, "funding": [ { @@ -5398,11 +5498,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -5693,9 +5793,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001768", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001768.tgz", - "integrity": "sha512-qY3aDRZC5nWPgHUgIB84WL+nySuo19wk0VJpp/XI9T34lrvkyhRvNVOFJOp2kxClQhiFBu+TaUSudf6oa3vkSA==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "dev": true, "funding": [ { @@ -5804,9 +5904,10 @@ "integrity": "sha512-b0553uYA5YAEGgyYIGYROzKQ7X5RAqedkfjiZxwi0kL1g3bOaBNNZfYkzt/CL0umgD5wc9Jec2FbB98CjkMRvQ==" }, "node_modules/chrome-remote-interface/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.11", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", "engines": { "node": ">=8.3.0" }, @@ -7111,15 +7212,15 @@ "dev": true }, "node_modules/electron": { - "version": "42.2.0", - "resolved": "https://registry.npmjs.org/electron/-/electron-42.2.0.tgz", - "integrity": "sha512-b2Tc7sIKiZEl0tBVwFM5GJ+FT5KYhmy9QJHjx8BGVZPVW2SctXWEvrE959ElB56qw7H05dBkhlikDA1DmpaAMw==", + "version": "42.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-42.5.0.tgz", + "integrity": "sha512-cYEKS9XFz+c9fAB4jI0x49yz1FFzB55r3q96wu9YkwwJMv7t9202IE/ltlgy6yitl/J4M7C8JQcmUqdzDvPl/w==", "dev": true, "license": "MIT", "dependencies": { + "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", - "@types/node": "^24.9.0", - "extract-zip": "^2.0.1" + "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", @@ -7130,9 +7231,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.379", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.379.tgz", + "integrity": "sha512-v/qV5aV5EUA2pGilzUCq5/eyOloZAqDZBu9UMBIzgPpLlprjSR6zswsWBTv0KpqxLGUAZEwhO95ZCt7srymNVA==", "dev": true, "license": "ISC" }, @@ -7880,6 +7981,16 @@ "node": ">=0.8.x" } }, + "node_modules/events-universal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", + "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-events": "^2.7.0" + } + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -8261,26 +8372,6 @@ "node": ">=0.10.0" } }, - "node_modules/extract-zip": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", - "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", - "dev": true, - "dependencies": { - "debug": "^4.1.1", - "get-stream": "^5.1.0", - "yauzl": "^2.10.0" - }, - "bin": { - "extract-zip": "cli.js" - }, - "engines": { - "node": ">= 10.17.0" - }, - "optionalDependencies": { - "@types/yauzl": "^2.9.1" - } - }, "node_modules/fancy-log": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", @@ -9100,31 +9191,6 @@ "node": ">=8" } }, - "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-stream/node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/get-symbol-description": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", @@ -12498,10 +12564,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", + "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -12700,7 +12776,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "MIT", "dependencies": { @@ -12729,9 +12804,9 @@ } }, "node_modules/koa": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/koa/-/koa-3.1.2.tgz", - "integrity": "sha512-2LOQnFKu3m0VxpE+5sb5+BRTSKrXmNxGgxVRiKwD9s5KQB1zID/FRXhtzeV7RT1L2GVpdEEAfVuclFOMGl1ikA==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/koa/-/koa-3.2.1.tgz", + "integrity": "sha512-e7IpWJrnanNUroVK2taAgMxoEZvHLXdQiNjeExSu/DEIWm83jaKGBgb7tLmu2rMYpA027qFB3iLR/k3AVpFRnA==", "dev": true, "license": "MIT", "dependencies": { @@ -14183,11 +14258,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/nopt": { "version": "4.0.1", @@ -15180,9 +15258,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", - "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "dev": true, "license": "MIT", "funding": { @@ -15292,13 +15370,13 @@ } }, "node_modules/playwright": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz", - "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.56.1" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -15323,9 +15401,9 @@ } }, "node_modules/playwright/node_modules/playwright-core": { - "version": "1.56.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz", - "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -15540,6 +15618,24 @@ "node": ">= 0.10" } }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -17597,17 +17693,15 @@ } }, "node_modules/streamx": { - "version": "2.22.0", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", - "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.0.tgz", + "integrity": "sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==", "dev": true, "license": "MIT", "dependencies": { + "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" } }, "node_modules/string_decoder": { @@ -18031,9 +18125,9 @@ } }, "node_modules/tar-fs": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", - "integrity": "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg==", + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.3.tgz", + "integrity": "sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ==", "dev": true, "license": "MIT", "dependencies": { @@ -18046,22 +18140,25 @@ } }, "node_modules/tar-fs/node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", "dev": true, + "license": "MIT", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "node_modules/tar-stream": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.6.tgz", - "integrity": "sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.2.0.tgz", + "integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==", "dev": true, + "license": "MIT", "dependencies": { "b4a": "^1.6.4", + "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } @@ -18226,14 +18323,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.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -19737,9 +19834,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 110b3ff6ff2473..6f91500f1802f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", - "version": "1.127.0", - "distro": "7abf39b86c07d094722a4b3ec9f37e78fe3d5db3", + "version": "1.129.0", + "distro": "a78eb8284ce7699e371b36071b579f557406f153", "author": { "name": "Microsoft Corporation" }, @@ -95,8 +95,8 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.82.0", - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.69-0", + "@github/copilot-sdk": "^1.0.6-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/dev-tunnels-connections": "^1.3.41", @@ -114,7 +114,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.4.0", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -125,16 +125,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "chrome-remote-interface": "^0.33.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -161,10 +161,10 @@ "zod": "^3.25.76" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "0.3.187", + "@anthropic-ai/claude-agent-sdk": "0.3.198", "@openai/codex": "0.142.0", "@playwright/cli": "^0.1.9", - "@playwright/test": "^1.56.1", + "@playwright/test": "^1.61.1", "@stylistic/eslint-plugin-ts": "^2.8.0", "@types/chrome-remote-interface": "^0.33.0", "@types/cookie": "^0.3.3", @@ -197,7 +197,7 @@ "@vscode/telemetry-extractor": "^1.20.2", "@vscode/test-cli": "^0.0.6", "@vscode/test-electron": "^2.4.0", - "@vscode/test-web": "^0.0.76", + "@vscode/test-web": "^0.0.81", "@vscode/v8-heap-parser": "^0.1.0", "@vscode/vscode-perf": "^0.0.19", "@webgpu/types": "^0.1.66", @@ -207,7 +207,7 @@ "cookie": "^0.7.2", "debounce": "^1.0.0", "deemon": "^1.13.6", - "electron": "42.2.0", + "electron": "42.5.0", "eslint": "^9.36.0", "eslint-formatter-compact": "^8.40.0", "eslint-plugin-header": "3.1.1", diff --git a/product.json b/product.json index 9b7a7947ee3036..15f5796e3ac4d4 100644 --- a/product.json +++ b/product.json @@ -95,6 +95,7 @@ "termsStatementUrl": "https://aka.ms/github-copilot-terms-statement", "privacyStatementUrl": "https://aka.ms/github-copilot-privacy-statement", "skusDocumentationUrl": "https://aka.ms/github-copilot-plans", + "optimizeUsageDocumentationUrl": "https://aka.ms/token-usage-tips", "publicCodeMatchesUrl": "https://aka.ms/github-copilot-match-public-code", "managePlanUrl": "https://aka.ms/github-copilot-manage-plan", "upgradePlanUrl": "https://aka.ms/github-copilot-upgrade-plan", diff --git a/remote/.npmrc b/remote/.npmrc index 6f2d4e8df7b2cf..36e023c6e740c5 100644 --- a/remote/.npmrc +++ b/remote/.npmrc @@ -1,8 +1,7 @@ disturl="https://nodejs.org/dist" -target="24.15.0" -ms_build_id="438265" +target="24.17.0" +ms_build_id="451432" runtime="node" build_from_source="true" legacy-peer-deps="true" timeout=180000 -min-release-age="1" diff --git a/remote/package-lock.json b/remote/package-lock.json index 46f4e699f5e672..7edaf1634e4c0f 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -8,8 +8,8 @@ "name": "vscode-reh", "version": "0.0.0", "dependencies": { - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.69-0", + "@github/copilot-sdk": "^1.0.6-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -18,7 +18,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -27,16 +27,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", @@ -60,9 +60,9 @@ } }, "node_modules/@github/copilot": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.65.tgz", - "integrity": "sha512-J1XvLuOiVpiAi/E1MBICBymszCgdGLnZxokosXzGcmcjEVZd+QSDoW/kPRHq6oEyBT9SDASPcjCEZ9Q0rLJllg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot/-/copilot-1.0.69-0.tgz", + "integrity": "sha512-Y/ZJBo1dtsP7k2LxDQxQT5pj2ZaN7+dCD4vx5jhX3i2T+YFlOx7ZhfUx8Hpe94wQPDlCs+232TXRHl2Wb5p62w==", "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "detect-libc": "^2.1.2" @@ -71,20 +71,20 @@ "copilot": "npm-loader.js" }, "optionalDependencies": { - "@github/copilot-darwin-arm64": "1.0.65", - "@github/copilot-darwin-x64": "1.0.65", - "@github/copilot-linux-arm64": "1.0.65", - "@github/copilot-linux-x64": "1.0.65", - "@github/copilot-linuxmusl-arm64": "1.0.65", - "@github/copilot-linuxmusl-x64": "1.0.65", - "@github/copilot-win32-arm64": "1.0.65", - "@github/copilot-win32-x64": "1.0.65" + "@github/copilot-darwin-arm64": "1.0.69-0", + "@github/copilot-darwin-x64": "1.0.69-0", + "@github/copilot-linux-arm64": "1.0.69-0", + "@github/copilot-linux-x64": "1.0.69-0", + "@github/copilot-linuxmusl-arm64": "1.0.69-0", + "@github/copilot-linuxmusl-x64": "1.0.69-0", + "@github/copilot-win32-arm64": "1.0.69-0", + "@github/copilot-win32-x64": "1.0.69-0" } }, "node_modules/@github/copilot-darwin-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.65.tgz", - "integrity": "sha512-NFc4xIstZNiIuAYkurQT5DVtbZjBoZ/z6yt/Ffcom7Y5QGjfpN4BFuekv9k+OADRioxxR99NgmhjbuNPWtQhNQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-arm64/-/copilot-darwin-arm64-1.0.69-0.tgz", + "integrity": "sha512-RyX31WkNGpXycW5FCAgJ7+MXTmfwSkiohHf7jWShKQPP6m/MEM0CrBuS4XPeWK0gjtWM4MdAwmVe7qXtbzZu1w==", "cpu": [ "arm64" ], @@ -98,9 +98,9 @@ } }, "node_modules/@github/copilot-darwin-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.65.tgz", - "integrity": "sha512-0wtV22KmTa12VbqWRRkgvJcBz/oIbszfcIpyDWGc4MzbCVksajQ3TWVQ6c7Sdzj5RifCaYdkHAX2zuIYXYlLoQ==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-darwin-x64/-/copilot-darwin-x64-1.0.69-0.tgz", + "integrity": "sha512-NaA44Uq6d1ijcF2eT8/Ql0eacyvjGFGYN8hVFLkD6CsjPmSbupMd39NFt2RWnZDjhg3S68J77OSXH4aj3vcaiA==", "cpu": [ "x64" ], @@ -114,12 +114,15 @@ } }, "node_modules/@github/copilot-linux-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.65.tgz", - "integrity": "sha512-dOwdy/YbTXQN/+x2v4ZgiDycdRtWElyHxPuA6ail3yJDt0nagwn8OYAA/diBLPMAJuuBXiOZGvvb9fGRuh7Xgg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-arm64/-/copilot-linux-arm64-1.0.69-0.tgz", + "integrity": "sha512-dSFha3BrnGeMKLzqWE8c63tRdKgw3mjV9Apx4/i7L9BMJ0o13oycVVe+D+bEVniWH12gVriIxE1+TQUAAVWLIQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -130,12 +133,15 @@ } }, "node_modules/@github/copilot-linux-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.65.tgz", - "integrity": "sha512-al/1a/l/GrpHtygTxt7PZspmv0eHBPdAZ5B31J7Hv/GRdVZM4STCC9dCIOSUFsOX2fhaKD8yLfz4HureSYs23g==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linux-x64/-/copilot-linux-x64-1.0.69-0.tgz", + "integrity": "sha512-2zH3yh7//D7rOriDt4eAc4+tYay+oQj9CcENP0Cb8aNEn27hyZmuKiLoA+xyGSrbhVqA4rzYLC3Hx9RI96xxSQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -146,12 +152,15 @@ } }, "node_modules/@github/copilot-linuxmusl-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.65.tgz", - "integrity": "sha512-xccQeJSR45xyoaL7J5mZjtU++dmte+ZCDQkIlrpTn2yuMl2LWriBvorQ1P2MwVnXmIiW/GHi93B+lNtsybA9yw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-arm64/-/copilot-linuxmusl-arm64-1.0.69-0.tgz", + "integrity": "sha512-vVezUNBfxp4ej9rTQy3zy8UT23imxFWqzkVu0yFrt6lqr9a7RSmPHhhKkPYkyxCWuzhSxGWLm3Ad6TDTYVVGog==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -162,12 +171,15 @@ } }, "node_modules/@github/copilot-linuxmusl-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.65.tgz", - "integrity": "sha512-RHPVUaqjSrhKHQ2EpfGKWErnV+R5elGIZaHXPKO10zpSaQD9b/C9u6nLigZnBuT/8sCaJpVrazPMwOYvYA62aw==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-linuxmusl-x64/-/copilot-linuxmusl-x64-1.0.69-0.tgz", + "integrity": "sha512-Wlb421yC7iuw6ICMxuNPPL+ItG0f9+vv3wdHUeWhyCMte7+7tqxvhyfxSCzj46V5CXoGo32vUc0oOxmXMfSbyQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -178,12 +190,12 @@ } }, "node_modules/@github/copilot-sdk": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.4.tgz", - "integrity": "sha512-c6/gpatP7LAuP9stlc2uXXOrB+TJMFSs3Jrzu9FG8lJJYFGmdzbBvGz2UdL1jww84fp6D7cohEZa4UGq1+iuyA==", + "version": "1.0.6-preview.1", + "resolved": "https://registry.npmjs.org/@github/copilot-sdk/-/copilot-sdk-1.0.6-preview.1.tgz", + "integrity": "sha512-SyBO5GpmCQRhvb7EvDMCsYyMDKm7On+dTHakJrkoKpARvMiCocYEjjAZuRGs8AiLXmyIuy/hqbSxWjIMNNrC0g==", "license": "MIT", "dependencies": { - "@github/copilot": "^1.0.65", + "@github/copilot": "^1.0.69-0", "vscode-jsonrpc": "^8.2.1", "zod": "^4.3.6" }, @@ -201,9 +213,9 @@ } }, "node_modules/@github/copilot-win32-arm64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.65.tgz", - "integrity": "sha512-/vSE/t9Wm3eFSWpxlKyn/oL8OAVOB0yFO7ECxhgbtiqNrBd1tgpYh1k7IXBIWa/saxlV1+de6DEmPuQfx3Z0bg==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-arm64/-/copilot-win32-arm64-1.0.69-0.tgz", + "integrity": "sha512-H0p9kiCO8Rt6tMuZUPzszoxYaipIIvOBbUtqljV56UX+XwBaSnxME/ZjRCNn480jdrA2QbLjaobZWH2w3C4scg==", "cpu": [ "arm64" ], @@ -217,9 +229,9 @@ } }, "node_modules/@github/copilot-win32-x64": { - "version": "1.0.65", - "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.65.tgz", - "integrity": "sha512-wjVWXepET+SpFg8z8V43ZiTy6X1OerCb7yu3QZKNNJ3zY9z20goihPXQCDWkiJpGzszNSgfrsiqUzpUsC9qS0A==", + "version": "1.0.69-0", + "resolved": "https://registry.npmjs.org/@github/copilot-win32-x64/-/copilot-win32-x64-1.0.69-0.tgz", + "integrity": "sha512-3W/e8Lhw1eStFJogIP4pf9bxg9izcI/c0KErHLFK+56HqfnzK5ZDQqb+oeqRw3+aq24MvzyLzHA421jPHLIoOQ==", "cpu": [ "x64" ], @@ -646,9 +658,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.42.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.42.0.tgz", - "integrity": "sha512-uFEBHiWPtBdbn+BFBVzyCMqqhdxRaRdPawLen1JZ+zM8pdKHsrVO+smmo/PbM6HgHr+MKGezDmxZ9cEHv49gEQ==", + "version": "0.43.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.43.0.tgz", + "integrity": "sha512-FkZUID6bSDSSMZb981WVGTJSxlnCfGMcNDGNJIzwnyIhVdNTLlSSyIS6K6Ks0ALs5DpS82VhNHRC3PJIqonTBQ==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", @@ -806,27 +818,27 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -836,67 +848,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.285.tgz", - "integrity": "sha512-NJud+XEUjKMT2LwPqcIh/gazktV+R2AHjEPMQsn/l6+53rgFusuifmJjVkWLwZ228YYsUaN5+lJELeExS26q4A==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.288.tgz", + "integrity": "sha512-b7lKSC8eW2Zk2iwy0BQja4XbBHcai7aJBdVq8jZmPS9GqY8qo6k/rjZiOs5pgqTR9S+/MXYq0c+4szY+0sPBLw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" @@ -1634,9 +1646,9 @@ } }, "node_modules/tar": { - "version": "7.5.13", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz", - "integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==", + "version": "7.5.17", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.17.tgz", + "integrity": "sha512-wPEBwzapC+2PaTYPH6e2L+cNOEE227S47wUYFqlegcs8zlLLmeb9Fcff1HVZY4Fwku/1Eyv38n7GYwB2aaS71g==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -1721,9 +1733,9 @@ "license": "Unlicense" }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "license": "MIT", "engines": { "node": ">=20.18.1" @@ -1792,9 +1804,9 @@ "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/remote/package.json b/remote/package.json index 6b0764ac42768c..9b1cb37c14990e 100644 --- a/remote/package.json +++ b/remote/package.json @@ -3,8 +3,8 @@ "version": "0.0.0", "private": true, "dependencies": { - "@github/copilot": "^1.0.65", - "@github/copilot-sdk": "^1.0.4", + "@github/copilot": "^1.0.69-0", + "@github/copilot-sdk": "^1.0.6-preview.1", "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", "@microsoft/mxc-sdk": "0.6.1", @@ -13,7 +13,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.42.0", + "@vscode/proxy-agent": "^0.43.0", "@vscode/ripgrep-universal": "^1.18.0", "@vscode/sandbox-runtime": "0.0.1", "@vscode/spdlog": "^0.15.8", @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.7.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/headless": "^6.1.0-beta.285", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/headless": "^6.1.0-beta.288", + "@xterm/xterm": "^6.1.0-beta.288", "cookie": "^0.7.0", "detect-libc": "^2.1.2", "http-proxy-agent": "^7.0.0", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 2141a34b2766f0..15042f53fab054 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/xterm": "^6.1.0-beta.288", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -100,27 +100,27 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.285.tgz", - "integrity": "sha512-3Sw2VvUqTc8r7OWzizLlbVcbJXUwduWqS7jQzWyIVZiRer+olG1++oyE5tD6VLbt5mFwTEm1jdINYE0HRjF26w==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.288.tgz", + "integrity": "sha512-evM4OakyY26JveIrj4+XlA7sUDHr0GvmMoTIgrhzKrr9jjiTJcWXJE2xK3vvbbIa9ZGHmbo1srVaehVCPytOZg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.285.tgz", - "integrity": "sha512-ffpIrUlFj88FVBLdZCThdbwDOAeuKadHNpaJdXbDo5O0ObYyfnXYTL1JmVQxqusJToROnogTPL/MMoqP2oA49g==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.288.tgz", + "integrity": "sha512-zlgz0IIG3Te/aP4Kg1xRgWvCzgMgAk3LOP+RvGJAfP2g8tMFZBqcVVdX9BWTsp6hZQ4XaBPiiW+bORms/0Y3OQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.285.tgz", - "integrity": "sha512-ZBqrv60zrIKGspVfv5+m3lRGHeAGDW2U/imu6vER8D2vhxs75FXh/bA+X2/oSdDJQVgpygsN8G3gNQqt16v3eg==", + "version": "0.11.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.288.tgz", + "integrity": "sha512-82bXLLGHPvyrLF6FByXOJEJEmqHBMH0sIao9kkWBLL3tBSAHty25xo0ulWNUgP6O6sxAZX8Qbfi/zVhrw0A56g==", "license": "MIT", "dependencies": { "lru-cache": "^11.3.6", @@ -130,58 +130,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.285.tgz", - "integrity": "sha512-5iD2ANyyIgSexa+Hkf4OmMwNxfpLrPuDAQihGoMXMMjALgESBb6JYvob4C6H+4o5uoNSMV33sb+iwlkCqww32A==", + "version": "0.3.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.288.tgz", + "integrity": "sha512-4jBLZIZE13d7veFoSM5GYjdYxoHnUQzYPv8KA+l0u3IzZcIcJZpt6ll0cUA75klCb8tyL7dUhP4y2P4uDCGczQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.285.tgz", - "integrity": "sha512-cGjvwxsCnzlLbDWhMaHF9ZxTbYt6foAvUlURe63XyonXR2DVYH6/sr4YoUhM4S5tUMtdIhPxJhtQ8uF6r+ch3g==", + "version": "0.17.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.288.tgz", + "integrity": "sha512-JQk4n83Zs0A3K2VjQGbfhGAxoQZaMZtgBMSLKOx2Zg2HuikIsjIjUsPpOoJHwAfhVXKXmgd9QmVp61DxJj8tyw==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.285.tgz", - "integrity": "sha512-ae1Fi0Rceby+Ctf39aCjVlJ5+K3OJMEdeU3LIw0Su4z58k6Yz577laM4OJ7CIAUQTCp7K7WliYaTo29vNVCdBw==", + "version": "0.15.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.288.tgz", + "integrity": "sha512-9wgP/SJeG5ko5ddUDy0gstcziSqlb8ESm8BGtddxz4/56zVGs0DVPVIKQUYKLKKHDCPxm1b6T9YuK3f6WRd3CA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.285.tgz", - "integrity": "sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==", + "version": "0.10.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.288.tgz", + "integrity": "sha512-nFQvOBqQEtaPdpiZ2m3A5dkSW+EQGytnyQjMond81bIv3MDRkDKTy8FhAI9fEsZSq2wTbB91tfV5FLjAkSSQ7A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.284", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.284.tgz", - "integrity": "sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==", + "version": "0.20.0-beta.287", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.287.tgz", + "integrity": "sha512-ADDuXRHLfhMk3y+BFvaI1ibGCGxPMNRonzH5M/qTUnigsVxdnEMsYODG6pWAYkfDMPoCRBF8Zf/hj0efyEBjzQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.285" + "@xterm/xterm": "^6.1.0-beta.288" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.285", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.285.tgz", - "integrity": "sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==", + "version": "6.1.0-beta.288", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.288.tgz", + "integrity": "sha512-XLtX5kO0bgvjOEypUtO5xYUykE+vbJuQPuRK2cIx7oBFwlSYXGCVKPC7pvkLOYsfJWqJ9yusIaeJQqJvrYT1kQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 765e00417ad251..6f827637cffd95 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.285", - "@xterm/addon-image": "^0.10.0-beta.285", - "@xterm/addon-ligatures": "^0.11.0-beta.285", - "@xterm/addon-progress": "^0.3.0-beta.285", - "@xterm/addon-search": "^0.17.0-beta.285", - "@xterm/addon-serialize": "^0.15.0-beta.285", - "@xterm/addon-unicode11": "^0.10.0-beta.285", - "@xterm/addon-webgl": "^0.20.0-beta.284", - "@xterm/xterm": "^6.1.0-beta.285", + "@xterm/addon-clipboard": "^0.3.0-beta.288", + "@xterm/addon-image": "^0.10.0-beta.288", + "@xterm/addon-ligatures": "^0.11.0-beta.288", + "@xterm/addon-progress": "^0.3.0-beta.288", + "@xterm/addon-search": "^0.17.0-beta.288", + "@xterm/addon-serialize": "^0.15.0-beta.288", + "@xterm/addon-unicode11": "^0.10.0-beta.288", + "@xterm/addon-webgl": "^0.20.0-beta.287", + "@xterm/xterm": "^6.1.0-beta.288", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/scripts/chat-simulation/common/utils.js b/scripts/chat-simulation/common/utils.js index 79c608cf2ec010..e97f84f8c32c42 100644 --- a/scripts/chat-simulation/common/utils.js +++ b/scripts/chat-simulation/common/utils.js @@ -217,12 +217,23 @@ function buildEnv(mockServer, { isDevBuild = true } = {}) { * @param {string} logsDir * @returns {string[]} */ -function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT } = {}) { +function buildArgs(userDataDir, extDir, logsDir, { isDevBuild = true, extHostInspectPort = 0, traceFile = '', appRoot = ROOT, gcObjectStats = false } = {}) { // 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,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats,devtools.timeline,blink.user_timing`); + // IMPORTANT: `disabled-by-default-v8.gc_stats` is intentionally OFF by + // default. It makes V8 run GC_OBJECT_DUMP_STATISTICS (a full per-type + // heap object dump) on every major GC, inflating a ~15ms GC pause to + // ~550ms. When such a GC lands in the measured request window it + // corrupts timeToFirstToken (bimodal ~250ms vs ~900ms). `v8.gc` + + // `disabled-by-default-v8.gc` still provide the GC events we count. + // Opt in via `--gc-object-stats` only for deliberate GC deep-dives + // (never for timing runs), accepting that timings become unreliable. + const gcCategories = gcObjectStats + ? 'v8.gc,disabled-by-default-v8.gc,disabled-by-default-v8.gc_stats' + : 'v8.gc,disabled-by-default-v8.gc'; + chromiumFlags.push(`--enable-tracing=${gcCategories},devtools.timeline,blink.user_timing`); chromiumFlags.push(`--trace-startup-file=${traceFile}`); chromiumFlags.push(`--enable-tracing-format=json`); } diff --git a/scripts/chat-simulation/config.jsonc b/scripts/chat-simulation/config.jsonc index 6555b532e713fc..54a3dd96d27966 100644 --- a/scripts/chat-simulation/config.jsonc +++ b/scripts/chat-simulation/config.jsonc @@ -17,8 +17,7 @@ "metricThresholds": { "timeToFirstToken": "100ms", "timeToComplete": 0.2, - "layoutCount": 0.2, - "recalcStyleCount": 0.2, + "layoutDurationMs": 0.2, "forcedReflowCount": 0.2, "longTaskCount": 0.2, "longAnimationFrameCount": 0.2 @@ -28,10 +27,10 @@ // 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. + // Max acceptable steady-state residual heap growth in MB, measured + // AFTER the first (warm-up) iteration. The first iteration's growth is + // dominated by one-time V8/JIT/module caches that aren't reclaimed and + // is excluded; a real leak keeps growing every subsequent iteration. "leakThresholdMB": 10 } } diff --git a/scripts/chat-simulation/merge-ci-summary.js b/scripts/chat-simulation/merge-ci-summary.js index 4c107f9f5dc6bf..370291a04555b2 100644 --- a/scripts/chat-simulation/merge-ci-summary.js +++ b/scripts/chat-simulation/merge-ci-summary.js @@ -253,6 +253,7 @@ function generateUnifiedSummary(jsonReport, baseline, opts) { ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], ['layoutCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['recalcStyleCount', 'rendering', ''], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], @@ -267,8 +268,11 @@ function generateUnifiedSummary(jsonReport, baseline, opts) { ['extHostHeapDelta', 'extHost', 'MB'], ['extHostHeapDeltaPostGC', 'extHost', 'MB'], ]; + // layoutCount / recalcStyleCount are informational (inflated by CSS + // animations, compositor-driven, cheap) and do NOT gate — real layout cost is + // gated via layoutDurationMs below / timeToComplete. See SKILL.md. const regressionMetricNames = new Set([ - 'timeToFirstToken', 'timeToComplete', 'layoutCount', 'recalcStyleCount', + 'timeToFirstToken', 'timeToComplete', 'layoutDurationMs', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount', ]); diff --git a/scripts/chat-simulation/test-chat-mem-leaks.js b/scripts/chat-simulation/test-chat-mem-leaks.js index d63c4e4dcb4afe..945eeda1fefaf6 100644 --- a/scripts/chat-simulation/test-chat-mem-leaks.js +++ b/scripts/chat-simulation/test-chat-mem-leaks.js @@ -25,7 +25,7 @@ * 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 -- --threshold 5 # 5MB steady-state threshold * npm run perf:chat-leak -- --build 1.115.0 # test a specific build */ @@ -349,11 +349,24 @@ async function runLeakCheck(electronPath, mockServer, opts) { const totalResidualMB = Math.round((final.heapMB - baseline.heapMB) * 100) / 100; const totalResidualNodes = final.domNodes - baseline.domNodes; + // Steady-state residual EXCLUDES the first iteration. The first + // iteration's growth is dominated by one-time warm-up (V8 JIT, module + // and string caches, lazy singletons) rather than a leak — a real leak + // keeps growing every iteration, whereas warm-up plateaus. Basing the + // verdict on post-warm-up growth (heap relative to the end of iteration + // 1) avoids false positives from caching. Falls back to total residual + // when there are too few iterations to drop the warm-up one. + const warmupBaselineMB = iterationResults.length > 1 + ? iterationResults[0].afterHeapMB + : baseline.heapMB; + const steadyResidualMB = Math.round((final.heapMB - warmupBaselineMB) * 100) / 100; + return { baseline, final: { heapMB: final.heapMB, domNodes: final.domNodes }, totalResidualMB, totalResidualNodes, + steadyResidualMB, iterations: iterationResults, }; } finally { @@ -377,7 +390,7 @@ async function main() { 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] Leak check: ${opts.iterations} iterations × ${getScenarioIds().length} scenarios, threshold ${opts.leakThresholdMB}MB steady-state (excl. warm-up)`); console.log(`[chat-simulation] Build: ${electronPath}`); console.log(''); @@ -393,8 +406,9 @@ async function main() { 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(` Total residual heap growth: ${result.totalResidualMB > 0 ? '+' : ''}${result.totalResidualMB}MB (includes one-time warm-up)`); + console.log(` Steady-state residual (excl. warm-up): ${result.steadyResidualMB > 0 ? '+' : ''}${result.steadyResidualMB}MB`); + console.log(` Total residual DOM growth: ${result.totalResidualNodes > 0 ? '+' : ''}${result.totalResidualNodes} nodes`); console.log(''); // Write JSON @@ -408,12 +422,12 @@ async function main() { }, null, 2)); console.log(`[chat-simulation] Results written to ${jsonPath}`); - const leaked = result.totalResidualMB > opts.leakThresholdMB; + const leaked = result.steadyResidualMB > opts.leakThresholdMB; console.log(''); if (leaked) { - console.log(`[chat-simulation] LEAK DETECTED — ${result.totalResidualMB}MB residual exceeds ${opts.leakThresholdMB}MB threshold`); + console.log(`[chat-simulation] LEAK DETECTED — ${result.steadyResidualMB}MB steady-state residual exceeds ${opts.leakThresholdMB}MB threshold`); } else { - console.log(`[chat-simulation] No leak detected (${result.totalResidualMB}MB residual < ${opts.leakThresholdMB}MB threshold)`); + console.log(`[chat-simulation] No leak detected (${result.steadyResidualMB}MB steady-state residual < ${opts.leakThresholdMB}MB threshold)`); } if (opts.ci) { @@ -429,11 +443,11 @@ async function main() { /** * 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 {{ baseline: { heapMB: number, domNodes: number }, final: { heapMB: number, domNodes: number }, totalResidualMB: number, totalResidualNodes: number, steadyResidualMB: 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 leaked = result.steadyResidualMB > opts.leakThresholdMB; const verdict = leaked ? '\u274C **LEAK DETECTED**' : '\u2705 **No leak detected**'; const lines = []; lines.push('## Memory Leak Check'); @@ -441,7 +455,7 @@ function generateLeakCISummary(result, opts) { lines.push('| | |'); lines.push('|---|---|'); lines.push(`| **Verdict** | ${verdict} |`); - lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB |`); + lines.push(`| **Threshold** | ${opts.leakThresholdMB} MB (steady-state, excl. warm-up) |`); lines.push(`| **Iterations** | ${opts.iterations} |`); lines.push(`| **Scenarios per iteration** | ${getScenarioIds().length} |`); lines.push(''); @@ -452,13 +466,17 @@ function generateLeakCISummary(result, opts) { 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}) |`); + const note = i === 0 ? ' _(warm-up)_' : ''; + lines.push(`| Iteration ${i + 1}${note} | ${it.afterHeapMB} (${sign}${it.deltaHeapMB}) | ${it.afterDomNodes} (${domSign}${it.deltaDomNodes}) |`); } lines.push(`| **Final** | **${result.final.heapMB}** | **${result.final.domNodes}** |`); lines.push(''); + const steadySign = result.steadyResidualMB > 0 ? '+' : ''; 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(`**Steady-state residual (excl. warm-up):** ${steadySign}${result.steadyResidualMB} MB heap`); + lines.push(''); + lines.push(`**Total residual growth:** ${sign}${result.totalResidualMB} MB heap, ${domSign}${result.totalResidualNodes} DOM nodes _(includes one-time warm-up)_`); lines.push(''); return lines.join('\n'); } diff --git a/scripts/chat-simulation/test-chat-perf-regression.js b/scripts/chat-simulation/test-chat-perf-regression.js index 8dbf14a07b3fc4..51228ff8e4ab78 100644 --- a/scripts/chat-simulation/test-chat-perf-regression.js +++ b/scripts/chat-simulation/test-chat-perf-regression.js @@ -47,6 +47,7 @@ function parseArgs() { noCache: false, force: false, heapSnapshots: false, + gcObjectStats: false, /** @type {string[]} */ scenarios: [], /** @type {string | undefined} */ @@ -100,6 +101,7 @@ function parseArgs() { case '--no-cache': opts.noCache = true; break; case '--force': opts.force = true; break; case '--heap-snapshots': opts.heapSnapshots = true; break; + case '--gc-object-stats': opts.gcObjectStats = true; break; case '--ci': opts.ci = true; opts.noCache = true; opts.heapSnapshots = true; opts.cleanupDiagnostics = true; break; case '--cleanup-diagnostics': opts.cleanupDiagnostics = true; break; case '--help': case '-h': @@ -128,6 +130,7 @@ function parseArgs() { ' --no-cache Ignore cached baseline data, always run fresh', ' --force Skip build mode mismatch confirmation', ' --heap-snapshots Take heap snapshots (slow; auto-enabled in --ci mode)', + ' --gc-object-stats Enable V8 gc_stats tracing for GC deep-dives only. WARNING: corrupts timings (adds ~550ms to any request hit by a major GC) — never use for benchmarking', ' --ci CI mode: write Markdown summary to ci-summary.md (implies --no-cache, --heap-snapshots, --cleanup-diagnostics)', ' --cleanup-diagnostics Remove heap snapshots, CPU profiles, and traces after each run to save disk space', ' --verbose Print per-run details', @@ -401,7 +404,7 @@ async function runOnce(electronPath, scenario, mockServer, verbose, runIndex, ru const extHostInspectPort = getNextExtHostInspectPort(); const vscode = await launchVSCode( electronPath, - buildArgs(userDataDir, extDir, logsDir, { isDevBuild, extHostInspectPort, traceFile: tracePath, appRoot }), + buildArgs(userDataDir, extDir, logsDir, { isDevBuild, extHostInspectPort, traceFile: tracePath, appRoot, gcObjectStats: runOpts?.gcObjectStats }), buildEnv(mockServer, { isDevBuild }), { verbose }, ); @@ -1003,6 +1006,7 @@ function generateCISummary(jsonReport, baseline, opts) { ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], ['layoutCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['recalcStyleCount', 'rendering', ''], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], @@ -1017,7 +1021,7 @@ function generateCISummary(jsonReport, baseline, opts) { ['extHostHeapDelta', 'extHost', 'MB'], ['extHostHeapDeltaPostGC', 'extHost', 'MB'], ]; - const regressionMetricNames = new Set(['timeToFirstToken', 'timeToComplete', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount']); + const regressionMetricNames = new Set(['timeToFirstToken', 'timeToComplete', 'layoutDurationMs', 'forcedReflowCount', 'longTaskCount', 'longAnimationFrameCount']); const lines = []; const scenarios = Object.keys(jsonReport.scenarios); @@ -1393,7 +1397,7 @@ async function main() { 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 }, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(testElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'test', { ...opts.settingsOverrides, ...opts.testSettingsOverrides }, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && prevTestRuns.length > 0) { cleanupRunDiagnostics(prevTestRuns[prevTestRuns.length - 1]); } prevTestRuns.push(m); @@ -1411,7 +1415,7 @@ async function main() { 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 }, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineElectron, scenario, mockServer, opts.verbose, runIdx, prevDir, 'baseline', { ...opts.settingsOverrides, ...opts.baselineSettingsOverrides }, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && prevBaseRuns.length > 0) { cleanupRunDiagnostics(prevBaseRuns[prevBaseRuns.length - 1]); } prevBaseRuns.push(m); @@ -1557,7 +1561,7 @@ async function main() { const newResults = []; for (let i = 0; i < runsNeeded; i++) { try { - const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${existingRuns.length + i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && newResults.length > 0) { cleanupRunDiagnostics(newResults[newResults.length - 1]); } newResults.push(m); @@ -1588,7 +1592,7 @@ async function main() { const results = []; for (let i = 0; i < opts.runs; i++) { try { - const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots }); + const m = await runOnce(baselineExePath, scenario, mockServer, opts.verbose, `baseline-${scenario}-${i}`, runDir, 'baseline', baselineSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } results.push(m); @@ -1671,7 +1675,7 @@ async function main() { 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, { heapSnapshots: opts.heapSnapshots }); + const metrics = await runOnce(electronPath, scenario, mockServer, opts.verbose, `${scenario}-${i}`, runDir, 'test', testSettings, { heapSnapshots: opts.heapSnapshots, gcObjectStats: opts.gcObjectStats }); // Clean up previous run's diagnostics to bound disk usage; keep the latest if (opts.cleanupDiagnostics && results.length > 0) { cleanupRunDiagnostics(results[results.length - 1]); } results.push(metrics); @@ -1790,13 +1794,19 @@ async function printComparison(jsonReport, opts) { // [metric, group, unit] ['timeToFirstToken', 'timing', 'ms'], ['timeToComplete', 'timing', 'ms'], - ['layoutCount', 'rendering', ''], - ['recalcStyleCount', 'rendering', ''], + ['layoutDurationMs', 'rendering', 'ms'], ['forcedReflowCount', 'rendering', ''], ['longTaskCount', 'rendering', ''], ]; - // Informational metrics — shown in comparison but don't trigger failure + // Informational metrics — shown in comparison but don't trigger failure. + // layoutCount / recalcStyleCount are informational on purpose: they are + // inflated by CSS animations (compositor-driven, cheap) and don't reflect + // real cost — the real layout cost is layoutDurationMs (gated above). A + // build can do more, cheaper layouts yet spend less layout time and finish + // faster (e.g. giant-codeblock: +28% layoutCount but -7% layoutDurationMs). const infoMetrics = [ + ['layoutCount', 'rendering', ''], + ['recalcStyleCount', 'rendering', ''], ['heapDelta', 'memory', 'MB'], ['gcDurationMs', 'memory', 'ms'], ['extHostHeapDelta', 'extHost', 'MB'], diff --git a/scripts/test-remote-integration.bat b/scripts/test-remote-integration.bat index 96288d35886d80..5e2c8864c2c215 100644 --- a/scripts/test-remote-integration.bat +++ b/scripts/test-remote-integration.bat @@ -3,6 +3,10 @@ setlocal pushd %~dp0\.. +:: TODO(deepak1556): Remove this once bumped > 24.16.0, refs https://github.com/nodejs/node/issues/63638 +for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set "TMP=%%i" +set "TEMP=%TMP%" + IF "%~1" == "" ( set AUTHORITY=vscode-remote://test+test/ :: backward to forward slashed diff --git a/scripts/test-web-integration.bat b/scripts/test-web-integration.bat index bc33dfc2a35c03..2f6c320e5878a0 100644 --- a/scripts/test-web-integration.bat +++ b/scripts/test-web-integration.bat @@ -3,6 +3,10 @@ setlocal pushd %~dp0\.. +:: TODO(deepak1556): Remove this once bumped > 24.16.0, refs https://github.com/nodejs/node/issues/63638 +for /f "delims=" %%i in ('node -p "require('fs').realpathSync.native(require('os').tmpdir())"') do set "TMP=%%i" +set "TEMP=%TMP%" + IF "%~1" == "" ( set AUTHORITY=vscode-remote://test+test/ :: backward to forward slashed diff --git a/src/bootstrap-esm.ts b/src/bootstrap-esm.ts index 54681a2fa9cc97..1a50553cde5c83 100644 --- a/src/bootstrap-esm.ts +++ b/src/bootstrap-esm.ts @@ -5,16 +5,125 @@ import * as fs from 'node:fs'; import { register } from 'node:module'; +import { sep } from 'node:path'; import { product, pkg } from './bootstrap-meta.js'; import './bootstrap-node.js'; import * as performance from './vs/base/common/performance.js'; import { INLSConfiguration } from './vs/nls.js'; -// Install a hook to module resolution to map 'fs' to 'original-fs' -if (process.env['ELECTRON_RUN_AS_NODE'] || process.versions['electron']) { +// Prepare globals that are needed for running +globalThis._VSCODE_PRODUCT_JSON = { ...product }; +globalThis._VSCODE_PACKAGE_JSON = { ...pkg }; +globalThis._VSCODE_FILE_ROOT = import.meta.dirname; + +// Install a hook to ESM module resolution that +// 1) maps 'fs' to 'original-fs' (the ASAR-unaware Node.js `fs`), and +// 2) resolves bare module specifiers into our `node_modules.asar` archive. +// +// The archive keeps the same top-level layout as `node_modules` +// (`node_modules.asar/`). Node's default ESM resolver only ever looks +// into directories literally named `node_modules`, so it cannot find modules at +// the archive's top level on its own. We therefore locate the target package +// inside the archive (via its `package.json`) and re-run the default resolution +// rooted inside that package so Node resolves it as a package self-reference, +// applying the package's real `exports`/`main` fields and ESM conditions. This +// top-level layout is what allows extensions (e.g. Dev Containers) that reach +// into `${appRoot}/node_modules.asar/` to keep working. +// +// The archive stands in for the application's own `node_modules` folder, which +// is the *farthest* directory Node would walk to. We therefore always try the +// default resolution first: an importer that ships its own dependencies (e.g. a +// built-in extension under `${appRoot}/extensions/` that bundles a +// different copy of a package) must resolve against its own, closer +// `node_modules` — exactly as it would without the archive. Only when the +// default resolution finds nothing do we consult the archive. +function enableASARSupport(): void { + if (!process.env['ELECTRON_RUN_AS_NODE'] && !process.versions['electron']) { + return; // only on Electron / Electron-as-node + } + const jsCode = ` + import { createRequire, isBuiltin } from 'node:module'; + import { pathToFileURL, fileURLToPath } from 'node:url'; + import { appendFileSync } from 'node:fs'; + + let asarRequire; + let resourcesPath; + let trace; + + function setupTrace(sink) { + if (!sink) { return; } + const prefix = '[asar-resolve] '; + if (sink === '1' || sink === 'true' || sink === 'on' || sink === 'stderr') { + trace = msg => { try { process.stderr.write(prefix + msg + '\\n'); } catch { /* ignore */ } }; + } else { + // Any other value is treated as a log file path to append to. + trace = msg => { try { appendFileSync(sink, prefix + msg + '\\n'); } catch { /* ignore */ } }; + } + trace('tracing enabled (node ' + process.versions.node + '); resourcesPath=' + resourcesPath); + } + + // True only for *bare package specifiers* — the exact inputs Node routes to + // its PACKAGE_RESOLVE (node_modules walk / self-reference / 'exports'/'main'). + // - relative ('./', '../') and absolute ('/') paths -> new URL(specifier, base) + // - '#name' subpath imports -> PACKAGE_IMPORTS_RESOLVE + // - URL-scheme specifiers ('file:', 'data:', 'node:', 'electron:', ...) -> used verbatim + function isBarePackageSpecifier(specifier) { + if (specifier === '') { return false; } + const c = specifier[0]; + if (c === '.' || c === '/' || c === '#') { return false; } + return !URL.canParse(specifier); + } + + // Electron injects a synthetic 'electron' module (also reachable via the + // 'electron/main', 'electron/common' and 'electron/renderer' aliases) that + // the loader resolves to the 'electron:' URL scheme rather than a real file. + // 'node:module#isBuiltin' does not recognize it, so we detect it explicitly + // and treat it like a Node built-in: it lives in the runtime, never in + // 'node_modules', and must never be redirected into the archive. + function isElectronBuiltin(specifier) { + return specifier === 'electron' || specifier.startsWith('electron/'); + } + + function normalizeDriveLetter(path) { + if (process.platform === 'win32' + && path.length >= 2 + && (path.charCodeAt(0) >= 65 && path.charCodeAt(0) <= 90 || path.charCodeAt(0) >= 97 && path.charCodeAt(0) <= 122) + && path.charCodeAt(1) === 58) { + return path[0].toLowerCase() + path.slice(1); + } + return path; + } + + // Extract the package name from a bare specifier, e.g. + // 'foo/lib/x.js' -> 'foo', '@scope/bar/baz' -> '@scope/bar'. + function packageNameOf(specifier) { + if (specifier[0] === '@') { + const firstSlash = specifier.indexOf('/'); + if (firstSlash === -1) { return specifier; } + const secondSlash = specifier.indexOf('/', firstSlash + 1); + return secondSlash === -1 ? specifier : specifier.slice(0, secondSlash); + } + const slash = specifier.indexOf('/'); + return slash === -1 ? specifier : specifier.slice(0, slash); + } + + export async function initialize({ resourcesPath: resPath, asarPath, traceSink }) { + if (asarPath) { + resourcesPath = normalizeDriveLetter(resPath); + // A require rooted at the archive: 'require.resolve("./")' + // resolves into '/' (top-level layout). The leading + // './' is required so resolution is relative to the archive root rather + // than a bare-specifier node_modules walk (the archive directory is + // named node_modules.asar, so a bare walk would never find it). + asarRequire = createRequire(asarPath + '/x.js'); + } + setupTrace(traceSink); + } + export async function resolve(specifier, context, nextResolve) { if (specifier === 'fs') { + if (trace) { trace('map "fs" -> node:original-fs (from ' + context.parentURL + ')'); } return { format: 'builtin', shortCircuit: true, @@ -22,17 +131,116 @@ if (process.env['ELECTRON_RUN_AS_NODE'] || process.versions['electron']) { }; } + if (asarRequire && context.parentURL && isBarePackageSpecifier(specifier) && !isBuiltin(specifier) && !isElectronBuiltin(specifier)) { + let parentPath; + try { parentPath = normalizeDriveLetter(fileURLToPath(context.parentURL)); } catch { parentPath = undefined; } + if (parentPath && parentPath.startsWith(resourcesPath)) { + if (trace) { trace('resolve "' + specifier + '" from "' + context.parentURL + '"'); } + // Try the default resolution first so an importer that ships its own + // dependencies (e.g. a built-in extension that bundles a different copy + // of a package) resolves against its own, closer 'node_modules' instead + // of being redirected into the app archive. The archive stands in for + // the application's own (farthest) 'node_modules', so it must only be + // consulted once the default walk has found nothing. + let defaultResult; + let defaultError; + try { + defaultResult = await nextResolve(specifier, context); + } catch (err) { + defaultError = err; + } + + // Only accept a default resolution that lands INSIDE the application + // tree (a closer copy under 'resources/app', e.g. one bundled by a + // built-in extension). A resolution ABOVE the app root must not win + // over the archive: when the app is nested inside a larger tree (e.g. + // '@vscode/test-electron' downloads the packaged app under the repo's + // own 'node_modules'), the default node_modules walk can escape the app + // and find a stale / ABI-mismatched copy. The archive stands in for the + // application's own 'node_modules' and must take precedence over + // anything outside 'resources/app'. + if (defaultResult) { + let resolvedPath; + try { resolvedPath = normalizeDriveLetter(fileURLToPath(defaultResult.url)); } catch { resolvedPath = undefined; } + if (!resolvedPath || resolvedPath.startsWith(resourcesPath)) { + if (trace) { trace(' default -> ' + defaultResult.url + ' (in app, ACCEPT)'); } + return defaultResult; + } + if (trace) { trace(' default -> ' + defaultResult.url + ' (outside app, reject)'); } + } else if (trace) { + trace(' default -> (' + (defaultError && (defaultError.code || defaultError.message)) + ')'); + } + + // Locate the package inside the archive via its package.json (this is + // resolution-condition independent), so we can re-root resolution + // inside it below. + let packageJsonPath; + try { + packageJsonPath = asarRequire.resolve('./' + packageNameOf(specifier) + '/package.json'); + } catch { + // The package is part of neither 'resources/app' (the default + // resolution above did not land inside the app) nor the archive. + // Do NOT fall back to a copy from an outer 'node_modules' (e.g. a + // parent checkout the app is nested under): the application must + // resolve its own dependencies exclusively from its own resources. + // Surface the original resolution error so a missing/misplaced + // dependency fails loudly instead of silently loading a foreign copy. + if (trace) { trace(' archive: package "' + packageNameOf(specifier) + '" NOT in archive -> throw'); } + throw defaultError ?? new Error("Cannot find package '" + specifier + "' within the application resources"); + } + if (trace) { trace(' archive pkg.json -> ' + packageJsonPath); } + // Re-run the default ESM resolution rooted *inside* the archived + // package (via its package.json) so Node resolves the request as a + // package self-reference, applying the real 'exports'/'main' fields and + // ESM conditions ('import' over 'require'). + try { + const selfRef = await nextResolve(specifier, { ...context, parentURL: pathToFileURL(packageJsonPath).href }); + // A package without an 'exports' field does not self-reference: Node + // falls back to a 'node_modules' walk from the package dir that can + // climb *out* of the archive into an outer 'node_modules' (e.g. the + // checkout the app is nested under). Only accept a result that stays + // inside the app resources; otherwise fall back to the direct, + // escape-proof archive resolution below. + let selfRefPath; + try { selfRefPath = normalizeDriveLetter(fileURLToPath(selfRef.url)); } catch { selfRefPath = undefined; } + if (selfRefPath && selfRefPath.startsWith(resourcesPath)) { + if (trace) { trace(' self-ref -> ' + selfRef.url + ' (in app, ACCEPT)'); } + return selfRef; + } + if (trace) { trace(' self-ref -> ' + selfRef.url + ' (escaped app, reject)'); } + } catch (err) { + // Fall through to direct resolution below. + if (trace) { trace(' self-ref -> (' + (err && (err.code || err.message)) + ')'); } + } + const resolved = asarRequire.resolve('./' + specifier); + const url = pathToFileURL(resolved).href; + if (trace) { trace(' direct -> ' + url + ' (ACCEPT)'); } + return { url, shortCircuit: true }; + } else if (trace) { + trace('defer "' + specifier + '" (parent outside app resources: ' + context.parentURL + ')'); + } + } + // Defer to the next hook in the chain, which would be the // Node.js default resolve if this is the last user-specified loader. return nextResolve(specifier, context); }`; - register(`data:text/javascript;base64,${Buffer.from(jsCode).toString('base64')}`, import.meta.url); + + // Opt-in resolution tracing, off by default. Set VSCODE_ASAR_TRACE to enable: + // VSCODE_ASAR_TRACE=1 -> trace to stderr (also '"true"', '"on"', '"stderr"') + // VSCODE_ASAR_TRACE=/path/x.log -> append the trace to that file + const traceSink = process.env['VSCODE_ASAR_TRACE'] || undefined; + + register(`data:text/javascript;base64,${Buffer.from(jsCode).toString('base64')}`, import.meta.url, { + data: process.env['VSCODE_DEV'] ? {} : { + resourcesPath: `${process.resourcesPath}${sep}app`, + asarPath: `${process.resourcesPath}${sep}app${sep}node_modules.asar`, + traceSink, + } + }); } -// Prepare globals that are needed for running -globalThis._VSCODE_PRODUCT_JSON = { ...product }; -globalThis._VSCODE_PACKAGE_JSON = { ...pkg }; -globalThis._VSCODE_FILE_ROOT = import.meta.dirname; +enableASARSupport(); //#region NLS helpers diff --git a/src/bootstrap-node.ts b/src/bootstrap-node.ts index e0aa65a7589957..f07bc738e047c5 100644 --- a/src/bootstrap-node.ts +++ b/src/bootstrap-node.ts @@ -54,6 +54,72 @@ function setupCurrentWorkingDirectory(): void { setupCurrentWorkingDirectory(); +/** + * Add ASAR support to Node's CommonJS module resolution. + * + * Production builds bundle our `node_modules` into a `node_modules.asar` + * archive that sits next to the (now mostly empty) `node_modules` folder. + * Node does not look into `.asar` archives on its own, so we splice the + * archive into the lookup paths right before the real `node_modules` folder. + * + * The archive keeps the same top-level layout as `node_modules` + * (`node_modules.asar/`), so bare `require('')` calls resolve + * exactly like they did before ASAR was introduced. This keeps extensions and + * tooling that reach into `${appRoot}/node_modules.asar/` working. + * + * Note: only applies to the packaged app running on Electron (incl. + * `ELECTRON_RUN_AS_NODE` forks), never when running out of sources. + */ +function enableASARSupport(): void { + if (!process.env['ELECTRON_RUN_AS_NODE'] && !process.versions['electron']) { + return; // only on Electron / Electron-as-node + } + + if (process.env['VSCODE_DEV']) { + return; // no ASAR when running out of sources + } + + // Normalize the drive letter to lower-case for comparison. On Windows the + // path derived from `import.meta.dirname` (a file URL) can use a different + // drive-letter case than the paths Node computes for a `require` parent, so + // an exact string comparison would miss the insertion point (breaking e.g. + // `require('mkdirp')` from a module inside the archive). + const normalizeDriveLetter = (p: string): string => { + if (isWindows && p.length >= 2 && p.charCodeAt(1) === 58 /* : */) { + const code = p.charCodeAt(0); + if ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) { + return p[0].toLowerCase() + p.slice(1); + } + } + return p; + }; + + const NODE_MODULES_PATH = normalizeDriveLetter(path.join(import.meta.dirname, '../node_modules')); + + const Module = require('node:module') as typeof import('node:module') & { + _resolveLookupPaths: (request: string, parent: unknown) => string[] | null; + }; + + const originalResolveLookupPaths = Module._resolveLookupPaths; + Module._resolveLookupPaths = function (request: string, parent: unknown): string[] | null { + const paths = originalResolveLookupPaths(request, parent); + if (Array.isArray(paths)) { + for (let i = 0, len = paths.length; i < len; i++) { + if (normalizeDriveLetter(paths[i]) === NODE_MODULES_PATH) { + // Derive the archive path from the matched entry so drive-letter + // case and path separators are preserved exactly. + paths.splice(i, 0, `${paths[i]}.asar`); + break; + } + } + } + + return paths; + }; +} + +enableASARSupport(); + /** * Add support for redirecting the loading of node modules * diff --git a/src/main.ts b/src/main.ts index f70cf87ee7f98b..085290ba0d7e9a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -332,8 +332,9 @@ function configureCommandlineSwitchesSync(cliArgs: NativeParsedArgs) { // `DocumentPolicyIncludeJSCallStacksInCrashReports` - https://www.electronjs.org/docs/latest/api/web-frame-main#framecollectjavascriptcallstack-experimental // `EarlyEstablishGpuChannel` - Refs https://issues.chromium.org/issues/40208065 // `EstablishGpuChannelAsync` - Refs https://issues.chromium.org/issues/40208065 + // `GlobalShortcutsPortal` - Enables Electron's `globalShortcut` (system-wide keybindings) on Linux Wayland via the XDG global shortcuts portal (no-op elsewhere) const featuresToEnable = - `NetAdapterMaxBufSizeFeature:NetAdapterMaxBufSize/8192,DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync,${app.commandLine.getSwitchValue('enable-features')}`; + `NetAdapterMaxBufSizeFeature:NetAdapterMaxBufSize/8192,DocumentPolicyIncludeJSCallStacksInCrashReports,EarlyEstablishGpuChannel,EstablishGpuChannelAsync${process.platform === 'linux' ? ',GlobalShortcutsPortal' : ''},${app.commandLine.getSwitchValue('enable-features')}`; app.commandLine.appendSwitch('enable-features', featuresToEnable); // Following features are disabled from the runtime: diff --git a/src/vs/amdX.ts b/src/vs/amdX.ts index e96ed8908b6a8e..4f0c0528080acf 100644 --- a/src/vs/amdX.ts +++ b/src/vs/amdX.ts @@ -9,8 +9,6 @@ import { IProductConfiguration } from './base/common/product.js'; import { URI } from './base/common/uri.js'; import { generateUuid } from './base/common/uuid.js'; -export const canASAR = false; // TODO@esm: ASAR disabled in ESM - declare const window: any; declare const document: any; declare const self: any; @@ -177,9 +175,16 @@ class AMDModuleImporter { private async _nodeJSLoadScript(scriptSrc: string): Promise { try { - const fs = (await import(/* webpackIgnore: true */ /* @vite-ignore */ `${'fs'}`)).default; - const vm = (await import(/* webpackIgnore: true */ /* @vite-ignore */ `${'vm'}`)).default; + // `import('module')` is not remapped (only `fs` is), so it yields the real + // `module` builtin. We use its `createRequire` to obtain `fs`/`vm`: the ESM + // resolution hook maps `import('fs')` to the ASAR-unaware `original-fs`, but + // `scriptSrc` may point inside the `node_modules.asar` archive. The `fs` + // returned by `require` stays ASAR-aware in Electron, so it can read module + // files from within the archive. const module = (await import(/* webpackIgnore: true */ /* @vite-ignore */ `${'module'}`)).default; + const nodeRequire = module.createRequire(import.meta.url); + const fs = nodeRequire('fs'); + const vm = nodeRequire('vm'); const filePath = URI.parse(scriptSrc).fsPath; const content = fs.readFileSync(filePath).toString(); @@ -218,7 +223,7 @@ export async function importAMDNodeModule(nodeModuleName: string, pathInsideN // bit of a special case for: src/vs/workbench/services/languageDetection/browser/languageDetectionWebWorker.ts scriptSrc = nodeModulePath; } else { - const useASAR = (canASAR && isBuilt && !platform.isWeb); + const useASAR = (isBuilt && (platform.isElectron || (platform.isWebWorker && platform.hasElectronUserAgent))); const actualNodeModulesPath = (useASAR ? nodeModulesAsarPath : nodeModulesPath); const resourcePath: AppResourcePath = `${actualNodeModulesPath}/${nodeModulePath}`; scriptSrc = FileAccess.asBrowserUri(resourcePath).toString(true); @@ -231,7 +236,7 @@ export async function importAMDNodeModule(nodeModuleName: string, pathInsideN export function resolveAmdNodeModulePath(nodeModuleName: string, pathInsideNodeModule: string): string { const product = globalThis._VSCODE_PRODUCT_JSON as unknown as IProductConfiguration; const isBuilt = Boolean((product ?? globalThis.vscode?.context?.configuration()?.product)?.commit); - const useASAR = (canASAR && isBuilt && !platform.isWeb); + const useASAR = (isBuilt && (platform.isElectron || (platform.isWebWorker && platform.hasElectronUserAgent))); const nodeModulePath = `${nodeModuleName}/${pathInsideNodeModule}`; const actualNodeModulesPath = (useASAR ? nodeModulesAsarPath : nodeModulesPath); diff --git a/src/vs/base/browser/animationSync.ts b/src/vs/base/browser/animationSync.ts new file mode 100644 index 00000000000000..e962a5595aec22 --- /dev/null +++ b/src/vs/base/browser/animationSync.ts @@ -0,0 +1,66 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface ISynchronizeAnimationsOptions { + /** + * Also synchronize animations running on descendant elements (e.g. the dots + * of a spinner whose animations live on child nodes). Defaults to `false`. + */ + readonly subtree?: boolean; + + /** + * When provided, further narrows synchronization to CSS animations whose + * `animation-name` is in this set. Non-keyframe animations (e.g. transitions) + * are always skipped regardless of this option. + */ + readonly animationNames?: ReadonlySet; +} + +/** + * Phase-aligns looping CSS animations so that every animation of the same + * duration displays the same frame at the same time, regardless of when each + * one started. + * + * All CSS animations share the document's timeline, so anchoring each + * animation's `startTime` to the same origin (`0`) forces their `currentTime` + * to equal the timeline time — making identical animations run in lock-step. + * Per-element `animation-delay` offsets are preserved (they are part of each + * animation's own timing), so intentional cascades (e.g. spinner dots) still + * work while the group as a whole stays globally in phase. + * + * Unlike adjusting `animation-delay`, this re-seeks animations that are already + * running (Chromium does not reliably re-seek a running animation when its + * `animation-delay` changes). Call it whenever an animation (re)starts or + * resumes after being paused offscreen — e.g. from an `animationstart` handler + * or when an element scrolls back into view. + * + * @param element The element whose (and optionally whose descendants') CSS + * animations should be synchronized. + * @param options See {@link ISynchronizeAnimationsOptions}. + */ +export function synchronizeCSSAnimations(element: HTMLElement, options?: ISynchronizeAnimationsOptions): void { + if (typeof element.getAnimations !== 'function') { + return; // Web Animations API not available; leave animations as-is. + } + for (const animation of element.getAnimations({ subtree: options?.subtree })) { + // Only CSS keyframe animations carry an `animationName`; skip transitions + // and other Web Animations so this helper strictly aligns CSS animations. + const animationName = (animation as CSSAnimation).animationName; + if (animationName === undefined) { + continue; + } + if (options?.animationNames && !options.animationNames.has(animationName)) { + continue; + } + // Anchor to a shared origin so all animations of the same duration display + // the same frame. Guard against the rare state where startTime is not yet + // settable (e.g. an animation still in its pending/ready phase). + try { + animation.startTime = 0; + } catch { + // ignore + } + } +} diff --git a/src/vs/base/browser/markdownRenderer.ts b/src/vs/base/browser/markdownRenderer.ts index 97313c3db4d10d..03793402374458 100644 --- a/src/vs/base/browser/markdownRenderer.ts +++ b/src/vs/base/browser/markdownRenderer.ts @@ -36,6 +36,9 @@ export interface MarkdownRenderOptions { readonly actionHandler?: MarkdownActionHandler; + /** Rewrites parsed Markdown link and image destinations before sanitization. */ + readonly transformUri?: (href: string, kind: 'link' | 'image') => string; + readonly fillInIncompleteTokens?: boolean; readonly sanitizerConfig?: MarkdownSanitizerConfig; @@ -85,26 +88,28 @@ function getLinkTitle(href: string): string { return ''; } -const defaultMarkedRenderers = Object.freeze({ - image: ({ href, title, text }: marked.Tokens.Image): string => { - let dimensions: string[] = []; - let attributes: string[] = []; - if (href) { - ({ href, dimensions } = parseHrefAndDimensions(href)); - attributes.push(`src="${escapeDoubleQuotes(href)}"`); - } - if (text) { - attributes.push(`alt="${escapeDoubleQuotes(text)}"`); - } - if (title) { - attributes.push(`title="${escapeDoubleQuotes(title)}"`); - } - if (dimensions.length) { - attributes = attributes.concat(dimensions); - } - return ''; - }, +function renderImage({ href, title, text }: marked.Tokens.Image, transformUri?: (href: string) => string): string { + let dimensions: string[] = []; + let attributes: string[] = []; + if (href) { + ({ href, dimensions } = parseHrefAndDimensions(href)); + href = transformUri?.(href) ?? href; + attributes.push(`src="${escapeDoubleQuotes(href)}"`); + } + if (text) { + attributes.push(`alt="${escapeDoubleQuotes(text)}"`); + } + if (title) { + attributes.push(`title="${escapeDoubleQuotes(title)}"`); + } + if (dimensions.length) { + attributes = attributes.concat(dimensions); + } + return ''; +} +const defaultMarkedRenderers = Object.freeze({ + image: renderImage, paragraph(this: marked.Renderer, { tokens }: marked.Tokens.Paragraph): string { return `

${this.parser.parseInline(tokens)}

`; }, @@ -389,8 +394,11 @@ function rewriteRenderedLinks(markdown: IMarkdownString, options: MarkdownRender function createMarkdownRenderer(marked: marked.Marked, options: MarkdownRenderOptions, markdown: IMarkdownString): { renderer: marked.Renderer; codeBlocks: Promise<[string, HTMLElement]>[]; syncCodeBlocks: [string, HTMLElement][] } { const renderer = new marked.Renderer(options.markedOptions); - renderer.image = defaultMarkedRenderers.image; - renderer.link = defaultMarkedRenderers.link; + renderer.image = token => renderImage(token, href => options.transformUri?.(href, 'image') ?? href); + renderer.link = token => defaultMarkedRenderers.link.call(renderer, { + ...token, + href: options.transformUri?.(token.href, 'link') ?? token.href, + }); renderer.paragraph = defaultMarkedRenderers.paragraph; if (markdown.supportAlertSyntax) { @@ -816,27 +824,11 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par if (subtoken.type === 'text') { const lines = subtoken.raw.split('\n'); const lastLine = lines[lines.length - 1]; - if (lastLine.includes('`')) { - return completeCodespan(token); - } - - else if (lastLine.includes('**')) { - return completeDoublestar(token); - } - - else if (lastLine.match(/\*\w/)) { - return completeStar(token); - } - - else if (lastLine.match(/(^|\s)__\w/)) { - return completeDoubleUnderscore(token); - } - - else if (lastLine.match(/(^|\s)_\w/)) { - return completeUnderscore(token); - } - else if ( + // An incomplete link target must be completed before emphasis/codespan. The link is the + // innermost unfinished construct, so any emphasis marker (e.g. the `**` in `**[text](htt`) + // belongs to an enclosing span. Completing the emphasis first would leave the link broken. + if ( // Text with start of link target hasLinkTextAndStartOfLinkTarget(lastLine) || // This token doesn't have the link text, eg if it contains other markdown constructs that are in other subtokens. @@ -860,6 +852,26 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par return completeLinkTarget(token); } + else if (lastLine.includes('`')) { + return completeCodespan(token); + } + + else if (lastLine.includes('**')) { + return completeDoublestar(token); + } + + else if (lastLine.match(/\*\w/)) { + return completeStar(token); + } + + else if (lastLine.match(/(^|\s)__\w/)) { + return completeDoubleUnderscore(token); + } + + else if (lastLine.match(/(^|\s)_\w/)) { + return completeUnderscore(token); + } + // Contains the start of link text, and no following tokens contain the link target else if (lastLine.match(/(^|\s)\[\w*[^\]]*$/)) { return completeLinkText(token); @@ -871,7 +883,9 @@ function completeSingleLinePattern(token: marked.Tokens.Text | marked.Tokens.Par } function hasLinkTextAndStartOfLinkTarget(str: string): boolean { - return !!str.match(/(^|\s)\[.*\]\(\w*/); + // The `[` may be preceded by start-of-line, whitespace, or an emphasis/strikethrough marker + // (e.g. `**[text](htt`) so that links nested inside bold/italic/strikethrough are detected. + return !!str.match(/(^|\s|\*|_|~)\[.*\]\(\w*/); } function hasStartOfLinkTargetAndNoLinkText(str: string): boolean { diff --git a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts index 6916063459fd7d..9ae1e420207951 100644 --- a/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts +++ b/src/vs/base/browser/ui/breadcrumbs/breadcrumbsWidget.ts @@ -114,6 +114,8 @@ export class BreadcrumbsWidget { this._onDidFocusItem.dispose(); this._onDidChangeFocus.dispose(); this._domNode.remove(); + dispose(this._items); + this._items.length = 0; this._nodes.length = 0; this._freeNodes.length = 0; } @@ -156,7 +158,7 @@ export class BreadcrumbsWidget { private _style(styleElement: HTMLStyleElement, style: IBreadcrumbsWidgetStyles): void { let content = ''; if (style.breadcrumbsBackground) { - content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}`; + content += `.monaco-breadcrumbs { background-color: ${style.breadcrumbsBackground}}\n`; } if (style.breadcrumbsForeground) { content += `.monaco-breadcrumbs .monaco-breadcrumb-item { color: ${style.breadcrumbsForeground}}\n`; @@ -168,7 +170,7 @@ export class BreadcrumbsWidget { content += `.monaco-breadcrumbs .monaco-breadcrumb-item.focused.selected { color: ${style.breadcrumbsFocusAndSelectionForeground}}\n`; } if (style.breadcrumbsHoverForeground) { - content += `.monaco-breadcrumbs:not(.disabled ) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; + content += `.monaco-breadcrumbs:not(.disabled) .monaco-breadcrumb-item:hover:not(.focused):not(.selected) { color: ${style.breadcrumbsHoverForeground}}\n`; } styleElement.textContent = content; } diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index e2c68a4214ed6b..4417777cb85e97 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,13 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + /** + * Temporary escape hatch for dialogs that embed widgets whose popups mount + * at window root (outside the dialog DOM). Needed because the focus trap + * would otherwise immediately reclaim focus from context views and pickers. + * See https://github.com/microsoft/vscode/issues/323920 for removal plan. + */ + readonly isExternalFocusAllowed?: (relatedTarget: HTMLElement) => boolean; readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; @@ -484,6 +491,11 @@ export class Dialog extends Disposable { this._register(addDisposableListener(this.element, 'focusout', e => { if (!!e.relatedTarget && !!this.element) { if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) { + // Temporary: let focus escape for body-level popups. + // See https://github.com/microsoft/vscode/issues/323920 + if (this.options.isExternalFocusAllowed?.(e.relatedTarget as HTMLElement)) { + return; + } this.focusToReturn = e.relatedTarget as HTMLElement; if (e.target) { diff --git a/src/vs/base/browser/ui/list/list.ts b/src/vs/base/browser/ui/list/list.ts index 3e7cb01796786a..0d3c7f995b74d0 100644 --- a/src/vs/base/browser/ui/list/list.ts +++ b/src/vs/base/browser/ui/list/list.ts @@ -151,6 +151,10 @@ export abstract class CachedListVirtualDelegate implements ILi return this.cache.get(element) ?? this.estimateHeight(element); } + protected getCachedHeight(element: T): number | undefined { + return this.cache.get(element); + } + protected abstract estimateHeight(element: T): number; abstract getTemplateId(element: T): string; diff --git a/src/vs/base/browser/ui/list/listView.ts b/src/vs/base/browser/ui/list/listView.ts index 5c62e99faf8860..831e610665e625 100644 --- a/src/vs/base/browser/ui/list/listView.ts +++ b/src/vs/base/browser/ui/list/listView.ts @@ -1620,12 +1620,7 @@ export class ListView implements IListView { private probeDynamicHeight(index: number): number { const item = this.items[index]; - const diff = this.probeDynamicHeightForItem(item, index); - if (diff > 0) { - this.virtualDelegate.setDynamicHeight?.(item.element, item.size); - } - - return diff; + return this.probeDynamicHeightForItem(item, index); } private probeDynamicHeightForItem(item: IItem, index: number): number { @@ -1635,6 +1630,7 @@ export class ListView implements IListView { const size = item.size; item.size = newSize; item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return newSize - size; } } @@ -1660,6 +1656,7 @@ export class ListView implements IListView { } } item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); return item.size - size; } @@ -1678,12 +1675,19 @@ export class ListView implements IListView { renderer.disposeElement?.(item.element, index, row.templateData); item.lastDynamicHeightWidth = this.renderWidth; + this.publishDynamicHeight(item); row.domNode.remove(); this.cache.release(row); return item.size - size; } + private publishDynamicHeight(item: IItem): void { + if (item.size > 0) { + this.virtualDelegate.setDynamicHeight?.(item.element, item.size); + } + } + getElementDomId(index: number): string { return `${this.domId}_${index}`; } diff --git a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts index 81c247cc5961f9..14399960b8a9a2 100644 --- a/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts +++ b/src/vs/base/browser/ui/pixelSpinner/pixelSpinner.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { getWindow, h, onDidUnregisterWindow } from '../../dom.js'; +import { synchronizeCSSAnimations } from '../../animationSync.js'; import { CodeWindow } from '../../window.js'; import { IDisposable } from '../../../common/lifecycle.js'; import './pixelSpinner.css'; @@ -57,6 +58,15 @@ export function createPixelSpinner(parent?: HTMLElement, options?: IPixelSpinner const PAUSED_CLASS = 'monaco-pixel-spinner-paused'; +// Keyframes names used by the spinner variants (see pixelSpinner.css). The sync +// is scoped to these so it never disturbs unrelated animations/transitions +// (e.g. the icon cross-fade) that may run on the same subtree. +const SPINNER_ANIMATION_NAMES = new Set([ + 'monaco-pixel-spinner-dot-cycle', + 'monaco-pixel-spinner-dot-cycle-long', + 'monaco-pixel-spinner-dot-cycle-short', + 'monaco-pixel-spinner-ring-pulse', +]); const observersByWindow = new Map(); let unregisterWindowListener: IDisposable | undefined; @@ -67,6 +77,11 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi let observer = observersByWindow.get(targetWindow); if (!observer) { observer = new targetWindow.IntersectionObserver(entries => { + // Two passes so all style writes happen before any style read: the + // pause-class toggles below dirty style, and `getAnimations()` in the + // sync pass flushes it. Interleaving them would force a style recalc + // per entry instead of one for the whole batch. + const toResync: HTMLElement[] = []; for (const entry of entries) { const target = entry.target as HTMLElement; if (!target.isConnected) { @@ -74,6 +89,16 @@ function getObserverFor(targetWindow: CodeWindow): IntersectionObserver | undefi continue; } target.classList.toggle(PAUSED_CLASS, !entry.isIntersecting); + if (entry.isIntersecting) { + toResync.push(target); + } + } + // Re-sync resumed spinners to the shared timeline: while paused + // offscreen the animation froze and its startTime drifted from + // spinners that kept running. Anchor it back (now that it is running + // again) so all visible spinners display the same frame. + for (const target of toResync) { + synchronizeCSSAnimations(target, { subtree: true, animationNames: SPINNER_ANIMATION_NAMES }); } }); observersByWindow.set(targetWindow, observer); @@ -101,4 +126,3 @@ function trackSpinner(root: HTMLElement): void { root.classList.add(PAUSED_CLASS); observer.observe(root); } - diff --git a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts index 12753112b43bac..4a1e346cfd1b2f 100644 --- a/src/vs/base/browser/ui/scrollbar/scrollableElement.ts +++ b/src/vs/base/browser/ui/scrollbar/scrollableElement.ts @@ -23,6 +23,24 @@ const HIDE_TIMEOUT = 500; const SCROLL_WHEEL_SENSITIVITY = 50; const SCROLL_WHEEL_SMOOTH_SCROLL_ENABLED = true; +/** The default size (px) used when a scrollbar element does not pass an explicit size. */ +export const DEFAULT_SCROLLBAR_SIZE = 10; +let globalDefaultScrollbarSize = DEFAULT_SCROLLBAR_SIZE; +const _onDidChangeDefaultScrollbarSizeEmitter = new Emitter(); +export const onDidChangeDefaultScrollbarSize: Event = _onDidChangeDefaultScrollbarSizeEmitter.event; + +/** + * Update the default scrollbar size used by all scrollable elements that were + * created without an explicit horizontal/vertical scrollbar size option. + * Elements with explicit sizes (e.g. the editor, menus) are unaffected. + */ +export function setGlobalDefaultScrollbarSize(size: number): void { + if (size !== globalDefaultScrollbarSize) { + globalDefaultScrollbarSize = size; + _onDidChangeDefaultScrollbarSizeEmitter.fire(size); + } +} + export interface IOverviewRulerLayoutInfo { parent: HTMLElement; insertBefore: HTMLElement; @@ -270,6 +288,20 @@ export abstract class AbstractScrollableElement extends Widget { this._shouldRender = true; this._revealOnScroll = true; + + // Subscribe to global default size changes, but only for axes whose size + // was NOT explicitly provided. Elements with explicit sizes (editor, + // menus, peek, chat input, etc.) use a fixed size and must not be updated. + const hSizeExplicit = typeof options.horizontalScrollbarSize !== 'undefined'; + const vSizeExplicit = typeof options.verticalScrollbarSize !== 'undefined'; + if (!hSizeExplicit || !vSizeExplicit) { + this._register(onDidChangeDefaultScrollbarSize(newSize => { + this.updateOptions({ + ...(!hSizeExplicit ? { horizontalScrollbarSize: newSize } : {}), + ...(!vSizeExplicit ? { verticalScrollbarSize: newSize } : {}), + }); + })); + } } public override dispose(): void { @@ -747,12 +779,12 @@ function resolveOptions(opts: ScrollableElementCreationOptions): ScrollableEleme listenOnDomNode: (typeof opts.listenOnDomNode !== 'undefined' ? opts.listenOnDomNode : null), horizontal: (typeof opts.horizontal !== 'undefined' ? opts.horizontal : ScrollbarVisibility.Auto), - horizontalScrollbarSize: (typeof opts.horizontalScrollbarSize !== 'undefined' ? opts.horizontalScrollbarSize : 10), + horizontalScrollbarSize: (typeof opts.horizontalScrollbarSize !== 'undefined' ? opts.horizontalScrollbarSize : globalDefaultScrollbarSize), horizontalSliderSize: (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : 0), horizontalHasArrows: (typeof opts.horizontalHasArrows !== 'undefined' ? opts.horizontalHasArrows : false), vertical: (typeof opts.vertical !== 'undefined' ? opts.vertical : ScrollbarVisibility.Auto), - verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : 10), + verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : globalDefaultScrollbarSize), verticalHasArrows: (typeof opts.verticalHasArrows !== 'undefined' ? opts.verticalHasArrows : false), verticalSliderSize: (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : 0), diff --git a/src/vs/base/browser/ui/splitview/paneview.css b/src/vs/base/browser/ui/splitview/paneview.css index ab4d94956cd36b..b94dd9b5721b89 100644 --- a/src/vs/base/browser/ui/splitview/paneview.css +++ b/src/vs/base/browser/ui/splitview/paneview.css @@ -50,6 +50,10 @@ margin: 0 2px; } +.monaco-pane-view .pane > .pane-header.expanded > .codicon:first-of-type { + transform: translateY(1px); +} + .monaco-pane-view .pane.horizontal:not(.expanded) > .pane-header > .codicon:first-of-type { margin: 2px; } diff --git a/src/vs/base/browser/ui/tree/abstractTree.ts b/src/vs/base/browser/ui/tree/abstractTree.ts index 0baaf62849795f..5111f737d12d76 100644 --- a/src/vs/base/browser/ui/tree/abstractTree.ts +++ b/src/vs/base/browser/ui/tree/abstractTree.ts @@ -3110,6 +3110,22 @@ export abstract class AbstractTree implements IDisposable return this.view.getRelativeTop(index, stickyScrollNode?.position ?? this.stickyScrollController?.height); } + /** + * Returns the absolute top offset of an element in the tree's scroll/content + * space, or `undefined` when the element is not in the tree. Unlike + * {@link getRelativeTop}, this reads the layout height model, so it also + * resolves elements outside the rendered viewport. + */ + getElementTop(location: TRef): number | undefined { + const index = this.model.getListIndex(location); + + if (index === -1) { + return undefined; + } + + return this.view.getElementTop(index); + } + getViewState(identityProvider = this.options.identityProvider): AbstractTreeViewState { if (!identityProvider) { throw new TreeError(this._user, 'Can\'t get tree view state without an identity provider'); diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index 272dd7a9d47caf..b3b071af31b9a5 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -9,6 +9,7 @@ export interface IQuotaSnapshotData { readonly overage_count: number; readonly overage_entitlement: number; readonly overage_permitted: boolean; + readonly credits_used?: number; readonly percent_remaining: number; readonly unlimited: boolean; readonly quota_reset_at?: number; diff --git a/src/vs/base/common/managedSettings.ts b/src/vs/base/common/managedSettings.ts index 56e99e0ef0b7f1..1d5205a0b996b7 100644 --- a/src/vs/base/common/managedSettings.ts +++ b/src/vs/base/common/managedSettings.ts @@ -38,6 +38,10 @@ export interface IStrictMarketplaceSource { * * Plain-string entries (allowed by the policy schema but unnamed) are stored with * the value used as both key and value so they survive the round-trip intact. + * + * Marketplace names come from managed settings (untrusted input) and are written as object keys, + * so `__proto__` / `constructor` / `prototype` keys are skipped to avoid prototype pollution + * (mirroring the guard in the managed-settings normalizer's string-map encoder). */ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | IExtraKnownMarketplaceEntry)[] | undefined): Record | undefined { if (!entries?.length) { @@ -46,8 +50,14 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I const obj: Record = {}; for (const entry of entries) { if (typeof entry === 'string') { + if (isUnsafeMarketplaceKey(entry)) { + continue; + } obj[entry] = entry; } else { + if (isUnsafeMarketplaceKey(entry.name)) { + continue; + } const s = entry.source; const base = s.source === 'github' ? s.repo : s.url; obj[entry.name] = s.ref ? `${base}#${s.ref}` : base; @@ -55,3 +65,8 @@ export function extraKnownMarketplacesToConfigDict(entries: readonly (string | I } return obj; } + +/** Whether a marketplace name would pollute the prototype chain if used as an object key. */ +function isUnsafeMarketplaceKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} diff --git a/src/vs/base/common/platform.ts b/src/vs/base/common/platform.ts index 3013e09489b22f..d2072dea83c8b4 100644 --- a/src/vs/base/common/platform.ts +++ b/src/vs/base/common/platform.ts @@ -274,6 +274,7 @@ export const isFirefox = !!(userAgent && userAgent.indexOf('Firefox') >= 0); export const isSafari = !!(!isChrome && (userAgent && userAgent.indexOf('Safari') >= 0)); export const isEdge = !!(userAgent && userAgent.indexOf('Edg/') >= 0); export const isAndroid = !!(userAgent && userAgent.indexOf('Android') >= 0); +export const hasElectronUserAgent = !!(userAgent && userAgent.indexOf('Electron') >= 0); export function isTahoeOrNewer(osVersion: string): boolean { return parseFloat(osVersion) >= 25; diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index b628c278181c37..5af093a6519bac 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -407,6 +407,7 @@ export interface IDefaultChatAgent { readonly documentationUrl: string; readonly skusDocumentationUrl: string; + readonly optimizeUsageDocumentationUrl: string; readonly publicCodeMatchesUrl: string; readonly managePlanUrl: string; readonly upgradePlanUrl: string; diff --git a/src/vs/base/common/uriTemplate.ts b/src/vs/base/common/uriTemplate.ts index 4734dc0d9f16c7..f425f810a28e27 100644 --- a/src/vs/base/common/uriTemplate.ts +++ b/src/vs/base/common/uriTemplate.ts @@ -298,7 +298,7 @@ function pctEncode(str: string): string { ) { out += str[i]; } else { - out += '%' + chr.toString(16).toUpperCase(); + out += '%' + chr.toString(16).toUpperCase().padStart(2, '0'); } } return out; diff --git a/src/vs/base/common/yaml.ts b/src/vs/base/common/yaml.ts index af3a2802125a71..64679352573b69 100644 --- a/src/vs/base/common/yaml.ts +++ b/src/vs/base/common/yaml.ts @@ -65,8 +65,12 @@ export class MarkdownNode { const property = this.header.properties.find(p => p.key.value === name); if (property && property.value.type === 'sequence') { return property.value.items.filter(item => item.type === 'scalar').map(item => item.value); - } else if (property && property.value.type === 'scalar' && property.value.format === 'none') { - return parseCommaSeparatedList(property.value.value, 0).map(item => item.value); + } else if (property && property.value.type === 'scalar') { + if (property.value.format === 'none') { + return parseCommaSeparatedList(property.value.value, 0).map(item => item.value); + } else { + return [property.value.value]; + } } } return undefined; diff --git a/src/vs/base/parts/ipc/common/ipc.ts b/src/vs/base/parts/ipc/common/ipc.ts index c767d36a7976a9..4e3a0c3f3c5c9c 100644 --- a/src/vs/base/parts/ipc/common/ipc.ts +++ b/src/vs/base/parts/ipc/common/ipc.ts @@ -680,6 +680,8 @@ export class ChannelClient implements IChannelClient, IDisposable { const emitter = new Emitter({ onWillAddFirstListener: () => { + const handler: IHandler = (res: IRawResponse) => emitter.fire((res as IRawEventFireResponse).data); + this.handlers.set(id, handler); const doRequest = () => { this.activeRequests.add(emitter); this.sendRequest(request); @@ -706,9 +708,6 @@ export class ChannelClient implements IChannelClient, IDisposable { } }); - const handler: IHandler = (res: IRawResponse) => emitter.fire((res as IRawEventFireResponse).data); - this.handlers.set(id, handler); - return emitter.event; } diff --git a/src/vs/base/parts/ipc/test/common/ipc.test.ts b/src/vs/base/parts/ipc/test/common/ipc.test.ts index 47a88f12ab13e4..90140a90c735a1 100644 --- a/src/vs/base/parts/ipc/test/common/ipc.test.ts +++ b/src/vs/base/parts/ipc/test/common/ipc.test.ts @@ -320,6 +320,27 @@ suite('Base IPC', function () { assert.deepStrictEqual(messages, ['hello', 'world']); }); + test('listen to events (resubscribe)', async function () { + const onPong = ipcService.onPong; + const messages: string[] = []; + + const disposable1 = onPong(msg => messages.push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, []); + service.ping('hello'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + disposable1.dispose(); + + const disposable2 = onPong(msg => (messages as string[]).push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + service.ping('world'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello', 'world']); + disposable2.dispose(); + }); + test('buffers in arrays', async function () { const r = await ipcService.buffersLength([VSBuffer.alloc(2), VSBuffer.alloc(3)]); return assert.strictEqual(r, 5); @@ -443,6 +464,27 @@ suite('Base IPC', function () { assert.deepStrictEqual(messages, ['hello', 'world']); }); + test('listen to events (resubscribe)', async function () { + const onPong = ipcService.onPong; + const messages: string[] = []; + + const disposable1 = onPong(msg => messages.push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, []); + service.ping('hello'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + disposable1.dispose(); + + const disposable2 = onPong(msg => (messages as string[]).push(msg)); + await timeout(0); + assert.deepStrictEqual(messages, ['hello']); + service.ping('world'); + await timeout(0); + assert.deepStrictEqual(messages, ['hello', 'world']); + disposable2.dispose(); + }); + test('marshalling uri', async function () { const uri = URI.file('foobar'); const r = await ipcService.marshall(uri); diff --git a/src/vs/base/test/browser/markdownRenderer.test.ts b/src/vs/base/test/browser/markdownRenderer.test.ts index 3ac03201b3234d..eec544d728f9fc 100644 --- a/src/vs/base/test/browser/markdownRenderer.test.ts +++ b/src/vs/base/test/browser/markdownRenderer.test.ts @@ -52,6 +52,34 @@ suite('MarkdownRenderer', () => { assert.ok(anchor, 'expected to be preserved when scheme is allowed'); assert.strictEqual(anchor!.dataset.href, 'vscode-agent-host://my-host/path/to/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0'); }); + + test('Transforms parsed link targets without changing labels, titles, or code', () => { + const markdown = { value: '`[same](file:///same)` [a[b].ts](file:///same "file:///same") ![image](file:///same|width=10,height=20)' }; + const result = store.add(renderMarkdown(markdown, { + transformUri: href => href === 'file:///same' ? 'https://example.com/a.ts' : href, + })).element; + const anchor = result.querySelector('a'); + assert.deepStrictEqual( + { + anchorCount: result.querySelectorAll('a').length, + text: anchor?.textContent, + href: anchor?.dataset.href, + title: anchor?.title, + image: result.querySelector('img')?.src, + imageWidth: result.querySelector('img')?.getAttribute('width'), + imageHeight: result.querySelector('img')?.getAttribute('height'), + }, + { + anchorCount: 1, + text: 'a[b].ts', + href: 'https://example.com/a.ts', + title: 'file:///same', + image: 'https://example.com/a.ts', + imageWidth: '10', + imageHeight: '20', + }, + ); + }); }); suite('Images', () => { @@ -818,6 +846,26 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); + test('list with bold incomplete link target', () => { + const incomplete = `- list item one +- **[link](http://microsoft`; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + + test('ordered list with bold incomplete link target', () => { + const incomplete = `1. list item one +2. **[link](http://microsoft`; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + test('list with incomplete subitem', () => { const incomplete = `1. list item one - `; @@ -1063,7 +1111,7 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); - test.skip('incomplete link in list', () => { + test('incomplete link in list', () => { const incomplete = '- [text'; const tokens = marked.marked.lexer(incomplete); const newTokens = fillInIncompleteTokens(tokens); @@ -1072,6 +1120,24 @@ suite('MarkdownRenderer', () => { assert.deepStrictEqual(newTokens, completeTokens); }); + test('incomplete link target inside bold', () => { + const incomplete = '**[text](http://microsoft'; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + ')**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + + test('incomplete link target with arg inside bold', () => { + const incomplete = '**[text](http://microsoft.com "more text '; + const tokens = marked.marked.lexer(incomplete); + const newTokens = fillInIncompleteTokens(tokens); + + const completeTokens = marked.marked.lexer(incomplete + '")**'); + assert.deepStrictEqual(newTokens, completeTokens); + }); + test('square brace between letters', () => { const incomplete = 'a[b'; const tokens = marked.marked.lexer(incomplete); diff --git a/src/vs/base/test/browser/ui/list/listView.test.ts b/src/vs/base/test/browser/ui/list/listView.test.ts index 37426c29206370..6b936d7823f4cf 100644 --- a/src/vs/base/test/browser/ui/list/listView.test.ts +++ b/src/vs/base/test/browser/ui/list/listView.test.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; +import { CachedListVirtualDelegate, IListRenderer, IListVirtualDelegate } from '../../../../browser/ui/list/list.js'; import { ListView } from '../../../../browser/ui/list/listView.js'; import { range } from '../../../../common/arrays.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; @@ -40,4 +40,67 @@ suite('ListView', function () { listView.dispose(); assert.strictEqual(templatesCount, 0, 'all templates have been disposed'); }); + + test('publishes freshly measured dynamic heights', function () { + const element = document.createElement('div'); + element.style.height = '200px'; + element.style.width = '200px'; + document.body.appendChild(element); + + type TestElement = { height: number }; + const delegate = new class extends CachedListVirtualDelegate { + protected estimateHeight() { return 100; } + getTemplateId() { return 'template'; } + hasDynamicHeight() { return true; } + getMeasuredHeight(element: TestElement) { return this.getCachedHeight(element); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate(container) { + const content = document.createElement('div'); + container.appendChild(content); + return content; + }, + renderElement(element, _index, templateData) { templateData.style.height = `${element.height}px`; }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(element, delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(200, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => delegate.getMeasuredHeight(element)), [40, 100, 160]); + } finally { + listView.dispose(); + element.remove(); + } + }); + + test('publishes positive delegate-provided dynamic heights', function () { + type TestElement = { height: number }; + const publishedHeights = new Map(); + const delegate: IListVirtualDelegate = { + getHeight() { return 100; }, + getTemplateId() { return 'template'; }, + getDynamicHeight(element) { return element.height; }, + setDynamicHeight(element, height) { publishedHeights.set(element, height); } + }; + const renderer: IListRenderer = { + templateId: 'template', + renderTemplate() { }, + renderElement() { }, + disposeTemplate() { } + }; + + const elements: TestElement[] = [{ height: 0 }, { height: 40 }, { height: 100 }, { height: 160 }]; + const listView = new ListView(document.createElement('div'), delegate, [renderer], { supportDynamicHeights: true }); + try { + listView.layout(400, 200); + listView.splice(0, 0, elements); + assert.deepStrictEqual(elements.map(element => publishedHeights.get(element)), [undefined, 40, 100, 160]); + } finally { + listView.dispose(); + } + }); }); diff --git a/src/vs/base/test/common/uriTemplate.test.ts b/src/vs/base/test/common/uriTemplate.test.ts index 0165f0aa4a48f1..fbe0ad8d1d7310 100644 --- a/src/vs/base/test/common/uriTemplate.test.ts +++ b/src/vs/base/test/common/uriTemplate.test.ts @@ -103,6 +103,14 @@ suite('UriTemplate', () => { testResolution('{hello}', variables, 'Hello%20World%21'); }); + test('control characters are percent-encoded with two hex digits', () => { + // Code points below 0x10 must be zero-padded (e.g. %09, not %9) so the + // output is a valid percent-encoding that decodeURIComponent accepts. + testResolution('{x}', { x: 'a\tb' }, 'a%09b'); + testResolution('{x}', { x: '\n' }, '%0A'); + testResolution('{x}', { x: '\r' }, '%0D'); + }); + test('Level 2 - Reserved expansion', () => { // Test cases from RFC 6570 Section 1.2 const variables = { diff --git a/src/vs/base/test/common/yaml.test.ts b/src/vs/base/test/common/yaml.test.ts index 933dfca3e754c7..3d5257f6ca5fbd 100644 --- a/src/vs/base/test/common/yaml.test.ts +++ b/src/vs/base/test/common/yaml.test.ts @@ -1259,6 +1259,17 @@ suite('YAML Parser', () => { assert.deepStrictEqual(result.getStringArrayValue('tags'), ['foo', 'bar', 'baz']); }); + test('getStringArrayValue wraps quoted scalars in a single-element array', () => { + const input = [ + '---', + 'tags: "foo, bar"', + '---', + ].join('\n'); + const result = parseFrontMatter(input); + assert.ok(result); + assert.deepStrictEqual(result.getStringArrayValue('tags'), ['foo, bar']); + }); + }); suite('parseCommaSeparatedList', () => { diff --git a/src/vs/code/electron-browser/workbench/workbench.ts b/src/vs/code/electron-browser/workbench/workbench.ts index 767fbe08db3c00..cfb8a1ee5c228c 100644 --- a/src/vs/code/electron-browser/workbench/workbench.ts +++ b/src/vs/code/electron-browser/workbench/workbench.ts @@ -293,6 +293,9 @@ const baseUrl = new URL(`${fileUriFromPath(configuration.appRoot, { isWindows: safeProcess.platform === 'win32', scheme: 'vscode-file', fallbackAuthority: 'vscode-app' })}/out/`); globalThis._VSCODE_FILE_ROOT = baseUrl.toString(); + // Set product configuration as global (used e.g. to select the ASAR path in `amdX`) + globalThis._VSCODE_PRODUCT_JSON = { ...configuration.product }; + // Dev only: CSS import map tricks setupCSSImportMaps(configuration, baseUrl); diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 684700fc3eec95..3823d3ff535759 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { app, BrowserWindow, desktopCapturer, Details, GPUFeatureStatus, powerMonitor, protocol, screen as electronScreen, session, Session, systemPreferences, WebFrameMain } from 'electron'; +import { app, BrowserWindow, desktopCapturer, Details, globalShortcut, GPUFeatureStatus, powerMonitor, protocol, screen as electronScreen, session, Session, systemPreferences, WebFrameMain } from 'electron'; import { addUNCHostToAllowlist, disableUNCAccessRestrictions } from '../../base/node/unc.js'; import { validatedIpcMain } from '../../base/parts/ipc/electron-main/ipcMain.js'; import { hostname, release } from 'os'; @@ -64,6 +64,7 @@ import { ILifecycleMainService, LifecycleMainPhase, ShutdownReason } from '../.. import { ILoggerService, ILogService } from '../../platform/log/common/log.js'; import { IMenubarMainService, MenubarMainService } from '../../platform/menubar/electron-main/menubarMainService.js'; import { INativeHostMainService, NativeHostMainService } from '../../platform/native/electron-main/nativeHostMainService.js'; +import { GlobalKeybindingsMainService, IGlobalKeybindingsMainService } from '../../platform/globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IMeteredConnectionService } from '../../platform/meteredConnection/common/meteredConnection.js'; import { METERED_CONNECTION_CHANNEL } from '../../platform/meteredConnection/common/meteredConnectionIpc.js'; import { MeteredConnectionChannel } from '../../platform/meteredConnection/electron-main/meteredConnectionChannel.js'; @@ -129,8 +130,6 @@ import { PtyHostService } from '../../platform/terminal/node/ptyHostService.js'; import { ElectronAgentHostStarter } from '../../platform/agentHost/electron-main/electronAgentHostStarter.js'; import { AgentHostProcessManager } from '../../platform/agentHost/node/agentHostService.js'; import { NODE_REMOTE_RESOURCE_CHANNEL_NAME, NODE_REMOTE_RESOURCE_IPC_METHOD_NAME, NodeRemoteResourceResponse, NodeRemoteResourceRouter } from '../../platform/remote/common/electronRemoteResources.js'; -import { RemoteFileSystemProxyMainHandler } from '../../platform/files/electron-main/remoteFileSystemProxyMainHandler.js'; -import { REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME } from '../../platform/files/common/remoteFileSystemProxy.js'; import { Lazy } from '../../base/common/lazy.js'; import { IAuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { AuxiliaryWindowsMainService } from '../../platform/auxiliaryWindow/electron-main/auxiliaryWindowsMainService.js'; @@ -1132,6 +1131,9 @@ export class CodeApplication extends Disposable { // Native Host services.set(INativeHostMainService, new SyncDescriptor(NativeHostMainService, undefined, false /* proxied to other processes */)); + // System-wide (OS global) keybindings + services.set(IGlobalKeybindingsMainService, new SyncDescriptor(GlobalKeybindingsMainService, [globalShortcut])); + // Metered Connection const meteredConnectionService = new MeteredConnectionMainService(this.configurationService); services.set(IMeteredConnectionService, meteredConnectionService); @@ -1176,7 +1178,7 @@ export class CodeApplication extends Disposable { // `chat.agentHost.enabled` resolves to `true` there (honoring experiment // overrides + policy + web), which the main process cannot observe since // experiment overrides are never persisted to `settings.json`. - const agentHostStarter = new ElectronAgentHostStarter(this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); + const agentHostStarter = new ElectronAgentHostStarter({ machineId, sqmId, devDeviceId }, this.configurationService, this.environmentMainService, this.lifecycleMainService, this.logService); this._register(new AgentHostProcessManager(agentHostStarter, this.logService, this.loggerService)); // External terminal @@ -1311,10 +1313,6 @@ export class CodeApplication extends Disposable { mainProcessElectronServer.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel); sharedProcessClient.then(client => client.registerChannel(ipcBrowserViewGroupChannelName, browserViewGroupChannel)); - // Remote File System Proxy - const remoteFileSystemProxyHandler = disposables.add(new RemoteFileSystemProxyMainHandler(accessor.get(IWindowsMainService), mainProcessElectronServer)); - mainProcessElectronServer.registerChannel(REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME, remoteFileSystemProxyHandler); - // Signing const signChannel = ProxyChannel.fromService(accessor.get(ISignService), disposables); mainProcessElectronServer.registerChannel('sign', signChannel); diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index f19cc424373e8c..bc50d36197179f 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -470,6 +470,9 @@ export class TextAreaEditContext extends AbstractEditContext { } private _getAndroidWordAtPosition(position: Position): [string, number] { + if (position.lineNumber > this._context.viewModel.getLineCount()) { + return ['', 0]; + } const ANDROID_WORD_SEPARATORS = '`~!@#$%^&*()-=+[{]}\\|;:",.<>/?'; const lineContent = this._context.viewModel.getLineContent(position.lineNumber); const wordSeparators = getMapForWordSeparators(ANDROID_WORD_SEPARATORS, []); @@ -511,6 +514,9 @@ export class TextAreaEditContext extends AbstractEditContext { } private _getWordBeforePosition(position: Position): string { + if (position.lineNumber > this._context.viewModel.getLineCount()) { + return ''; + } const lineContent = this._context.viewModel.getLineContent(position.lineNumber); const wordSeparators = getMapForWordSeparators(this._context.configuration.options.get(EditorOption.wordSeparators), []); @@ -530,6 +536,9 @@ export class TextAreaEditContext extends AbstractEditContext { private _getCharacterBeforePosition(position: Position): string { if (position.column > 1) { + if (position.lineNumber > this._context.viewModel.getLineCount()) { + return ''; + } const lineContent = this._context.viewModel.getLineContent(position.lineNumber); const charBefore = lineContent.charAt(position.column - 2); if (!strings.isHighSurrogate(charBefore.charCodeAt(0))) { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index bd6af43721efe6..152f03cece7521 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -6,7 +6,7 @@ import { addDisposableListener, EventType, h } from '../../../../base/browser/do import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, globalTransaction, observableValue } from '../../../../base/common/observable.js'; +import { autorun, derived, globalTransaction, IObservable, observableValue } from '../../../../base/common/observable.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -68,6 +68,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< private readonly _container: HTMLElement, private readonly _overflowWidgetsDomNode: HTMLElement, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, + private readonly _optionsOverride: IObservable | undefined, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IContextKeyService _parentContextKeyService: IContextKeyService, ) { @@ -218,6 +219,7 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< const instantiationService = this._register(this._instantiationService.createChild(new ServiceCollection([IContextKeyService, this._contextKeyService]))); this._register(instantiationService.createInstance(MenuWorkbenchToolBar, this._elements.actions, MenuId.MultiDiffEditorFileToolbar, { actionRunner: this._register(new ActionRunnerWithContext(() => (this._viewModel.get()?.modifiedUri ?? this._viewModel.get()?.originalUri))), + highlightToggledItems: true, menuOptions: { shouldForwardArgs: true, }, @@ -240,9 +242,11 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< public setData(data: TemplateData | undefined): void { this._data = data; + const optionsOverride = this._optionsOverride; function updateOptions(options: IDiffEditorOptions): IDiffEditorOptions { return { ...options, + ...optionsOverride?.get(), scrollBeyondLastLine: false, hideUnchangedRegions: { enabled: true, @@ -304,6 +308,12 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this.editor.updateOptions(updateOptions(value.options ?? {})); })); } + if (optionsOverride) { + this._dataStore.add(autorun(reader => { + optionsOverride.read(reader); + this.editor.updateOptions(updateOptions(value.options ?? {})); + })); + } data.viewModel.isAlive.recomputeInitiallyAndOnChange(this._dataStore, value => { if (!value) { this.setData(undefined); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts index 01cf1197415024..5ea935f1f73773 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.ts @@ -62,6 +62,18 @@ export class MultiDiffEditorViewModel extends Disposable { }); } + public collapse(item: DocumentDiffItemViewModel): void { + transaction(tx => { + item.collapsed.set(true, tx); + }); + } + + public expand(item: DocumentDiffItemViewModel): void { + transaction(tx => { + item.collapsed.set(false, tx); + }); + } + public get contextKeys(): Record | undefined { return this.model.contextKeys; } diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts index b442a2c8397ff5..fa210709d72d0a 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.ts @@ -25,6 +25,7 @@ import { IWorkbenchUIElementFactory } from './workbenchUIElementFactory.js'; export class MultiDiffEditorWidget extends Disposable { private readonly _dimension = observableValue(this, undefined); private readonly _viewModel = observableValue(this, undefined); + private readonly _renderSideBySide = observableValue(this, undefined); private readonly _widgetImpl = derived(this, (reader) => { readHotReloadableExport(DiffEditorItemTemplate, reader); @@ -34,6 +35,7 @@ export class MultiDiffEditorWidget extends Disposable { this._dimension, this._viewModel, this._workbenchUIElementFactory, + this._renderSideBySide, )); }); @@ -67,6 +69,19 @@ export class MultiDiffEditorWidget extends Disposable { this._dimension.set(dimension, undefined); } + /** + * Overrides whether the embedded diffs render side by side (`true`) or inline + * (`false`) as editor-local state, independent of the + * `diffEditor.renderSideBySide` setting. When left unset the setting applies. + */ + public setRenderSideBySide(renderSideBySide: boolean): void { + this._renderSideBySide.set(renderSideBySide, undefined); + } + + public toggleRenderSideBySide(): void { + this._renderSideBySide.set(!(this._renderSideBySide.get() ?? true), undefined); + } + private readonly _activeControl = derived(this, (reader) => this._widgetImpl.read(reader).activeControl.read(reader)); public getActiveControl(): DiffEditorWidget | undefined { diff --git a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts index 504a91a9d2c7c8..4ac2707b50f47d 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.ts @@ -18,6 +18,7 @@ import { ITextEditorOptions } from '../../../../platform/editor/common/editor.js import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { OffsetRange } from '../../../common/core/ranges/offsetRange.js'; +import { IDiffEditorOptions } from '../../../common/config/editorOptions.js'; import { IRange } from '../../../common/core/range.js'; import { ISelection, Selection } from '../../../common/core/selection.js'; import { IDiffEditor } from '../../../common/editorCommon.js'; @@ -45,6 +46,8 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _objectPool; + private readonly _optionsOverride: IObservable; + public readonly scrollTop; public readonly scrollLeft; @@ -74,6 +77,7 @@ export class MultiDiffEditorWidgetImpl extends Disposable { private readonly _dimension: IObservable, private readonly _viewModel: IObservable, private readonly _workbenchUIElementFactory: IWorkbenchUIElementFactory, + private readonly _renderSideBySide: IObservable, @IContextKeyService private readonly _parentContextKeyService: IContextKeyService, @IInstantiationService private readonly _parentInstantiationService: IInstantiationService, ) { @@ -102,12 +106,20 @@ export class MultiDiffEditorWidgetImpl extends Disposable { h('div.placeholder@placeholder', {}, [h('div')]), ]); this._sizeObserver = this._register(new ObservableElementSizeObserver(this._element, undefined)); + this._optionsOverride = derived(this, reader => { + const renderSideBySide = this._renderSideBySide.read(reader); + // Also pin `useInlineViewWhenSpaceIsLimited` off so the toggle deterministically + // controls inline vs. side-by-side regardless of the available width. + const options: IDiffEditorOptions = renderSideBySide === undefined ? {} : { renderSideBySide, useInlineViewWhenSpaceIsLimited: false }; + return options; + }); this._objectPool = this._register(new ObjectPool((data) => { const template = this._instantiationService.createInstance( DiffEditorItemTemplate, this._scrollableElements.content, this._scrollableElements.overflowWidgetsDomNode, - this._workbenchUIElementFactory + this._workbenchUIElementFactory, + this._optionsOverride, ); template.setData(data); return template; @@ -176,6 +188,14 @@ export class MultiDiffEditorWidgetImpl extends Disposable { } })); + const ctxRenderSideBySide = this._parentContextKeyService.createKey(EditorContextKeys.multiDiffEditorRenderSideBySide.key, true); + this._register(autorun((reader) => { + const renderSideBySide = this._renderSideBySide.read(reader); + if (renderSideBySide !== undefined) { + ctxRenderSideBySide.set(renderSideBySide); + } + })); + this._register(autorun((reader) => { /** @description Update widget dimension */ const dimension = this._dimension.read(reader); diff --git a/src/vs/editor/browser/widget/multiDiffEditor/style.css b/src/vs/editor/browser/widget/multiDiffEditor/style.css index 57e2ab568cd6a1..595eba2d6407b4 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/style.css +++ b/src/vs/editor/browser/widget/multiDiffEditor/style.css @@ -89,17 +89,20 @@ .file-path { display: flex; - flex: 1; + flex: 1 1 auto; min-width: 0; + overflow: hidden; .title { font-size: 14px; line-height: 22px; + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; &.original { - flex: 1; - min-width: 0; - text-overflow: ellipsis; + flex: 1 1 auto; } } @@ -123,10 +126,15 @@ color: v ar(--vscode-gitDecoration-addedResourceForeground); } */ + + &:not(.added):not(.deleted):not(.renamed) { + display: none; + } } } .actions { + flex: 0 0 auto; padding: 0 8px; } } diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index 3085801286c06f..9995d25075bab4 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -4492,6 +4492,12 @@ export interface IInlineSuggestOptions { showLongDistanceHint?: boolean; + /** + * Controls how many lines of surrounding context are shown above and below the target line + * in the long distance inline suggestion hint preview. `0` shows only the target line. + */ + longDistanceHintContextLineCount?: number; + /** * @internal */ @@ -4551,6 +4557,7 @@ class InlineEditorSuggest extends BaseEditorOption('isEmbeddedDiffEditor', false, nls.localize('isEmbeddedDiffEditor', "Whether the context is an embedded diff editor")); export const inMultiDiffEditor = new RawContextKey('inMultiDiffEditor', false, nls.localize('inMultiDiffEditor', "Whether the context is a multi diff editor")); export const multiDiffEditorAllCollapsed = new RawContextKey('multiDiffEditorAllCollapsed', undefined, nls.localize('multiDiffEditorAllCollapsed', "Whether all files in multi diff editor are collapsed")); + export const multiDiffEditorRenderSideBySide = new RawContextKey('multiDiffEditorRenderSideBySide', true, nls.localize('multiDiffEditorRenderSideBySide', "Whether the multi diff editor renders diffs side by side instead of inline")); export const hasChanges = new RawContextKey('diffEditorHasChanges', false, nls.localize('diffEditorHasChanges', "Whether the diff editor has changes")); export const comparingMovedCode = new RawContextKey('comparingMovedCode', false, nls.localize('comparingMovedCode', "Whether a moved code block is selected for comparison")); export const accessibleDiffViewerVisible = new RawContextKey('accessibleDiffViewerVisible', false, nls.localize('accessibleDiffViewerVisible', "Whether the accessible diff viewer is visible")); diff --git a/src/vs/editor/common/languages.ts b/src/vs/editor/common/languages.ts index 33e90ab7f7572a..44ae1380e4ba61 100644 --- a/src/vs/editor/common/languages.ts +++ b/src/vs/editor/common/languages.ts @@ -404,7 +404,6 @@ export namespace CompletionItemKinds { byKind.set(CompletionItemKind.Value, Codicon.symbolValue); byKind.set(CompletionItemKind.Enum, Codicon.symbolEnum); byKind.set(CompletionItemKind.Constant, Codicon.symbolConstant); - byKind.set(CompletionItemKind.Enum, Codicon.symbolEnum); byKind.set(CompletionItemKind.EnumMember, Codicon.symbolEnumMember); byKind.set(CompletionItemKind.Keyword, Codicon.symbolKeyword); byKind.set(CompletionItemKind.Snippet, Codicon.symbolSnippet); @@ -1139,6 +1138,7 @@ export type LifetimeSummary = { editKind: string | undefined; longDistanceHintVisible?: boolean; longDistanceHintDistance?: number; + isForAnotherDocument?: boolean; }; export interface CodeAction { diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts index 6c0744229c754f..57189a4fbca6a1 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerParticipantUtils.ts @@ -11,7 +11,7 @@ import { DocumentColorProvider, IColorInformation } from '../../../common/langua import { ITextModel, TrackedRangeStickiness } from '../../../common/model.js'; import { getColorPresentations } from './color.js'; import { ColorPickerModel } from './colorPickerModel.js'; -import { Range } from '../../../common/core/range.js'; +import { IRange, Range } from '../../../common/core/range.js'; export const enum ColorPickerWidgetType { Hover = 'hover', @@ -42,16 +42,20 @@ export async function createColorHover(editorModel: ITextModel, colorInfo: IColo }; } -export function updateEditorModel(editor: IActiveCodeEditor, range: Range, model: ColorPickerModel): Range { +export function updateEditorModel(editor: IActiveCodeEditor, range: Range, model: ColorPickerModel, insertionRanges?: IRange[]): Range { const textEdits: ISingleEditOperation[] = []; const edit = model.presentation.textEdit ?? { range, text: model.presentation.label, forceMoveMarkers: false }; - textEdits.push(edit); if (model.presentation.additionalTextEdits) { textEdits.push(...model.presentation.additionalTextEdits); } const replaceRange = Range.lift(edit.range); const trackedRange = editor.getModel()._setTrackedRange(null, replaceRange, TrackedRangeStickiness.GrowsOnlyWhenTypingAfter); + if (insertionRanges) { + textEdits.push(...insertionRanges.map(insertionRange => ({ range: insertionRange, text: edit.text, forceMoveMarkers: false }))); + } else { + textEdits.push(edit); + } editor.executeEdits('colorpicker', textEdits); editor.pushUndoStop(); return editor.getModel()._getTrackedRange(trackedRange) ?? replaceRange; diff --git a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts index 8e66ff8b1b21a9..a7b878c775ca64 100644 --- a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts +++ b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerParticipant.ts @@ -16,7 +16,7 @@ import { ColorDetector } from '../colorDetector.js'; import { ColorPickerModel } from '../colorPickerModel.js'; import { BaseColor, ColorPickerWidgetType, createColorHover, updateColorPresentations, updateEditorModel } from '../colorPickerParticipantUtils.js'; import { ColorPickerWidget } from '../colorPickerWidget.js'; -import { Range } from '../../../../common/core/range.js'; +import { IRange, Range } from '../../../../common/core/range.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { Dimension } from '../../../../../base/browser/dom.js'; @@ -107,7 +107,7 @@ export class StandaloneColorPickerParticipant { return { colorHover, foundInEditor }; } - public async updateEditorModel(colorHoverData: StandaloneColorPickerHover): Promise { + public async updateEditorModel(colorHoverData: StandaloneColorPickerHover, insertionRanges?: IRange[]): Promise { if (!this._editor.hasModel()) { return; } @@ -115,7 +115,7 @@ export class StandaloneColorPickerParticipant { let range = new Range(colorHoverData.range.startLineNumber, colorHoverData.range.startColumn, colorHoverData.range.endLineNumber, colorHoverData.range.endColumn); if (this._color) { await updateColorPresentations(this._editor.getModel(), colorPickerModel, this._color, range, colorHoverData); - range = updateEditorModel(this._editor, range, colorPickerModel); + range = updateEditorModel(this._editor, range, colorPickerModel, insertionRanges); } } diff --git a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts index f15557441bdf67..6d2104a88dfcca 100644 --- a/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts +++ b/src/vs/editor/contrib/colorPicker/browser/standaloneColorPicker/standaloneColorPickerWidget.ts @@ -47,6 +47,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW private _body: HTMLElement = document.createElement('div'); private _colorHover: StandaloneColorPickerHover | null = null; private _selectionSetInEditor: boolean = false; + private _selections: IRange[]; private readonly _onResult = this._register(new Emitter()); public readonly onResult = this._onResult.event; @@ -69,6 +70,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW this._standaloneColorPickerParticipant = _instantiationService.createInstance(StandaloneColorPickerParticipant, this._editor); this._position = this._editor._getViewModel()?.getPrimaryCursorState().modelState.position; const editorSelection = this._editor.getSelection(); + this._selections = this._editor.getSelections() ?? []; const selection = editorSelection ? { startLineNumber: editorSelection.startLineNumber, @@ -91,6 +93,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW } else { this._selectionSetInEditor = false; } + this._selections = this._editor.getSelections() ?? []; })); this._register(this._editor.onMouseMove((e) => { const classList = e.target.element?.classList; @@ -108,7 +111,7 @@ export class StandaloneColorPickerWidget extends Disposable implements IContentW public updateEditor() { if (this._colorHover) { - this._standaloneColorPickerParticipant.updateEditorModel(this._colorHover); + this._standaloneColorPickerParticipant.updateEditorModel(this._colorHover, this._selections); } } diff --git a/src/vs/editor/contrib/hover/browser/contentHoverController.ts b/src/vs/editor/contrib/hover/browser/contentHoverController.ts index ba0e7d0b161937..d675e1de6296ee 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverController.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverController.ts @@ -151,7 +151,7 @@ export class ContentHoverController extends Disposable implements IEditorContrib } private _isMouseOnContentHoverWidget(mouseEvent: IPartialEditorMouseEvent): boolean { - if (!this._contentWidget) { + if (!this._contentWidget || !this._contentWidget.getDomNode().isConnected) { return false; } return isMousePositionWithinElement(this._contentWidget.getDomNode(), mouseEvent.event.posx, mouseEvent.event.posy); @@ -224,6 +224,12 @@ export class ContentHoverController extends Disposable implements IEditorContrib if (this._ignoreMouseEvents) { return; } + // When the user is dragging to select text (mouse down started outside the hover widget), + // hide the hover and suppress any new hover computation to avoid covering the selection. + if (this._isMouseDown && !this._shouldKeepHoverWidgetVisible(mouseEvent)) { + this._cancelSchedulerAndHide(); + return; + } this._mouseMoveEvent = mouseEvent; const shouldKeepCurrentHover = this._shouldKeepCurrentHover(mouseEvent); if (shouldKeepCurrentHover) { diff --git a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts index 91f045b6e6ff20..85f4edb6ad3193 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverRendered.ts @@ -226,6 +226,7 @@ class RenderedContentHoverParts extends Disposable { }); private readonly _renderedParts: IRenderedContentHoverPartOrStatusBar[] = []; + private readonly _perPartDisposables = new Map(); private readonly _fragment: DocumentFragment; private readonly _context: IEditorHoverContext; @@ -320,29 +321,42 @@ class RenderedContentHoverParts extends Disposable { } private _registerListenersOnRenderedParts(): IDisposable { - const disposables = new DisposableStore(); + // Create per-part disposables so that when an individual rendered part is + // updated we can dispose its listeners and copy button without affecting + // the others. this._renderedParts.forEach((renderedPart: IRenderedContentHoverPartOrStatusBar, index: number) => { - const element = renderedPart.hoverElement; - element.tabIndex = 0; - disposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_IN, (event: Event) => { - event.stopPropagation(); - this._focusedHoverPartIndex = index; - })); - disposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_OUT, (event: Event) => { - event.stopPropagation(); - this._focusedHoverPartIndex = -1; - })); - // Add copy button for marker hovers - if (renderedPart.type === 'hoverPart' && !(renderedPart.hoverPart instanceof ColorHover) && !renderedPart.participant.hideCopyButton) { - disposables.add(new HoverCopyButton( - element, - () => renderedPart.participant.getAccessibleContent(renderedPart.hoverPart), - this._clipboardService, - this._hoverService - )); + this._createListenersForPart(index, renderedPart); + }); + return toDisposable(() => { + for (const d of this._perPartDisposables.values()) { + d.dispose(); } + this._perPartDisposables.clear(); }); - return disposables; + } + + private _createListenersForPart(index: number, renderedPart: IRenderedContentHoverPartOrStatusBar): void { + const partDisposables = new DisposableStore(); + const element = renderedPart.hoverElement; + element.tabIndex = 0; + partDisposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_IN, (event: Event) => { + event.stopPropagation(); + this._focusedHoverPartIndex = index; + })); + partDisposables.add(dom.addDisposableListener(element, dom.EventType.FOCUS_OUT, (event: Event) => { + event.stopPropagation(); + this._focusedHoverPartIndex = -1; + })); + // Add copy button for marker hovers + if (renderedPart.type === 'hoverPart' && !(renderedPart.hoverPart instanceof ColorHover) && !renderedPart.participant.hideCopyButton) { + partDisposables.add(new HoverCopyButton( + element, + () => renderedPart.participant.getAccessibleContent(renderedPart.hoverPart), + this._clipboardService, + this._hoverService + )); + } + this._perPartDisposables.set(index, partDisposables); } private _updateMarkdownAndColorParticipantInfo(participants: IEditorHoverParticipant[]) { @@ -409,12 +423,20 @@ class RenderedContentHoverParts extends Disposable { if (!renderedPart) { continue; } + // Dispose any listeners/copy button for the previous part at this index + const prevDisposable = this._perPartDisposables.get(i); + if (prevDisposable) { + prevDisposable.dispose(); + this._perPartDisposables.delete(i); + } this._renderedParts[i] = { type: 'hoverPart', participant: this._markdownHoverParticipant, hoverPart: renderedPart.hoverPart, hoverElement: renderedPart.hoverElement, }; + // Recreate listeners and copy button for the updated part. + this._createListenersForPart(i, this._renderedParts[i]); } if (focus) { if (index >= 0) { diff --git a/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts b/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts index 1fa7ff1c38f1f9..c5bb38b0093d64 100644 --- a/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts +++ b/src/vs/editor/contrib/hover/browser/contentHoverWidgetWrapper.ts @@ -251,6 +251,9 @@ export class ContentHoverWidgetWrapper extends Disposable implements IHoverWidge if (isContentWidgetResizing) { return true; } + if (this._isMouseOnCodeActionWidget(mouseEvent)) { + return true; + } const anchorCandidates: HoverAnchor[] = this._findHoverAnchorCandidates(mouseEvent); const anchorCandidatesExist = anchorCandidates.length > 0; if (!anchorCandidatesExist) { @@ -295,6 +298,14 @@ export class ContentHoverWidgetWrapper extends Disposable implements IHoverWidge return anchorCandidates; } + private _isMouseOnCodeActionWidget(mouseEvent: IEditorMouseEvent): boolean { + const target = mouseEvent.event.browserEvent.target; + if (target instanceof Element && !!target.closest('.action-widget')) { + return true; + } + return false; + } + private _onMouseLeave(e: MouseEvent): void { const editorDomNode = this._editor.getDomNode(); const isMousePositionOutsideOfEditor = !editorDomNode || !isMousePositionWithinElement(editorDomNode, e.x, e.y); diff --git a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts index d76df671aa8c81..abc6f9cd7157dd 100644 --- a/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markerHoverParticipant.ts @@ -116,7 +116,11 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant `${basename(related.resource)}(${related.startLineNumber}, ${related.startColumn}): ${related.message}`).join('\n') + : undefined; + return [marker.message, relatedInformation].filter(value => !!value).join('\n'); } private _renderMarkerHover(markerHover: MarkerHover): IRenderedHoverPart { @@ -284,9 +288,6 @@ export class MarkerHoverParticipant implements IEditorHoverParticipant new InlineCompletionViewData(-1, -1, -1, -1, -1, -1, -1, true); function getViewData(inlineEdit: InlineEditWithChanges, stringChanges: { originalRange: Range; modifiedRange: Range; original: string; modified: string }[], textModel: ITextModel) { if (!inlineEdit.edit) { - return emptyViewData; + return createEmptyViewData(); } const cursorPosition = inlineEdit.cursorPosition; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts index e5712352eff21c..0cb3a78cc57a20 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViewInterface.ts @@ -48,6 +48,11 @@ export class InlineCompletionViewData { public longDistanceHintVisible: boolean | undefined = undefined; public longDistanceHintDistance: number | undefined = undefined; + /** + * Whether the suggestion targets a different text model (URI) than the active editor's, + * i.e. a cross-document Next Edit Suggestion. `undefined` for inline completions / ghost text. + */ + public isForAnotherDocument: boolean | undefined = undefined; constructor( public readonly cursorColumnDistance: number, @@ -76,7 +81,8 @@ export class InlineCompletionViewData { disjointReplacements: this.disjointReplacements, sameShapeReplacements: this.sameShapeReplacements, longDistanceHintVisible: this.longDistanceHintVisible, - longDistanceHintDistance: this.longDistanceHintDistance + longDistanceHintDistance: this.longDistanceHintDistance, + isForAnotherDocument: this.isForAnotherDocument }; } } diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts index 17d310d950cc9e..e56701eb696d29 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/inlineEditsInsertionView.ts @@ -54,27 +54,35 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits private readonly _trimVertically = derived(this, reader => { const state = this._state.read(reader); - const text = state?.text; - if (!text || text.trim() === '') { - return { topOffset: 0, bottomOffset: 0, linesTop: 0, linesBottom: 0 }; + if (!state) { + return { topOffset: 0, contentHeight: 0, linesTop: 0, linesBottom: 0 }; } - // Adjust for leading/trailing newlines + const text = state.text; const lineHeight = this._editor.getLineHeightForPosition(new Position(state.lineNumber, 1)); const eol = this._editor.getModel()!.getEOL(); + const lineCount = text.split(eol).length; + + // Count leading/trailing blank lines so the overlay can be trimmed to the actual inserted content. let linesTop = 0; let linesBottom = 0; + if (text.trim() !== '') { + let i = 0; + for (; i < text.length && text.startsWith(eol, i); i += eol.length) { + linesTop += 1; + } - let i = 0; - for (; i < text.length && text.startsWith(eol, i); i += eol.length) { - linesTop += 1; - } - - for (let j = text.length; j > i && text.endsWith(eol, j); j -= eol.length) { - linesBottom += 1; + for (let j = text.length; j > i && text.endsWith(eol, j); j -= eol.length) { + linesBottom += 1; + } } - return { topOffset: linesTop * lineHeight, bottomOffset: linesBottom * lineHeight, linesTop, linesBottom }; + return { + topOffset: linesTop * lineHeight, + contentHeight: (lineCount - linesTop - linesBottom) * lineHeight, + linesTop, + linesBottom, + }; }); private readonly _maxPrefixTrim = derived(this, reader => { @@ -240,10 +248,14 @@ export class InlineEditsInsertionView extends Disposable implements IInlineEdits return null; } - const { topOffset: topTrim, bottomOffset: bottomTrim } = this._trimVertically.read(reader); + const { topOffset: topTrim, contentHeight: height } = this._trimVertically.read(reader); const scrollTop = this._editorObs.scrollTop.read(reader); - const height = this._ghostTextView.height.read(reader) - topTrim - bottomTrim; + // Derive the overlay height synchronously from the model (via _trimVertically) rather than the + // asynchronously measured ghost text view zone height, which is transiently just a single line while + // the view zone is (re)created. Because it uses the same line height and line accounting as the trims, + // top/height/bottom stay consistent and height is always positive: leading and trailing blank lines + // can never cover every inserted line. const top = this._editor.getTopForLineNumber(state.lineNumber) - scrollTop + topTrim; const bottom = top + height; diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts index fa35b11b59e6ab..5ef5f0f38d03cb 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/inlineEditsLongDistanceHint.ts @@ -301,7 +301,11 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd } const { previewEditorMargin, widgetPadding, widgetBorder, lowerBarHeight } = layoutConstants; - const maxWidgetWidth = Math.min(position === 'overlay' ? MAX_WIDGET_WIDTH.OVERLAY : MAX_WIDGET_WIDTH.EMPTY_SPACE, previewEditorContentLayout.maxEditorWidth + previewEditorMargin + widgetPadding); + const maxWidgetWidthUpperBound = position === 'overlay' ? MAX_WIDGET_WIDTH.OVERLAY : MAX_WIDGET_WIDTH.EMPTY_SPACE; + // Honor the same `minWidgetWidth` the placement logic guarantees, so a short/empty preview line + // (e.g. a jump onto an empty line) can't collapse the widget below its reserved space. + const contentBasedWidgetWidth = previewEditorContentLayout.maxEditorWidth + previewEditorMargin + widgetPadding; + const maxWidgetWidth = Math.min(maxWidgetWidthUpperBound, Math.max(contentBasedWidgetWidth, layoutConstants.minWidgetWidth)); const layout = distributeFlexBoxLayout(rectAvailableSpace.width, { spaceBefore: { min: 0, max: 10, priority: 1 }, @@ -413,9 +417,9 @@ export class InlineEditsLongDistanceHint extends Disposable implements IInlineEd } // Check if this is a cross-file edit - const currentUri = this._editorObs.model.read(reader)?.uri; + const currentModel = this._editorObs.model.read(reader); const targetUri = viewState.target.uri; - const isCrossFileEdit = targetUri && (!currentUri || targetUri.toString() !== currentUri.toString()); + const isCrossFileEdit = !currentModel || !viewState.target.targets(currentModel); if (isCrossFileEdit) { // For cross-file edits, show target filename instead of outline diff --git a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts index b4df474593bbc8..843e3bd64bcdcc 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/view/inlineEdits/inlineEditsViews/longDistanceHint/longDistancePreviewEditor.ts @@ -9,6 +9,7 @@ import { clamp } from '../../../../../../../../base/common/numbers.js'; import { IObservable, derived, constObservable, IReader, autorun, observableValue } from '../../../../../../../../base/common/observable.js'; import { IInstantiationService } from '../../../../../../../../platform/instantiation/common/instantiation.js'; import { ICodeEditor } from '../../../../../../../browser/editorBrowser.js'; +import { EditorOption } from '../../../../../../../common/config/editorOptions.js'; import { ObservableCodeEditor, observableCodeEditor } from '../../../../../../../browser/observableCodeEditor.js'; import { EmbeddedCodeEditorWidget } from '../../../../../../../browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { IDimension } from '../../../../../../../common/core/2d/dimension.js'; @@ -38,6 +39,17 @@ export interface ILongDistancePreviewProps { target: TextModelValueReference; } +/** + * Widens the previewed range around `targetLineNumber` by `contextLineCount` lines on each side, + * clamped to the model bounds. With `contextLineCount === 0` this returns just the target line. + */ +function expandLineRangeWithContext(targetLineNumber: number, contextLineCount: number, lineCount: number): LineRange { + const clampedTarget = Math.max(1, Math.min(lineCount, targetLineNumber)); + const startLineNumber = Math.max(1, clampedTarget - contextLineCount); + const endLineNumberExclusive = Math.min(lineCount + 1, clampedTarget + contextLineCount + 1); + return new LineRange(startLineNumber, endLineNumberExclusive); +} + export class LongDistancePreviewEditor extends Disposable { public readonly previewEditor; private readonly _previewEditorObs; @@ -107,7 +119,7 @@ export class LongDistancePreviewEditor extends Disposable { return; } // Ensure there is enough space to the left of the line number for the gutter indicator to fits. - const lineNumberDigets = state.visibleLineRange.startLineNumber.toString().length; + const lineNumberDigets = (state.visibleLineRange.endLineNumberExclusive - 1).toString().length; this.previewEditor.updateOptions({ lineNumbersMinChars: lineNumberDigets + 1 }); })); @@ -121,7 +133,7 @@ export class LongDistancePreviewEditor extends Disposable { if (!props) { return undefined; } return new InlineEditsGutterIndicatorData( props.inlineSuggestInfo, - LineRange.ofLength(state.visibleLineRange.startLineNumber, 1), + LineRange.ofLength(state.targetLineNumber, 1), props.model, undefined, ); @@ -137,6 +149,7 @@ export class LongDistancePreviewEditor extends Disposable { private readonly _state = derived<{ mode: 'original' | 'modified'; + targetLineNumber: number; visibleLineRange: LineRange; textModel: TextModelValueReference | undefined; diff: DetailedLineRangeMapping[]; @@ -147,18 +160,18 @@ export class LongDistancePreviewEditor extends Disposable { } let mode: 'original' | 'modified'; - let visibleRange: LineRange; + let targetLineNumber: number; if (props.nextCursorPosition !== null) { mode = 'original'; - visibleRange = LineRange.ofLength(props.nextCursorPosition.lineNumber, 1); + targetLineNumber = props.nextCursorPosition.lineNumber; } else { if (props.diff[0].innerChanges?.every(c => c.modifiedRange.isEmpty())) { mode = 'original'; - visibleRange = LineRange.ofLength(props.diff[0].original.startLineNumber, 1); + targetLineNumber = props.diff[0].original.startLineNumber; } else { mode = 'modified'; - visibleRange = LineRange.ofLength(props.diff[0].modified.startLineNumber, 1); + targetLineNumber = props.diff[0].modified.startLineNumber; } } @@ -166,9 +179,15 @@ export class LongDistancePreviewEditor extends Disposable { ? TextModelValueReference.snapshot(this._previewTextModel) : props.target; + // Optionally widen the previewed range with surrounding context lines (e.g. when the target line is empty). + const contextLineCount = this._parentEditorObs.getOption(EditorOption.inlineSuggest).read(reader).edits.longDistanceHintContextLineCount; + const displayModel = mode === 'modified' ? this._previewTextModel : props.target.dangerouslyGetUnderlyingModel(); + const visibleLineRange = expandLineRangeWithContext(targetLineNumber, contextLineCount, displayModel.getLineCount()); + return { mode, - visibleLineRange: visibleRange, + targetLineNumber, + visibleLineRange, textModel, diff: props.diff, }; @@ -250,8 +269,8 @@ export class LongDistancePreviewEditor extends Disposable { return constObservable(null); } - const previewEditorHeight = this._previewEditorObs.observeLineHeightForLine(viewState.visibleLineRange.startLineNumber); - return previewEditorHeight; + return this._previewEditorObs.observeLineHeightsForLineRange(viewState.visibleLineRange) + .map(heights => heights.reduce((sum, height) => sum + height, 0)); }).flatten(); private _getHorizontalContentRangeInPreviewEditorToShow(editor: ICodeEditor, reader: IReader) { @@ -403,5 +422,5 @@ function growUntilVariableBoundaries(textModel: ITextModel, range: Range, maxGro endColumn++; } - return new Range(startPosition.lineNumber, startPosition.column, endPosition.lineNumber, endColumn + 1); + return new Range(startPosition.lineNumber, startColumn, endPosition.lineNumber, endColumn + 1); } diff --git a/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts b/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts index 0b1493d16b7104..623b9b3938829b 100644 --- a/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts +++ b/src/vs/editor/contrib/insertFinalNewLine/browser/insertFinalNewLineCommand.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as strings from '../../../../base/common/strings.js'; import { EditOperation, ISingleEditOperation } from '../../../common/core/editOperation.js'; import { Position } from '../../../common/core/position.js'; import { Selection } from '../../../common/core/selection.js'; @@ -40,13 +39,6 @@ export class InsertFinalNewLineCommand implements ICommand { */ export function insertFinalNewLine(model: ITextModel): ISingleEditOperation | undefined { const lineCount = model.getLineCount(); - const lastLine = model.getLineContent(lineCount); - const lastLineIsEmptyOrWhitespace = strings.lastNonWhitespaceIndex(lastLine) === -1; - - if (!lineCount || lastLineIsEmptyOrWhitespace) { - return; - } - return EditOperation.insert( new Position(lineCount, model.getLineMaxColumn(lineCount)), model.getEOL() diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts index 2a7c1cf5b1b385..d507ef01fc7435 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts @@ -162,7 +162,11 @@ export class StickyScrollWidget extends Disposable implements IOverlayWidget { } let totalHeight = 0; for (let i = 0; i < candidateLineNumbers.length; i++) { - totalHeight += this._editor.getLineHeightForPosition(new Position(candidateLineNumbers[i], 1)); + const position = new Position(candidateLineNumbers[i], 1); + const viewModel = this._editor._getViewModel(); + if (viewModel && position.lineNumber <= viewModel.getLineCount()) { + totalHeight += this._editor.getLineHeightForPosition(new Position(candidateLineNumbers[i], 1)); + } } if (totalHeight === 0) { return { lineNumbers: [], lastLineRelativePosition: 0 }; diff --git a/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts b/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts index c162a282476eb0..f545a46aa8edb5 100644 --- a/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts +++ b/src/vs/editor/contrib/wordOperations/test/browser/wordOperations.test.ts @@ -775,7 +775,7 @@ suite('WordOperations', () => { }); test('deleteWordRight - issue #832', () => { - const EXPECTED = ' |/*| Just| some| text| a|+=| 3| +|5|-|3| */| |'; + const EXPECTED = ' |/*| |Just| |some| |text| |a|+=| |3| |+|5|-|3| |*/| |'; const [text,] = deserializePipePositions(EXPECTED); const actualStops = testRepeatedActionAndExtractPositions( text, diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index d93410d667a91d..1f86bd9aa01b44 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -7794,6 +7794,7 @@ declare namespace monaco.languages { editKind: string | undefined; longDistanceHintVisible?: boolean; longDistanceHintDistance?: number; + isForAnotherDocument?: boolean; }; export interface CodeAction { diff --git a/src/vs/platform/accessibility/browser/accessibleView.ts b/src/vs/platform/accessibility/browser/accessibleView.ts index 109b4d8703aa86..9461288c8811d1 100644 --- a/src/vs/platform/accessibility/browser/accessibleView.ts +++ b/src/vs/platform/accessibility/browser/accessibleView.ts @@ -47,7 +47,9 @@ export const enum AccessibleViewProviderId { OutputFindHelp = 'outputFindHelp', ProblemsFilterHelp = 'problemsFilterHelp', SessionsChat = 'sessionsChat', + SessionsChanges = 'sessionsChanges', Survey = 'survey', + Automations = 'automations', } export const enum AccessibleViewType { diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 38c73aa8949d72..3e4e7491b9e39f 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -27,6 +27,7 @@ import { localize } from '../../../nls.js'; import { IContextViewService } from '../../contextview/browser/contextView.js'; import { IKeybindingService } from '../../keybinding/common/keybinding.js'; import { IOpenerService } from '../../opener/common/opener.js'; +import { Link } from '../../opener/browser/link.js'; import { defaultListStyles } from '../../theme/browser/defaultStyles.js'; import { asCssVariable } from '../../theme/common/colorRegistry.js'; import { ILayoutService } from '../../layout/browser/layoutService.js'; @@ -491,6 +492,22 @@ function getKeyboardNavigationLabel(item: IActionListItem): string | undef return undefined; } +/** + * A "Learn more" style link rendered inline in the action list header banner. + */ +export interface IActionListHeaderLink { + /** Visible link text (e.g. "Learn more"). Should be localized. */ + readonly label: string; + /** Target opened via the opener service when the link is activated. */ + readonly uri: URI; +} + +export interface IActionListCloseAnimation { + readonly className: string; + readonly duration: number; + readonly requiredAncestorClasses?: readonly string[]; +} + /** * Options for configuring the action list. */ @@ -600,10 +617,22 @@ export interface IActionListOptions { */ readonly headerIcon?: ThemeIcon; + /** Optional "Learn more" link rendered inline after {@link headerText}, opened via the opener service. */ + readonly headerLink?: IActionListHeaderLink; + + /** Optional dismiss ("x") button on the header banner; invoked on click, and the banner is removed. */ + readonly headerDismiss?: () => void; + /** * Optional CSS class name added to the action list container, for scoped styling. */ readonly className?: string; + + /** + * Optional CSS class and duration used to animate the containing action widget + * before the context view is hidden. + */ + readonly closeAnimation?: IActionListCloseAnimation; } /** @@ -639,7 +668,7 @@ export class ActionListWidget extends Disposable { private readonly _filterInput: HTMLInputElement | undefined; private readonly _filterContainer: HTMLElement | undefined; private readonly _footerContainer: HTMLElement | undefined; - private readonly _headerContainer: HTMLElement | undefined; + private _headerContainer: HTMLElement | undefined; private readonly _filterCts = this._register(new MutableDisposable()); private readonly _groupTitleByIndex = new Map(); @@ -838,6 +867,41 @@ export class ActionListWidget extends Disposable { } const text = dom.append(this._headerContainer, dom.$('span.action-list-header-text')); text.textContent = this._options.headerText; + + if (this._options.headerLink) { + const { label, uri } = this._options.headerLink; + // Trailing space so the link reads as a continuation of the banner text. + text.textContent += ' '; + this._register(this._instantiationService.createInstance(Link, text, { label, href: uri.toString(true) }, {})); + } + + if (this._options.headerDismiss) { + const onDismiss = this._options.headerDismiss; + const dismissButton = dom.append(this._headerContainer, dom.$('span.action-list-header-dismiss')); + dismissButton.appendChild(dom.$(ThemeIcon.asCSSSelector(Codicon.close))); + dismissButton.tabIndex = 0; + dismissButton.setAttribute('role', 'button'); + dismissButton.setAttribute('aria-label', localize('actionList.header.dismiss', "Dismiss")); + const dismiss = () => { + onDismiss(); + // Refocus the widget first so removing the focused button doesn't trip close-on-blur. + this.focus(); + this._headerContainer?.remove(); + // Drop the reference so the banner no longer reserves header height, then + // request a re-layout so the popup shrinks to fit the remaining content. + this._headerContainer = undefined; + this._onDidRequestLayout.fire(); + }; + // Generic mouse-up maps to pointer events on iOS, so tap/pen activation + // works without extra gesture plumbing (raw 'click' is unreliable there). + this._register(dom.addDisposableGenericMouseUpListener(dismissButton, () => dismiss())); + this._register(dom.addDisposableListener(dismissButton, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + dismiss(); + } + })); + } } this._applyFilter(); @@ -1103,6 +1167,10 @@ export class ActionListWidget extends Disposable { return this._filterInput; } + get closeAnimation(): IActionListCloseAnimation | undefined { + return this._options?.closeAnimation; + } + private focusCondition(element: IActionListItem): boolean { return !element.disabled && element.kind === ActionListItemKind.Action; } @@ -1960,6 +2028,10 @@ export class ActionList extends Disposable { return this._widget.filterInput; } + get closeAnimation(): IActionListCloseAnimation | undefined { + return this._widget.closeAnimation; + } + /** * Returns the resolved anchor position after the first layout. * Used by the context view delegate to lock the dropdown direction. @@ -2008,9 +2080,11 @@ export class ActionList extends Disposable { this._widget.focus(); } - hide(didCancel?: boolean): void { + hide(didCancel?: boolean, hideContextView = true): void { this._widget.hide(didCancel); - this._contextViewService.hideContextView(); + if (hideContextView) { + this._contextViewService.hideContextView(); + } } clearFilter(): boolean { diff --git a/src/vs/platform/actionWidget/browser/actionWidget.css b/src/vs/platform/actionWidget/browser/actionWidget.css index b0f70b75e09360..bb7fc4d8a352c7 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.css +++ b/src/vs/platform/actionWidget/browser/actionWidget.css @@ -530,6 +530,24 @@ font-size: 11px; } +.action-widget .action-list-header .action-list-header-dismiss { + flex-shrink: 0; + cursor: pointer; + color: var(--vscode-descriptionForeground); + font-size: 12px; + line-height: 16px; +} + +.action-widget .action-list-header .action-list-header-dismiss:hover { + color: var(--vscode-foreground); +} + +.action-widget .action-list-header .action-list-header-dismiss:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: 2px; + border-radius: var(--vscode-cornerRadius-small); +} + /* Anchor for the absolutely-positioned submenu panel */ .action-widget .actionList { position: relative; diff --git a/src/vs/platform/actionWidget/browser/actionWidget.ts b/src/vs/platform/actionWidget/browser/actionWidget.ts index 6feb8e32953187..0fbacf3cbffc43 100644 --- a/src/vs/platform/actionWidget/browser/actionWidget.ts +++ b/src/vs/platform/actionWidget/browser/actionWidget.ts @@ -6,6 +6,7 @@ import * as dom from '../../../base/browser/dom.js'; import { ActionBar } from '../../../base/browser/ui/actionbar/actionbar.js'; import { IAnchor } from '../../../base/browser/ui/contextview/contextview.js'; import { IAction } from '../../../base/common/actions.js'; +import { disposableTimeout } from '../../../base/common/async.js'; import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import './actionWidget.css'; @@ -65,6 +66,9 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } private readonly _list = this._register(new MutableDisposable>()); + private readonly _closeAnimation = this._register(new MutableDisposable()); + private _widgetElement: HTMLElement | undefined; + private _closingList: ActionList | undefined; constructor( @IContextViewService private readonly _contextViewService: IContextViewService, @@ -129,11 +133,33 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { } hide(didCancel?: boolean) { - this._list.value?.hide(didCancel); - this._list.clear(); + const list = this._list.value; + const widget = this._widgetElement; + if (!list || this._closingList === list) { + return; + } + + const closeAnimation = list.closeAnimation; + if (!widget || !closeAnimation || closeAnimation.duration <= 0 || !this._hasRequiredAncestorClasses(widget, closeAnimation.requiredAncestorClasses)) { + this._closingList = list; + list.hide(didCancel); + return; + } + + this._closingList = list; + widget.classList.add(closeAnimation.className); + list.hide(didCancel, false); + this._closeAnimation.value = disposableTimeout(() => { + if (this._list.value === list) { + this._contextViewService.hideContextView(didCancel); + } + }, closeAnimation.duration); } clear() { + this._closeAnimation.clear(); + this._closingList = undefined; + this._widgetElement = undefined; this._list.clear(); } @@ -141,6 +167,7 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { const widget = document.createElement('div'); widget.classList.add('action-widget'); element.appendChild(widget); + this._widgetElement = widget; this._list.value = list; if (this._list.value) { @@ -230,7 +257,26 @@ class ActionWidgetService extends Disposable implements IActionWidgetService { return actionBar; } + private _hasRequiredAncestorClasses(element: HTMLElement, classNames: readonly string[] | undefined): boolean { + if (!classNames?.length) { + return true; + } + for (let candidate: HTMLElement | null = element; candidate; candidate = candidate.parentElement) { + if (classNames.every(className => candidate.classList.contains(className))) { + return true; + } + } + return false; + } + private _onWidgetClosed(didCancel?: boolean): void { + if (this._closingList === this._list.value) { + this.clear(); + return; + } + this._closeAnimation.clear(); + this._closingList = undefined; + this._widgetElement = undefined; this._list.value?.hide(didCancel); } } diff --git a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts index 51ba3ce6f72a13..e2f974eea51076 100644 --- a/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts +++ b/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts @@ -275,6 +275,14 @@ export class ActionWidgetDropdown extends BaseDropdown { ); } + override hide(): void { + const wasVisible = this.isVisible(); + super.hide(); + if (wasVisible) { + this.actionWidgetService.hide(true); + } + } + setEnabled(enabled: boolean): void { this._enabled = enabled; } diff --git a/src/vs/platform/actionWidget/test/browser/actionList.test.ts b/src/vs/platform/actionWidget/test/browser/actionList.test.ts index 841bf5f8d6e690..42af87d05bc7ad 100644 --- a/src/vs/platform/actionWidget/test/browser/actionList.test.ts +++ b/src/vs/platform/actionWidget/test/browser/actionList.test.ts @@ -19,7 +19,8 @@ import { IKeybindingService } from '../../../keybinding/common/keybinding.js'; import { ILayoutService } from '../../../layout/browser/layoutService.js'; import { IOpenerService } from '../../../opener/common/opener.js'; import { NullOpenerService } from '../../../opener/test/common/nullOpenerService.js'; -import { ActionList, ActionListItemKind, ActionListWidget, IActionListItem } from '../../browser/actionList.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ActionList, ActionListItemKind, ActionListWidget, IActionListItem, IActionListOptions } from '../../browser/actionList.js'; interface ITestActionItem { readonly id: string; @@ -36,6 +37,7 @@ function separator(label?: string): IActionListItem { function createActionListWidget(disposables: ReturnType, options: { readonly items?: readonly IActionListItem[]; readonly onFilter?: (filter: string, cancellationToken: CancellationToken) => Promise[]>; + readonly listOptions?: Partial; }): ActionListWidget { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.set(IKeybindingService, new MockKeybindingService()); @@ -59,13 +61,20 @@ function createActionListWidget(disposables: ReturnType widget.filterContainer?.remove() }); } + // The header banner is a standalone element the caller attaches (like the + // filter container), so the test appends it to exercise header behaviors. + const headerContainer = widget.headerContainer; + if (headerContainer) { + document.body.appendChild(headerContainer); + disposables.add({ dispose: () => headerContainer.remove() }); + } document.body.appendChild(widget.domNode); disposables.add({ dispose: () => widget.domNode.remove() }); widget.layout(200, 200); @@ -235,4 +244,38 @@ suite('ActionListWidget', () => { const listHeight = parseFloat(list.domNode.style.height); assert.ok(listHeight + filterHeight + actionWidgetVerticalChromeHeight <= availableSpaceAboveAnchor); })); + + test('header dismiss removes the banner and requests a re-layout', () => { + let dismissed = false; + let layoutRequested = false; + const widget = createActionListWidget(disposables, { + listOptions: { headerText: 'Cache hint', headerDismiss: () => { dismissed = true; } }, + }); + disposables.add(widget.onDidRequestLayout(() => { layoutRequested = true; })); + + const header = widget.headerContainer; + assert.ok(header, 'header banner should render when headerText + headerDismiss are set'); + const dismissButton = header!.querySelector('.action-list-header-dismiss'); + assert.ok(dismissButton, 'dismiss button should render'); + + dismissButton!.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + + assert.deepStrictEqual( + { dismissed, layoutRequested, headerCleared: widget.headerContainer === undefined, headerStillInDom: header!.isConnected }, + { dismissed: true, layoutRequested: true, headerCleared: true, headerStillInDom: false }, + ); + }); + + test('header renders a "Learn more" link to the given uri', () => { + const widget = createActionListWidget(disposables, { + listOptions: { headerText: 'Cache hint', headerLink: { label: 'Learn more', uri: URI.parse('https://aka.ms/test') } }, + }); + + const link = widget.headerContainer?.querySelector('a.monaco-link'); + assert.ok(link, 'a "Learn more" link should render in the header'); + assert.deepStrictEqual( + { text: link!.textContent, href: link!.getAttribute('href') }, + { text: 'Learn more', href: 'https://aka.ms/test' }, + ); + }); }); diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index aa03abe039b12c..fc496387e578d8 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -100,6 +100,11 @@ export class MenuId { static readonly EmptyEditorGroupContext = new MenuId('EmptyEditorGroupContext'); static readonly EditorGroupWatermarkToolbar = new MenuId('EditorGroupWatermarkToolbar'); static readonly EditorTabsBarContext = new MenuId('EditorTabsBarContext'); + /** + * Menu whose actions populate the editor tab bar's "+" (Add Tab) dropdown. + * The button is rendered by core and is shown only when this menu has actions. + */ + static readonly EditorTabsBarAddTab = new MenuId('EditorTabsBarAddTab'); static readonly EditorTabsBarShowTabsSubmenu = new MenuId('EditorTabsBarShowTabsSubmenu'); static readonly EditorTabsBarShowTabsZenModeSubmenu = new MenuId('EditorTabsBarShowTabsZenModeSubmenu'); static readonly EditorActionsPositionSubmenu = new MenuId('EditorActionsPositionSubmenu'); @@ -258,12 +263,14 @@ export class MenuId { static readonly ChatMessageTitle = new MenuId('ChatMessageTitle'); static readonly ChatWelcomeContext = new MenuId('ChatWelcomeContext'); static readonly ChatMessageFooter = new MenuId('ChatMessageFooter'); + static readonly ChatSubagentContent = new MenuId('ChatSubagentContent'); static readonly ChatExecute = new MenuId('ChatExecute'); static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); static readonly ChatInputSecondary = new MenuId('ChatInputSecondary'); static readonly ChatInputStatus = new MenuId('ChatInputStatus'); static readonly ChatInputSide = new MenuId('ChatInputSide'); + static readonly AutomationsDialogInput = new MenuId('AutomationsDialogInput'); static readonly ChatModePicker = new MenuId('ChatModePicker'); static readonly ChatEditingWidgetToolbar = new MenuId('ChatEditingWidgetToolbar'); static readonly ChatEditingSessionChangesToolbar = new MenuId('ChatEditingSessionChangesToolbar'); diff --git a/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md b/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md new file mode 100644 index 00000000000000..8325e3f1e6d820 --- /dev/null +++ b/src/vs/platform/agentHost/MULTI_CHAT_ARCHITECTURE.md @@ -0,0 +1,323 @@ + + +# Multi-Chat Architecture + +> **Status: COMPLETE** (2026-07-01) +> All waves A–D and gates G-B1, G-C1, G-C2, G-D1 are done. Codex, Claude, and +> Copilot all use the unified orchestrator path. + +--- + +## 1. Mental Model + +### Three distinct concepts + +| Term | What it is | Owner | +|------|-----------|-------| +| **Session** (SDK-level) | The SDK-level session: working directory, active client, tool permissions, restore identity. Owns the default chat implicitly. | Agent harness | +| **Chat** | A thread of turns within a session, addressed by a chat channel URI. The default chat's URI is derived from the session URI with `buildDefaultChatUri`. Additional (peer) chats have their own `ahp-chat://` URIs. | Agent harness (SDK) | +| **Orchestrator session** | The protocol-visible entity that bundles a session with its chat catalog, state, and persistence. The orchestrator owns the catalog (which chats exist), the default-chat pointer, and all persistence. | `AgentService` + `AgentHostStateManager` | + +### Guiding principles + +- **"Represent, don't orchestrate."** The agent harness creates and drives SDK + chats; the orchestrator records what exists and routes protocol + actions. No agent-specific logic leaks into `AgentService` or + `AgentHostStateManager`. +- **Composition over inheritance.** All harnesses share one membership path + (`addChat`/`removeChat`), one persistence path (`PEER_CHATS_METADATA_KEY`), + and one restore path (`restoreChat`). Per-harness features are expressed + through `IAgentCapabilities` flags, not `if (provider === 'claude') ...` + branches. +- **Single catalog path.** Whether a chat is created by the user ("Add Chat") + or spawned by the harness (subagent tool call), it enters the catalog through + exactly one path (`AgentHostStateManager.addChat`). See invariant I4 below. + +--- + +## 2. Ownership and Layering + +```mermaid +graph TB + subgraph UI["UI / provider layer (sessions window)"] + caps["ISessionCapabilities → context keys
(sessionContextKeys.ts)"] + smgt["ISessionsManagementService"] + end + + subgraph Orch["Orchestrator (agent host process)"] + svc["AgentService
(node/agentService.ts)"] + stm["AgentHostStateManager
(node/agentHostStateManager.ts)"] + svc -->|dispatch actions| stm + stm -->|action envelopes| svc + end + + subgraph Agents["Agent harnesses (IAgent)"] + claude["ClaudeAgent"] + copilot["CopilotAgent"] + codex["CodexAgent"] + end + + UI -->|"createChat / disposeChat / dispatchAction"| svc + svc -->|"chats.createChat / fork / sendMessage"| Agents + Agents -->|"onDidSessionProgress / onDidSpawnChat / onDidEndChat"| svc + stm -->|state snapshots / envelopes| UI + Agents -->|"getDescriptor().capabilities"| caps +``` + +### Agent layer (`common/agentService.ts:IAgent`) + +Responsible for: +- Creating and owning SDK chats (`chats.createChat`, `chats.fork`). +- Reading history (`chats.getMessages`). +- Emitting progress signals (`onDidSessionProgress`). +- Emitting membership events for harness-spawned chats (`onDidSpawnChat`, `onDidEndChat`). +- Re-attaching a peer chat's backing on restore (`materializeChat`). +- Advertising static capability flags (`getDescriptor().capabilities`). + +Agents do **not** maintain the chat catalog, persist membership, or know about the orchestrator's URI mapping. + +### Orchestrator layer + +**`AgentService` (`node/agentService.ts`):** +- Owns the `(session, chat)` → `(agent, session URI, chat URI)` mapping. +- Owns `_providers`, `_sessionToProvider`, and `_findProviderForSession` (which falls back through the session URI's scheme when a session was restored without a `createSession` call in this process lifetime). +- Dispatches user-driven chat lifecycle (`createChat`, `disposeChat`) to `chats.*`. +- Persists and restores the orchestrator-owned peer-chat catalog (`PEER_CHATS_METADATA_KEY` in the session database, serialized per session via `_peerChatCatalogWrites`). +- Suppresses a peer chat's separately-enumerable backing SDK session (when `IAgentCreateChatResult.backingSession` is set): marks it via `_markPeerChatBacking` and filters it out of `listSessions` (invariant I7). +- Routes harness-spawned chats into the catalog (`_onChatSpawned`, `_onChatEnded`). +- Owns the restore flow (`restoreSession`, `_restorePeerChats`). + +**`AgentHostStateManager` (`node/agentHostStateManager.ts`):** +- Holds the authoritative in-memory state tree: + - `_sessionStates: Map` — per-session `SessionState` + catalog timestamps. + - `_chatStates: Map` — per-chat state (turns, activeTurn, draft). + - `_chatProviderData: Map` — opaque `providerData` blobs keyed by peer-chat URI; never parsed. +- Owns `_ensureDefaultChat`: creates the default `ChatState` (URI derived deterministically from the session URI via `buildDefaultChatUri`) at create/restore time. +- `addChat`/`restoreChat`/`removeChat`: the single path for catalog membership changes. +- Session-level active-turn tracking via `_sessionsWithActiveTurn` (a set of chat URIs per session, so multi-chat sessions running concurrent turns stay correct). + +### UI/provider layer (`sessions/services/sessions/common/session.ts:ISessionCapabilities`) + +- Protocol `AgentCapabilities` (`multipleChats?: { fork?: boolean }`) flows from `AgentInfo.capabilities` (protocol) through the provider adapter into `ISession.capabilities` (`ISessionCapabilities`), whose `supportsMultipleChats`/`supportsFork` flags derive from the presence of `multipleChats` and `multipleChats.fork`, and from there into VS Code context keys (`sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). +- UI actions read context keys — no provider-id switches. + +--- + +## 3. Key Invariants + +**I1 — `providerData` is opaque.** +`AgentHostStateManager._chatProviderData` stores the blob returned by `chats.createChat` verbatim. Neither `AgentService` nor `AgentHostStateManager` ever parses, validates, or mutates it. It is round-tripped to the agent verbatim on restore via `materializeChat(chat, providerData)`. + +**I2 — `sessionUri` and `chatChannelUri` are never overloaded.** +A session URI (`ahp-copilot://`, `ahp-claude://`, …) identifies a session. A chat channel URI (`ahp-chat://…`) identifies a chat within a session. The two schemes are structurally distinct; `isAhpChatChannel` / `parseDefaultChatUri` / `buildDefaultChatUri` are the only crossing points. Passing a chat URI where a session URI is expected (or vice versa) is a bug. + +**I3 — The default chat's backing SDK session IS the session.** +The default chat's URI is derived deterministically from the session URI (`buildDefaultChatUri(sessionUri)`), and its backing SDK session id equals the session raw id. The default chat owns the session-level resources: working directory, active client, and restore-by-session-id. Peer chats are satellites, each backed by its own SDK session id (`IPersistedChat.sdkSessionId`). This distinction is encapsulated inside each harness's session container; the orchestrator never special-cases it. + +**I4 — Single catalog path (spawn channel).** +Both user-driven chats (`AgentService.createChat` → `addChat`) and harness-spawned chats (`AgentService._onChatSpawned` → `addChat`) go through `AgentHostStateManager.addChat`. The spawn-channel listener is registered **before** `AgentSideEffects` during `registerProvider` (`node/agentService.ts:registerProvider`) to guarantee the chat exists in the catalog before any turn actions arrive for it (DR1 deterministic sequencing). + +**I5 — Orchestrator peer-chat catalog is the restore source of truth (with one-time legacy migration).** +After Wave C2, the orchestrator persists its own peer-chat catalog (`PEER_CHATS_METADATA_KEY`) alongside the session database. On restore, `_restorePeerChats` reads that catalog. When it is **absent** (`undefined` — a session persisted before the orchestrator owned the catalog), a one-time migration (`_migrateLegacyPeerChats`) enumerates the agent's legacy `*.chats` via `IAgent.listLegacyChats` (only **Copilot** implements it — mapping its `_readPersistedChats` entries to `{ uri: buildChatUri(session, chatId), providerData: encodeProviderData(info) }`; Claude and Codex omit it, so `listLegacyChats` falls to its optional-undefined default and nothing is drained), restores them through the same catalog path, then writes `PEER_CHATS_METADATA_KEY` so subsequent restores read the new catalog and never consult the legacy read again. An **empty** catalog (`[]`) is "known-empty" and skips migration. Harness-spawned chats (subagents) are NOT in the catalog — they are transient and re-derived from the parent's event log on restore. (Claude has no legacy `claude.chats` blob: Claude multi-chat shipped only with the orchestrator-owned catalog, so there was never a pre-catalog format to migrate.) + +**I6 — `_findProviderForSession` not `_sessionToProvider`.** +The `_sessionToProvider` map is populated only by `createSession`. A restored session (alive in the state manager after a host restart but never created in this process) is absent from it. `_findProviderForSession` (`node/agentService.ts:AgentService._findProviderForSession`) falls back to the session URI scheme, which is what makes restored sessions work. + +**I7 — A peer chat's backing SDK session must never surface as a top-level session.** +Some agents (e.g. Claude) back a peer chat with a fresh top-level SDK session minted in the same global store their own `IAgent.listSessions` enumerates, so the backing would leak into the session list as a phantom session. To suppress it, `IAgentCreateChatResult` carries an optional **first-class, non-opaque** `backingSession: URI` (distinct from the opaque `providerData` of I1 — the orchestrator reads it but still never parses `providerData`). On `createChat`, the orchestrator writes a persisted `peerChatBacking` marker (value = the owning peer chat's URI) into that backing session's own database (`_markPeerChatBacking`), and `AgentService.listSessions` drops any enumerated session whose database carries that marker (batched into the existing metadata-overlay read, mirroring the subagent filter). Because the marker is persisted, the suppression survives a host restart with no re-stamping. Agents whose peer chats do not have a separately-enumerable backing session (e.g. Copilot, whose peer SDK sessions live in the chat's data dir and are dropped by its own `listSessions`) may leave `backingSession` unset; Copilot sets it anyway for uniformity, which is harmless. + +--- + +## 4. Capabilities Gating + +`AgentCapabilities` (`common/state/protocol/channels-root/state.ts:AgentCapabilities`) is the protocol-level contract: + +```typescript +interface AgentCapabilities { + // presence (`{}`) signals multi-chat support; absence = unsupported + multipleChats?: { + fork?: boolean; // can fork a chat from a turn + }; +} +``` + +The agent declares these in `getDescriptor().capabilities` (`common/agentService.ts:IAgentDescriptor`). They flow to the UI as `ISessionCapabilities` (`sessions/services/sessions/common/session.ts`) and are bound to context keys (`sessions/services/sessions/common/sessionContextKeys.ts:SessionSupportsMultipleChatsContext`, `SessionSupportsForkContext`). + +UI code gates "Add Chat" and "Fork" actions on those context keys. No code inside `AgentService` or `AgentHostStateManager` switches on provider id to gate features. `AgentService.createChat` throws synchronously when `!provider.chats` (the structural guard that replaces a capability check in the orchestrator). + +--- + +## 5. Diagrams + +### 5a. Ownership/Component + +```mermaid +graph LR + subgraph SessionsUI["Sessions UI (workbench process)"] + provider["agentHostSessionsProvider
(copilotChatSessionsProvider)"] + ctxkeys["context keys
(sessionContextKeys.ts)"] + end + + subgraph AHP["Agent Host Process"] + svc["AgentService"] + stm["AgentHostStateManager\n• _sessionStates\n• _chatStates\n• _chatProviderData"] + se["AgentSideEffects"] + svc --- stm + svc --- se + end + + subgraph Harnesses["Agent Harnesses"] + claude["ClaudeAgent\n_sessions: DisposableMap"] + copilot["CopilotAgent\n_sessions: DisposableMap\n_chatBackings: Map"] + codex["CodexAgent\n_sessions: Map\n(single-chat)"] + end + + provider -->|"IPC (agentHost channel)"| svc + svc -->|"IAgentChats.*"| Harnesses + Harnesses -->|"onDidSessionProgress / onDidSpawnChat"| svc + stm -->|"ActionEnvelope stream"| provider + provider -->|"capabilities.multipleChats(.fork)"| ctxkeys +``` + +### 5b. Sequence: User-Driven Add Chat + +```mermaid +sequenceDiagram + participant UI as Sessions UI + participant AS as AgentService + participant A as IAgent.chats + participant SM as AgentHostStateManager + + UI->>AS: createChat(session, chatUri, options?) + AS->>AS: _findProviderForSession(session) + AS->>A: chats.createChat(chatUri, convOptions) + A-->>AS: IAgentCreateChatResult { providerData?, backingSession? } + AS->>SM: addChat(session, chatUri, { providerData }) + SM-->>UI: ActionEnvelope (SessionChatAdded) + AS->>AS: _persistPeerChat(session, chatUri, providerData) + Note over AS: enqueued per-session RMW of PEER_CHATS_METADATA_KEY + opt backingSession set (I7) + AS->>AS: _markPeerChatBacking(backingSession, chatUri) + Note over AS: writes peerChatBacking marker into the backing session's DB
so listSessions filters it out + end +``` + +### 5c. Sequence: Harness-Spawned Chat (Subagent via Spawn Channel) + +```mermaid +sequenceDiagram + participant SDK as Agent SDK + participant A as IAgent (onDidSessionProgress / onDidSpawnChat) + participant AS as AgentService + participant SM as AgentHostStateManager + participant SE as AgentSideEffects + + SDK->>A: subagent_started signal + A->>AS: onDidSessionProgress(AgentSignal{kind:'subagent_started'}) + Note over AS: _sequenceSpawnedChat (registered BEFORE AgentSideEffects) + AS->>AS: _onChatSpawned(event) + AS->>SM: addChat(session, chat, {origin: {kind:Tool, toolCallId}}) + SM-->>AS: ChatSummary + Note over SE: AgentSideEffects listener fires next, chat already in catalog (DR1) + SE->>SM: dispatch turn lifecycle actions for the spawned chat + Note over AS: Spawned chats are NOT persisted to PEER_CHATS_METADATA_KEY\n(transient, re-derived from event log on restore) +``` + +### 5d. Sequence: Restore + +```mermaid +sequenceDiagram + participant C as Client (subscribe) + participant AS as AgentService + participant A as IAgent + participant SM as AgentHostStateManager + + C->>AS: subscribe(sessionUri, clientId) + AS->>AS: restoreSession(sessionUri) + AS->>A: getSessionMessages(sessionUri) [default chat turns] + A-->>AS: Turn[] + AS->>AS: _readPersistedChatTitle(session, defaultChatUri) + AS->>SM: restoreSession(summary, turns, {draft, defaultChatTitle}) + SM->>SM: _ensureDefaultChat(sessionKey, summary, turns) + Note over AS: Peer chats: read PEER_CHATS_METADATA_KEY from DB + alt catalog present (defined) + loop for each IPersistedPeerChat (in catalog order) + AS->>A: materializeChat(chatUri, providerData?) + AS->>A: chats.getMessages(chatUri) + A-->>AS: Turn[] + AS->>SM: restoreChat(session, chatUri, {title, turns, draft, providerData}) + end + else catalog absent (undefined) — one-time legacy migration (Copilot only) + AS->>A: listLegacyChats(session) [legacy copilot.chats] + A-->>AS: {uri, providerData}[] + loop for each legacy chat + AS->>A: materializeChat + getMessages + AS->>SM: restoreChat(session, chatUri, {...}) + end + AS->>AS: _persistPeerChat(...) writes PEER_CHATS_METADATA_KEY (drain once) + end + AS-->>C: IStateSnapshot +``` + +### 5e. The (session, chat) to (agent, session URI, chat URI) Mapping + +```mermaid +graph TD + A["client dispatch: channel=ahp-chat://session/…/chat/…"] + B{isAhpChatChannel?} + C["chatChannel = channel\nsessionChannel = parseRequiredSessionUriFromChatUri(channel)"] + D["sessionChannel = channel\nchatChannel = undefined"] + E["agent = _findProviderForSession(sessionChannel)"] + F["session = sessionChannel (session URI)\nchat = chatChannel (concrete chat channel URI)"] + A --> B + B -->|yes| C + B -->|no| D + C --> E + D --> E + E --> F + F -->|"chats.sendMessage(chat, …)"| G["agent harness resolves its SDK session\nfrom the concrete chat URI"] +``` + +The orchestrator resolves the owning **session** from the session URI for session-scoped work, but passes a concrete **chat channel URI** to `IAgentChats` operations. For the default chat, that is `buildDefaultChatUri(sessionUri)`, not the bare session URI. Agents encapsulate the SDK fact that the default chat's backing SDK session id is the session id. + +--- + +## 6. Per-Agent Notes + +### Claude (`node/claude/claudeAgent.ts`) + +Single `_sessions: DisposableMap` keyed by session id. + +`ClaudeSessionEntry` (`claudeAgent.ts:ClaudeSessionEntry`) is a thin subclass of the shared `AgentSessionEntry` (`node/agentPeerChats.ts`), which is a `Disposable` container holding ALL chats of the session — the default (main) chat and any peers — together in ONE map keyed by each chat's channel URI string: +- `_chats: DisposableMap>` — every chat (default + peers) as a leaf entry, keyed by chat URI string. +- `_defaultChatKey` — the key of the default chat within `_chats`. `defaultChat` reads it; Claude narrows the base's optional `defaultChat` accessor to non-optional because a Claude entry is always seeded with a materialized default chat. + +Chat resolution: `entry.resolveChat(chatKey)` is ONE uniform map lookup that returns the `ClaudeAgentSession` for any chat - default or peer - plus whether the resolved entry is the default chat. Operational methods derive the owning session from the concrete chat URI and use that resolved entry rather than branching on `isDefaultChatUri`. Capabilities: `supportsMultipleChats: true, supportsFork: true`. + +Each peer chat is backed by a fresh top-level SDK session (`sdkSessionId = generateUuid()`) minted in the same global Claude project store that `listSessions` enumerates. `_createChat` therefore returns `backingSession: AgentSession.uri(this.id, sdkSessionId)` so the orchestrator can suppress that backing from the top-level session list (invariant I7); without it the peer chat would leak as a phantom session. The SDK exposes no delete-chat RPC, so `disposeChat` leaves the backing transcript on disk — the orchestrator-owned catalog simply drops the entry so it is never resumed again. (Claude writes no legacy `claude.chats` blob and has no legacy migration: Claude multi-chat shipped only with the orchestrator-owned catalog, so there is nothing to drain. Copilot keeps its own `copilot.chats` migration because `copilot.chats` predates the catalog.) + +### Copilot (`node/copilot/copilotAgent.ts`) + +F2 complete (2026-07-01): single `_sessions: DisposableMap` keyed by session id; the parallel `_chatSessions` map has been removed. + +`CopilotSessionEntry` (`copilotAgent.ts:CopilotSessionEntry`) is an empty subclass of the shared `AgentSessionEntry` (`node/agentPeerChats.ts`) — its API matches the base exactly. It is a `Disposable` container holding ALL chats (default + peers) in ONE map keyed by chat URI string: +- `_chats: DisposableMap>` — every chat (default + peers) as a leaf entry. +- `defaultChat: CopilotAgentSession | undefined` — the default chat via `_defaultChatKey`; `undefined` while the session is still provisional (not yet materialized). +- `setDefaultChat(chatKey, entry)` / `clearDefaultChat()` — lifecycle for the default chat (e.g. config-driven restart), seeding/dropping it in the same map as peers. + +Chat resolution reads that single map: `_findAnySession` returns `entry.defaultChat`, `_findPeerChat` returns `entry.getPeerChat(chatKey)`, and operational methods use `entry.resolveChat(chatKey)` so default and peer chats are resolved through the same map. Remaining `isDefaultChatUri` checks are outside the operational chat surface, for chat lifecycle, tool routing, and legacy/subagent guards. + +The peer-chat `providerData` codec (`IPersistedChat` + `encodeProviderData`/`decodeProviderData`) is also shared from `node/agentPeerChats.ts`; both agents import it rather than carrying private copies. + +An orthogonal `_chatBackings: Map` records the live SDK session id (`sdkSessionId`) + model override for each peer chat URI so the agent can resume peer chats without re-consulting disk. This map is populated by `createChat`/`materializeChat` and is separate from the orchestrator's `_chatProviderData` (which holds the opaque blob the agent produced, while `_chatBackings` is the agent's own in-memory parse of that blob). Capabilities: `multipleChats: { fork: true }`. + +### Codex (`node/codex/codexAgent.ts`) + +Single-chat harness. `_sessions: Map` keyed by session id; no peer-chat map. `chats.createChat` and `chats.fork` **throw** (`"Codex agent does not support multiple chats"` / `"Codex agent does not support chat forking"`); `chats.disposeChat` is a no-op; `sendMessage`/`abort`/`changeModel`/`getMessages` first resolve the addressed chat to Codex's single session and then operate on it (and `changeAgent` is a no-op). `getDescriptor().capabilities` omits `multipleChats` (absent = unsupported), so the UI never offers "Add Chat" or "Fork" for Codex sessions. diff --git a/src/vs/platform/agentHost/browser/agentHost.config.contribution.ts b/src/vs/platform/agentHost/browser/agentHost.config.contribution.ts deleted file mode 100644 index 967331669d00d8..00000000000000 --- a/src/vs/platform/agentHost/browser/agentHost.config.contribution.ts +++ /dev/null @@ -1,45 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IPolicyData } from '../../../base/common/defaultAccount.js'; -import { PolicyCategory } from '../../../base/common/policy.js'; -import * as nls from '../../../nls.js'; -import { Extensions as ConfigurationExtensions, IConfigurationNode, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; -import { Registry } from '../../registry/common/platform.js'; -import { AgentHostEnabledSettingId } from '../common/agentService.js'; -import '../common/agentHost.config.contribution.js'; - -// Re-registers `chat.agentHost.enabled` with the `policy` block attached. -// The bare setting (type + default) is registered in the common layer so the -// main process knows the default; this browser-only file adds the policy's -// `value` callback which cannot be structured-cloned over Electron IPC. -// -// Side-effect imports of this file: -// - `src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts` - -const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); -const existingProp = configurationRegistry.getConfigurationProperties()[AgentHostEnabledSettingId]; -const oldNode: IConfigurationNode = { id: 'chatAgentHost', properties: { [AgentHostEnabledSettingId]: existingProp } }; -const newNode: IConfigurationNode = { - id: 'chatAgentHost', - properties: { - [AgentHostEnabledSettingId]: { - ...existingProp, - policy: { - name: 'ChatAgentHostEnabled', - category: PolicyCategory.InteractiveSession, - minimumVersion: '1.126', - value: (policyData: IPolicyData) => policyData.chat_preview_features_enabled === false ? false : undefined, - localization: { - description: { - key: 'chat.agentHost.enabled', - value: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process.") - } - }, - } - }, - } -}; -configurationRegistry.updateConfigurations({ remove: [oldNode], add: [newNode] }); diff --git a/src/vs/platform/agentHost/browser/agentHostEnablementService.ts b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts new file mode 100644 index 00000000000000..0d04f5bb09ec86 --- /dev/null +++ b/src/vs/platform/agentHost/browser/agentHostEnablementService.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IPolicyData } from '../../../base/common/defaultAccount.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { isWeb } from '../../../base/common/platform.js'; +import { PolicyCategory } from '../../../base/common/policy.js'; +import * as nls from '../../../nls.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { Extensions as ConfigurationExtensions, IConfigurationNode, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { IContextKeyService } from '../../contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../instantiation/common/extensions.js'; +import { Registry } from '../../registry/common/platform.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY, IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; + +// The setting ID is intentionally not exported — all runtime checks go through +// IAgentHostEnablementService. The string is needed here only to register +// and apply the policy. +const agentHostEnabledSettingId = 'chat.agentHost.enabled'; + +// Add the `policy` block to `chat.agentHost.enabled`. The base registration +// (type, default, description) is done in the common layer as a side-effect of +// importing `../common/agentHostEnablementService.js`. The policy `value` +// callback cannot be structured-cloned over Electron IPC, so it is added here +// in the browser layer only. +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); +const existingProp = configurationRegistry.getConfigurationProperties()[agentHostEnabledSettingId]; +const oldNode: IConfigurationNode = { id: 'chatAgentHost', properties: { [agentHostEnabledSettingId]: existingProp } }; +const newNode: IConfigurationNode = { + id: 'chatAgentHost', + properties: { + [agentHostEnabledSettingId]: { + ...existingProp, + policy: { + name: 'ChatAgentHostEnabled', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.126', + value: (policyData: IPolicyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.agentHost.enabled', + value: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process.") + } + }, + } + }, + } +}; +configurationRegistry.updateConfigurations({ remove: [oldNode], add: [newNode] }); + +export class AgentHostEnablementService extends Disposable implements IAgentHostEnablementService { + + declare readonly _serviceBrand: undefined; + + readonly enabled: boolean; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + this.enabled = !isWeb && (configurationService.getValue(agentHostEnabledSettingId) ?? false); + AGENT_HOST_ENABLED_CONTEXT_KEY.bindTo(contextKeyService).set(this.enabled); + } +} + +registerSingleton(IAgentHostEnablementService, AgentHostEnablementService, InstantiationType.Eager); diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 9ed29001347e39..2e57149ae52729 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -37,7 +37,7 @@ import { encodeBase64 } from '../../../base/common/buffer.js'; import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import type { OtlpExportLogsParams } from '../common/state/protocol/channels-otlp/notifications.js'; import type { TelemetryCapabilities } from '../common/state/protocol/channels-otlp/state.js'; import type { InitializeResult } from '../common/state/protocol/common/commands.js'; @@ -356,6 +356,12 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } this._updateAutoReplyEnabled(); } + if (e.affectsConfiguration(PREFER_LONG_CONTEXT_SETTING_ID)) { + if (this._state.kind !== AgentHostClientState.Connected) { + return; + } + this._updatePreferLongContextEnabled(); + } if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) { if (this._state.kind !== AgentHostClientState.Connected) { return; @@ -458,6 +464,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC this._updateTerminalAutoApproveEnabled(); this._updateGlobalAutoApproveEnabled(); this._updateAutoReplyEnabled(); + this._updatePreferLongContextEnabled(); this._updateTerminalAutoApproveRules(); this._updateCodexEnabled(); this._transitionTo({ kind: AgentHostClientState.Connected }); @@ -1092,6 +1099,7 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC case 'root/sessionAdded': case 'root/sessionRemoved': case 'root/sessionSummaryChanged': + case 'root/progress': case 'auth/required': { this._logService.trace(`[RemoteAgentHostProtocol] Notification: ${msg.method}`); // The case narrows `msg.method` to a single literal; the matching params @@ -1351,6 +1359,14 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC }, this._clientId, 0); } + private _updatePreferLongContextEnabled(): void { + const enabled = this._configurationService.getValue(PREFER_LONG_CONTEXT_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostPreferLongContextEnabledConfigKey]: enabled }, + }, this._clientId, 0); + } + private _updateCodexEnabled(): void { // Always forwards the current value; the host only acts on enable, so a // forwarded `false` only takes effect on the next agent host restart diff --git a/src/vs/platform/agentHost/common/agentHostByokLm.ts b/src/vs/platform/agentHost/common/agentHostByokLm.ts new file mode 100644 index 00000000000000..b96f8d2da48aa1 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostByokLm.ts @@ -0,0 +1,141 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; + +/** + * Serializable bridge contract between the node agent host (where the + * {@link IByokLmProxyService} OpenAI-compatible proxy runs) and the renderer + * (which owns the extension-provided BYOK language models via the LM API). + * + * These shapes are deliberately wire-friendly (plain JSON, no `VSBuffer`, + * `URI`, or `workbench/contrib/chat` types) so they survive both the local + * utility-process IPC channel and the remote JSON-RPC transport without a + * translation step. The node side converts OpenAI Chat Completions wire + * payloads to/from these; the renderer side converts these to/from the VS Code + * LM API (`ILanguageModelsService`). + */ + +/** A single tool/function call requested by the assistant. */ +export interface IByokLmToolCall { + /** Stable id correlating the call with its later `tool` result message. */ + readonly id: string; + /** Tool/function name. */ + readonly name: string; + /** JSON-encoded arguments object. */ + readonly argumentsJson: string; +} + +/** A tool/function the model may call. */ +export interface IByokLmTool { + readonly name: string; + readonly description?: string; + /** JSON schema for the tool parameters. */ + readonly parametersSchema?: object; +} + +/** One chat message in a BYOK request. */ +export interface IByokLmChatMessage { + readonly role: 'system' | 'user' | 'assistant' | 'tool'; + /** Flattened text content. Empty string when the message carries only tool calls/results. */ + readonly content: string; + /** Present on `assistant` messages that requested tool calls. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Present on `tool` messages: the {@link IByokLmToolCall.id} this result answers. */ + readonly toolCallId?: string; +} + +/** A chat request forwarded from the proxy to the renderer LM API. */ +export interface IByokLmChatRequest { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id (the wire id the runtime sent on the OpenAI request). */ + readonly modelId: string; + readonly messages: IByokLmChatMessage[]; + readonly tools?: IByokLmTool[]; + /** Opaque per-request model options forwarded to the LM provider. */ + readonly modelOptions?: Record; +} + +/** The (buffered) completion produced by the renderer LM API. */ +export interface IByokLmChatResult { + /** Concatenated assistant text. */ + readonly content: string; + /** Tool calls the assistant requested, if any. */ + readonly toolCalls?: IByokLmToolCall[]; + /** Best-effort token usage, when the provider reports it. */ + readonly usage?: { + readonly promptTokens?: number; + readonly completionTokens?: number; + }; + /** Set when the LM call failed; `content` is then empty. */ + readonly error?: string; +} + +/** + * Metadata for a renderer BYOK model, enumerated over the bridge so the node + * agent host can advertise it to the SDK runtime without any host-side config. + */ +export interface IByokLmModelInfo { + /** Provider/vendor name (the LM API vendor that registered the model). */ + readonly vendor: string; + /** Provider-local model id. */ + readonly id: string; + /** Display name, when the provider supplies one. */ + readonly name?: string; + /** Maximum context window tokens (prompt + output), when known. */ + readonly maxContextWindowTokens?: number; + /** Whether the model accepts image inputs, when known. */ + readonly supportsVision?: boolean; +} + +export const IAgentHostByokLmHandler = createDecorator('agentHostByokLmHandler'); + +/** + * Renderer-side handler that services {@link IByokLmChatRequest}s by calling + * the VS Code Language Model API. Implemented in the workbench (where + * `ILanguageModelsService` lives) and reached from the node agent host over + * the reverse bridge. + */ +export interface IAgentHostByokLmHandler { + readonly _serviceBrand: undefined; + + /** + * Fires when the renderer's set of BYOK models changes, so the node agent + * host can re-enumerate them for the model picker. Optional: test fakes may + * omit it. + */ + readonly onDidChangeModels?: Event; + + /** + * Run a BYOK chat completion against the extension-registered model that + * matches `request.vendor` + `request.modelId`. Rejects (or resolves with + * {@link IByokLmChatResult.error}) when no such model is available. + */ + chat(request: IByokLmChatRequest, token: CancellationToken): Promise; + + /** + * Enumerate the renderer's BYOK models (vendor `isBYOK`, excluding + * session-scoped agent-host copies) so the node agent host can synthesize + * provider/model config for the SDK runtime. + */ + listModels(token: CancellationToken): Promise; +} + +/** + * Node-side connection to a single renderer's {@link IAgentHostByokLmHandler}. + * Mirrors `IRemoteFilesystemConnection` for the reverse FS bridge. + */ +export interface IByokLmBridgeConnection { + chat(request: IByokLmChatRequest): Promise; + listModels(): Promise; + /** + * Fires when the renderer's set of BYOK models changes, so the agent host + * can re-enumerate. Optional: test fakes may omit it. + */ + readonly onDidChangeModels?: Event; +} diff --git a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts index 32a9f73771318d..6a4a6fdc4f4e50 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetOperationService.ts @@ -117,9 +117,9 @@ export interface IAgentHostChangesetOperationService extends IDisposable { updateOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): void; /** - * Returns the operations that should be advertised for the given changeset, or - * `undefined` when no operations are available. - */ + * Returns the operations that should be advertised for the given changeset, or + * `undefined` when no operations are available. + */ getOperations(sessionKey: string, changeset?: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined; /** diff --git a/src/vs/platform/agentHost/common/agentHostChangesetService.ts b/src/vs/platform/agentHost/common/agentHostChangesetService.ts index 961dd90bf56e6e..956d9eb024227b 100644 --- a/src/vs/platform/agentHost/common/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/common/agentHostChangesetService.ts @@ -181,6 +181,11 @@ export interface IAgentHostChangesetService { */ isStaticChangesetComputeActive(changesetUri: ProtocolURI): boolean; + /** + * Refreshes the list of changesets for the given session. + */ + refreshChangesetCatalog(session: ProtocolURI): void; + /** * Lazy refresh of the branch changeset, kicked off when a client * first subscribes to `/changeset/branch`. Self-defers when the diff --git a/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts new file mode 100644 index 00000000000000..8d66db0443be71 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientByokLmChannel.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Lazy } from '../../../base/common/lazy.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { + IAgentHostByokLmHandler, + IByokLmBridgeConnection, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmModelInfo, +} from './agentHostByokLm.js'; + +/** + * IPC channel name used for in-process agent-host → renderer reverse BYOK + * language-model RPCs. The renderer registers a server channel under this + * name on its `MessagePortClient`; the agent host reaches it via + * `server.getChannel(name, c => c.ctx === clientId)` on its + * `UtilityProcessServer`. + * + * Mirrors {@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL} for the reverse FS bridge. + */ +export const AGENT_HOST_CLIENT_BYOK_LM_CHANNEL = 'agentHostClientByokLm'; + +/** + * Wraps an {@link IChannel} (obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an {@link IByokLmBridgeConnection} + * suitable for the node-side {@link IByokLmProxyService}. This is the node end + * of the bridge: `chat()` ships the request to the renderer and resolves with + * the buffered completion the renderer produced from the LM API. + */ +export function createAgentHostClientByokLmConnection(channel: IChannel): IByokLmBridgeConnection { + // Reach for `channel.listen` lazily — only when a consumer actually + // subscribes to `onDidChangeModels` — mirroring the deferred `channel.call` + // usage below (and the reverse FS bridge). Touching the channel eagerly at + // construction would force every connection to register an IPC event handler + // up front, even when nothing listens. + const onDidChangeModels = new Lazy(() => channel.listen('onDidChangeModels')); + return { + chat: (request) => channel.call('chat', request) as Promise, + listModels: () => channel.call('listModels') as Promise, + onDidChangeModels: (listener, thisArgs, disposables) => onDidChangeModels.value(listener, thisArgs, disposables), + }; +} + +/** + * Server-side channel for in-process reverse BYOK LM RPCs from the local agent + * host. Thin adapter — forwards `chat` calls to the renderer's + * {@link IAgentHostByokLmHandler} (backed by `ILanguageModelsService`). + */ +export class AgentHostClientByokLmChannel implements IServerChannel { + + constructor( + @IAgentHostByokLmHandler private readonly _handler: IAgentHostByokLmHandler, + ) { } + + listen(_ctx: unknown, event: string): Event { + if (event === 'onDidChangeModels') { + return (this._handler.onDidChangeModels ?? Event.None) as Event; + } + throw new Error(`No event '${event}' on AgentHostClientByokLmChannel`); + } + + async call(_ctx: unknown, command: string, arg?: unknown): Promise { + switch (command) { + case 'chat': { + const result = await this._handler.chat(arg as IByokLmChatRequest, CancellationToken.None); + return result as T; + } + case 'listModels': { + const models = await this._handler.listModels(CancellationToken.None); + return models as T; + } + } + throw new Error(`Unknown command '${command}' on AgentHostClientByokLmChannel`); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts b/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts new file mode 100644 index 00000000000000..87ec29202499a0 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostClientProxyChannel.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { IRequestService } from '../../request/common/request.js'; + +/** + * IPC channel name used for in-process agent-host → renderer reverse proxy + * resolution RPCs. The renderer registers a server channel under this name on + * its `MessagePortClient`; the agent host reaches it via + * `server.getChannel(name, c => c.ctx === clientId)` on its + * `UtilityProcessServer`. + * + * Mirrors {@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL} for the reverse BYOK bridge. + */ +export const AGENT_HOST_CLIENT_PROXY_CHANNEL = 'agentHostClientProxy'; + +/** + * Node end of the proxy-resolution bridge: `resolveProxy()` ships the target + * URL to the renderer and resolves with the *raw* result of VS Code's + * `IRequestService.resolveProxy` (the Electron session PAC-style string, e.g. + * `PROXY host:port` / `DIRECT`). The node side feeds this into + * `@vscode/proxy-agent`'s `resolveProxyURL` to derive the final proxy URL. + */ +export interface IAgentHostClientProxyConnection { + resolveProxy(url: string): Promise; +} + +/** + * Wraps an {@link IChannel} (obtained from the agent host's + * `UtilityProcessServer.getChannel`) into an {@link IAgentHostClientProxyConnection}. + */ +export function createAgentHostClientProxyConnection(channel: IChannel): IAgentHostClientProxyConnection { + return { + resolveProxy: (url) => channel.call('resolveProxy', { url }) as Promise, + }; +} + +/** + * Server-side channel for in-process reverse proxy-resolution RPCs from the + * local agent host. Thin adapter — forwards `resolveProxy` calls to the + * renderer's {@link IRequestService}, which resolves the proxy through the + * Electron session (system proxy settings, PAC scripts, etc.). The raw result + * is returned verbatim; the node side derives the proxy URL from it. + */ +export class AgentHostClientProxyChannel implements IServerChannel { + + constructor( + @IRequestService private readonly _requestService: IRequestService, + ) { } + + listen(_ctx: unknown, event: string): Event { + throw new Error(`No event '${event}' on AgentHostClientProxyChannel`); + } + + async call(_ctx: unknown, command: string, arg?: unknown): Promise { + switch (command) { + case 'resolveProxy': { + const { url } = arg as { url: string }; + const proxy = await this._requestService.resolveProxy(url); + return proxy as T; + } + } + throw new Error(`Unknown command '${command}' on AgentHostClientProxyChannel`); + } +} diff --git a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts index 2d01ed5843bd47..109da1f3871923 100644 --- a/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts +++ b/src/vs/platform/agentHost/common/agentHostCustomizationConfig.ts @@ -20,16 +20,6 @@ export const enum AgentHostConfigKey { * TODO: revisit magic key in config; refine into a dedicated typed channel. https://github.com/microsoft/vscode/issues/313812 */ DefaultShell = 'defaultShell', - /** When true, Copilot SDK sessions use Agent Host's custom terminal tool override instead of the SDK's default terminal behavior. Disabled by default. */ - EnableCustomTerminalTool = 'enableCustomTerminalTool', - /** When true, Copilot SDK sessions enable the rubber duck critic subagent. */ - RubberDuck = 'rubberDuck', - /** - * When true, Copilot SDK sessions running a Claude Opus 4.8 model apply the - * Opus 4.8-tuned system-prompt section overrides on top of the SDK - * foundation prompt. Opt-in; disabled by default. - */ - Opus48Prompt = 'opus48Prompt', /** * When true (the default), the Claude provider routes all Anthropic * `messages` traffic through the local Copilot-CAPI proxy (Copilot-routed @@ -37,6 +27,16 @@ export const enum AgentHostConfigKey { * the user's own credentials (BYO Anthropic — Phase 19). */ ClaudeUseCopilotProxy = 'claudeUseCopilotProxy', + /** + * Optional GitHub Enterprise base URI (e.g. `https://ghe.example.com` for a + * GitHub Enterprise Server, or `https://tenant.ghe.com` for GitHub Enterprise + * Cloud). When set, the agent host computes its GitHub protected resources and + * REST/GraphQL endpoints from this base instead of github.com. Normally pushed + * by the local VS Code client from the workbench `github-enterprise.uri` + * setting; remote operators set it directly in the remote + * `agent-host-config.json`. + */ + GithubEnterpriseUri = 'githubEnterpriseUri', } /** @@ -83,30 +83,17 @@ export const agentHostCustomizationConfigSchema = createSchema({ title: localize('agentHost.config.defaultShell.title', "Default Shell"), description: localize('agentHost.config.defaultShell.description', "Absolute path to the shell executable used by host-managed terminals. Normally pushed by the connected VS Code client from `terminal.integrated.agentHostProfile.` (falling back to `terminal.integrated.defaultProfile.`); when unset, the agent host falls back to the system shell. Only the path is supported; `args` and `env` from the workbench profile are not piped through yet. The workbench only pushes this for the local agent host — remote agent host operators should set this directly in the remote machine's `agent-host-config.json`."), }), - [AgentHostConfigKey.EnableCustomTerminalTool]: schemaProperty({ - type: 'boolean', - title: localize('agentHost.config.enableCustomTerminalTool.title', "Use Agent Host Terminal Tool"), - description: localize('agentHost.config.enableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use Agent Host's terminal tool override instead of the SDK's default terminal behavior."), - default: false, - }), - [AgentHostConfigKey.RubberDuck]: schemaProperty({ - type: 'boolean', - title: localize('agentHost.config.rubberDuck.title', "Rubber Duck Agent"), - description: localize('agentHost.config.rubberDuck.description', "When enabled, the coding agent uses a rubber duck critic subagent to review code changes using a complementary model."), - default: false, - }), - [AgentHostConfigKey.Opus48Prompt]: schemaProperty({ - type: 'boolean', - title: localize('agentHost.config.opus48Prompt.title', "Opus 4.8 Agent Prompt"), - description: localize('agentHost.config.opus48Prompt.description', "When enabled, Copilot SDK sessions running a Claude Opus 4.8 model apply Opus 4.8-tuned system-prompt section overrides on top of the default system message."), - default: false, - }), [AgentHostConfigKey.ClaudeUseCopilotProxy]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.claudeUseCopilotProxy.title', "Route Claude Through Copilot"), description: localize('agentHost.config.claudeUseCopilotProxy.description', "When enabled (the default), the Claude agent routes all requests through GitHub Copilot. When disabled, Claude talks to Anthropic directly using your own credentials (API key or Claude subscription)."), default: true, }), + [AgentHostConfigKey.GithubEnterpriseUri]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.githubEnterpriseUri.title', "GitHub Enterprise URI"), + description: localize('agentHost.config.githubEnterpriseUri.description', "Optional base URI of a GitHub Enterprise instance (for example \"https://ghe.example.com\" for GitHub Enterprise Server, or \"https://tenant.ghe.com\" for GitHub Enterprise Cloud). When set, the agent host authenticates and makes GitHub API calls against this instance instead of github.com. Normally pushed by the connected VS Code client from the `github-enterprise.uri` setting; remote agent host operators can set it directly in the remote `agent-host-config.json`."), + }), }); export const defaultAgentHostCustomizationConfigValues = { diff --git a/src/vs/platform/agentHost/common/agentHost.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostEnablementService.ts similarity index 62% rename from src/vs/platform/agentHost/common/agentHost.config.contribution.ts rename to src/vs/platform/agentHost/common/agentHostEnablementService.ts index 09e20948078d45..751d5fc1111e3f 100644 --- a/src/vs/platform/agentHost/common/agentHost.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostEnablementService.ts @@ -6,35 +6,40 @@ import { isWeb } from '../../../base/common/platform.js'; import * as nls from '../../../nls.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../configuration/common/configurationRegistry.js'; +import { RawContextKey } from '../../contextkey/common/contextkey.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; -import { AgentHostEnabledSettingId } from './agentService.js'; -// `chat.agentHost.enabled` is read in the desktop main process -// (`src/vs/code/electron-main/app.ts`) to decide whether to spawn the agent -// host, and in the renderer for various gating decisions. The remote server -// does **not** consume this key — it spawns the agent host based on its own -// `--agent-host-port` / `--agent-host-path` CLI args — so this registration -// is intentionally not imported there. -// -// Side-effect imports of this file: -// - `src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts` -// (loaded transitively from `app.ts`). -// - `src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts` -// (renderer registration for the settings UI). -// -// The `policy` block for `chat.agentHost.enabled` is added in the browser -// layer (`agentHost/browser/agentHost.config.contribution.ts`) via -// `updateConfigurations` because the `value` callback cannot be -// structured-cloned over Electron IPC. +/** @internal Only the enablement service may read this configuration value at runtime. */ +const agentHostEnabledSettingId = 'chat.agentHost.enabled'; +/** Context key set by {@link IAgentHostEnablementService}. Use in `when` clauses to gate UI on whether the agent host is enabled. */ +export const AGENT_HOST_ENABLED_CONTEXT_KEY = new RawContextKey('agentHostEnabled', false, { type: 'boolean', description: nls.localize('agentHostEnabled', "Whether the local agent host process is enabled.") }); + +export const IAgentHostEnablementService = createDecorator('agentHostEnablementService'); + +export interface IAgentHostEnablementService { + readonly _serviceBrand: undefined; + /** + * Whether the local agent host process is enabled in this runtime. + * Returns `false` on web. This value is fixed at startup and never changes. + */ + readonly enabled: boolean; +} + +// Register `chat.agentHost.enabled` and related settings. +// Intentionally kept in this file so the setting ID stays internal. +// Loaded by: +// - `electronAgentHostStarter.ts` (main process, for default value awareness) +// - `platform/agentHost/browser/agentHostEnablementService.ts` (renderer, via import) const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); configurationRegistry.registerConfiguration({ id: 'chatAgentHost', title: nls.localize('chatAgentHostConfigurationTitle', "Chat Agent Host"), type: 'object', properties: { - [AgentHostEnabledSettingId]: { + [agentHostEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), default: !isWeb && product.quality !== 'stable', @@ -43,7 +48,7 @@ configurationRegistry.registerConfiguration({ }, 'chat.agents.copilotCli.hideExtensionHost': { type: 'boolean', - description: nls.localize('chat.agents.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the Agents window picker."), + markdownDescription: nls.localize('chat.agents.copilotCli.hideExtensionHost', "When enabled, hides the Extension Host Copilot CLI entry from the Agents window picker. Requires `#chat.agentHost.enabled#`.", agentHostEnabledSettingId), default: false, tags: ['experimental'], experiment: { mode: 'startup' }, diff --git a/src/vs/platform/agentHost/common/agentHostGitService.ts b/src/vs/platform/agentHost/common/agentHostGitService.ts index b11746a4387858..2832ebf33665d8 100644 --- a/src/vs/platform/agentHost/common/agentHostGitService.ts +++ b/src/vs/platform/agentHost/common/agentHostGitService.ts @@ -16,6 +16,19 @@ import { ISessionFileDiff, ISessionGitState } from './state/sessionState.js'; */ export const META_DIFF_BASE_BRANCH = 'agentHost.diffBaseBranch'; +/** + * Resolves the Branch Changes base-branch **name** from its two sources, in + * precedence order: the agent-persisted {@link META_DIFF_BASE_BRANCH} metadata + * value, then the session git state's detected base branch. Returns `undefined` + * when neither is available (callers then anchor the diff at `HEAD`). + * + * Shared by {@link IAgentHostChangesetService} and the review service so both + * pick the same base branch. + */ +export function resolveDiffBaseBranchName(persistedBaseBranch: string | undefined, sessionGitStateBaseBranch: string | undefined): string | undefined { + return persistedBaseBranch ?? sessionGitStateBaseBranch; +} + /** * The well-known SHA-1 of git's empty tree, used as a fallback when a * repository has no commits (no `HEAD` to read into the temp index). @@ -162,11 +175,23 @@ export interface IAgentHostGitService { computeSessionFileDiffs(workingDirectory: URI, options: IComputeSessionFileDiffsOptions): Promise; /** - * Reads a single git blob via `git show :` from + * Resolves the commit-ish the **Branch Changes** baseline is measured from: + * the merge-base of `HEAD` and `baseBranch` (preferring the + * `origin/` remote-tracking ref when it exists), falling back to + * `HEAD`, then to the empty-tree object for a repo with no commits. Returns + * `undefined` only when {@link workingDirectory} is not a git work tree. + * + * Shared by {@link computeSessionFileDiffs} (which anchors the Branch Changes + * diff here) and the review service, so both agree on the exact baseline. + */ + resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise; + + /** + * Reads a single git blob via `git show :` from * the given working directory. Returns `undefined` when the blob does * not exist or the directory is not a git work tree. */ - showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise; + showBlob(workingDirectory: URI, ref: string, repoRelativePath: string): Promise; // ---- Checkpoint plumbing (used by IAgentHostCheckpointService) ------- @@ -202,6 +227,28 @@ export interface IAgentHostGitService { */ revParse(repositoryRoot: URI, expression: string): Promise; + /** + * Builds a new tree from `baseTreeOid` in which the single repo-relative + * `path` is replaced by its content (blob + mode) from `sourceTreeOid`, or + * removed when the path is absent in `sourceTreeOid`. All other paths are + * copied verbatim from `baseTreeOid`. Uses a throwaway `GIT_INDEX_FILE` so + * the user's real index is untouched. Returns the new tree OID, or + * `undefined` on git failure. + * + * File-level building block for review (see `IAgentHostReviewService`): to + * mark a file reviewed, overlay it from the working-tree snapshot tree; to + * unmark, overlay it from the baseline tree. + */ + overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise; + + /** + * Returns the repo-relative paths that differ between two tree-ish (commit + * or tree) objects via `git diff --name-only --no-renames -z`. Rename + * detection is off so a rename shows as delete(old) + add(new). Returns + * `undefined` on git failure (e.g. not a git work tree). + */ + diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise; + /** * Computes per-file diffs between two refs (typically two consecutive * checkpoint refs) by shelling out to diff --git a/src/vs/platform/agentHost/common/agentHostReviewService.ts b/src/vs/platform/agentHost/common/agentHostReviewService.ts new file mode 100644 index 00000000000000..fecdb33ae62627 --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostReviewService.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import type { URI as ProtocolURI } from './state/sessionState.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; + +export const IAgentHostReviewService = createDecorator('agentHostReviewService'); + +/** + * Returns the canonical name for a session's synthetic **reviewed** ref. + * Lives under the same `refs/agents//…` namespace as checkpoint refs so + * the two coexist safely and never surface to the user as branches/tags. + */ +export function buildReviewedRefName(sanitizedSessionId: string): string { + return `refs/agents/${sanitizedSessionId}/reviewed`; +} + +/** + * Tracks which files in a session's **Branch Changes** the user has reviewed, + * as a session-private synthetic git ref (`refs/agents//reviewed`) whose + * tree snapshots the reviewed content. A file is reviewed when its content in + * the reviewed tree matches the current working tree; re-editing a reviewed + * file therefore auto-unreviews it. + * + * All operations are keyed on the Branch Changes baseline (the merge-base of + * `HEAD` and the session's base branch). The `baseBranch` argument is the + * already-resolved base-branch **name** (see `resolveDiffBaseBranchName`), + * shared with the changeset service so both agree on the baseline. + * + * Operations are no-ops when the working directory is not inside a git + * repository; a future milestone will add a non-git fallback (see the + * DB-backed reviewed-file store on `ISessionDatabase`). + */ +export interface IAgentHostReviewService { + readonly _serviceBrand: undefined; + + /** + * Marks a single file reviewed at its current working-tree content by + * overlaying that content into the reviewed tree and advancing the + * reviewed ref. No-op when the file is already reviewed at that content. + */ + markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise; + + /** + * Marks a single file as unreviewed by resetting its entry in the + * reviewed tree back to the baseline content and advancing the reviewed + * ref. No-op when the file is not currently reviewed. + */ + markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise; + + /** + * Returns the set of reviewed repo-relative paths within the current Branch + * Changes: the changed files whose reviewed-tree content matches the + * working tree. Empty when nothing is reviewed or the directory is not a + * git work tree. + */ + getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise>; + + /** + * Copies the reviewed ref from `sourceSessionUri` to `targetSessionUri` so a + * forked session starts with the parent's review progress. Points the + * target's reviewed ref at the same commit as the source's (git objects are + * shared within the repository). No-op when the source has no reviewed ref or + * the directory is not a git work tree. + */ + copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise; +} + +/** + * A no-op {@link IAgentHostReviewService} used as the default for the optional + * `_reviewService` parameter on `AgentService` so existing test callsites keep + * compiling without forced fixture updates. + */ +export const NULL_REVIEW_SERVICE: IAgentHostReviewService = { + _serviceBrand: undefined, + markFileReviewed: async () => { }, + markFileUnreviewed: async () => { }, + getReviewedPaths: async () => new Set(), + copyReviewedRef: async () => { }, +}; diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 5f834c1ed9618a..0efa6c64cc7159 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -443,6 +443,12 @@ export const AgentHostAutoReplyEnabledConfigKey = 'autoReplyEnabled'; */ export const AUTO_REPLY_SETTING_ID = 'chat.autoReply'; +// Root config key forwarded from the renderer when Copilot Chat's `github.copilot.chat.preferLongContext.enabled` setting changes. +export const AgentHostPreferLongContextEnabledConfigKey = 'preferLongContextEnabled'; + +// The Copilot Chat setting ID for preferring long context, forwarded into the agent host root config. +export const PREFER_LONG_CONTEXT_SETTING_ID = 'github.copilot.chat.preferLongContext.enabled'; + /** * Root config key forwarded from the renderer when VS Code's * `chat.tools.terminal.autoApprove` setting changes. Holds the effective @@ -681,6 +687,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.autoReplyEnabled.description', "Whether VS Code's auto-reply setting is enabled. When `true`, `ask_user` questions are auto-answered instead of blocking on the user, mirroring autopilot mode."), default: false, }), + [AgentHostPreferLongContextEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.preferLongContextEnabled.title', "Prefer Long Context"), + description: localize('agentHost.config.preferLongContextEnabled.description', "Whether Copilot Chat's prefer-long-context setting is enabled. When `true`, models with a free long context window only show the long context option in the picker. When `false` (default), the smaller default context option stays selectable."), + default: false, + }), [AgentHostTerminalAutoApproveRulesConfigKey]: schemaProperty({ type: 'object', title: localize('agentHost.config.terminalAutoApproveRules.title', "Terminal Auto Approve Rules"), diff --git a/src/vs/platform/agentHost/common/agentHostSessionType.ts b/src/vs/platform/agentHost/common/agentHostSessionType.ts index 8788abcd3da7c5..c85f425305b1ef 100644 --- a/src/vs/platform/agentHost/common/agentHostSessionType.ts +++ b/src/vs/platform/agentHost/common/agentHostSessionType.ts @@ -57,6 +57,22 @@ function isRemoteAgentHostSessionTypeForAuthority(sessionType: string, connectio return !!connectionAuthority && sessionType.startsWith(remoteAgentHostSessionTypeAuthorityPrefix(connectionAuthority)); } +/** + * Extracts the harness/provider suffix from a remote agent host session type. + * + * Remote session types are formatted as `remote-{authority}-{provider}`. The + * authority may contain `-`, but provider names do not, so the harness is the + * final `-`-delimited segment. Returns `undefined` for non-remote session types. + */ +export function parseRemoteAgentHostHarness(sessionType: string): string | undefined { + if (!isRemoteAgentHostSessionType(sessionType)) { + return undefined; + } + const lastDash = sessionType.lastIndexOf('-'); + const harness = sessionType.slice(lastDash + 1); + return harness || undefined; +} + /** * Extracts the connection authority from a remote agent host session type when the provider is known. */ diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index 0beea5266a08e0..046555f43bbf1f 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -11,6 +11,7 @@ import { COPILOT_OTEL_CAPTURE_CONTENT_KEY, COPILOT_OTEL_ENABLED_KEY, COPILOT_OTE import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { + AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, @@ -106,6 +107,12 @@ configurationRegistry.registerConfiguration({ } }, }, + [AgentHostByokModelsEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.byokModels.enabled', "When enabled, the agent host wires up the BYOK ('bring your own key') language-model bridge so extension-provided BYOK models can run in agent-host sessions. Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), + default: true, + tags: ['experimental', 'advanced'], + }, [AgentHostCodexAgentEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.codexAgent.enabled', "When enabled, the agent host registers the Codex provider (subject to the Codex SDK being reachable). Requires `#chat.agentHost.enabled#`. The agent host process must be restarted for changes to take effect."), diff --git a/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.ts new file mode 100644 index 00000000000000..d55d5e33af613c --- /dev/null +++ b/src/vs/platform/agentHost/common/agentHostTelemetryEnv.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. + *--------------------------------------------------------------------------------------------*/ + +/** + * Environment variables used to forward the host's resolved telemetry + * identifiers into the agent host process. + * + * The agent host runs in its own utility process and would otherwise compute + * its own `machineId`/`devDeviceId` live from the MAC address / device-id store + * on every launch. That can diverge from the workbench's persisted, state-backed + * identifiers (e.g. when `state.json` was seeded by imaging/migration, or when + * the "first valid MAC" changes), breaking per-user joins across event sources. + * + * To keep the identifiers consistent, the local starter + * (`ElectronAgentHostStarter`, which runs in the main process where these are + * already resolved) forwards them via these env vars, and + * `createAgentHostTelemetryService` prefers them over recomputing. + */ +export const AgentHostMachineIdEnvKey = 'VSCODE_AGENT_HOST_MACHINE_ID'; +export const AgentHostSqmIdEnvKey = 'VSCODE_AGENT_HOST_SQM_ID'; +export const AgentHostDevDeviceIdEnvKey = 'VSCODE_AGENT_HOST_DEV_DEVICE_ID'; + +export interface IAgentHostForwardedTelemetryIds { + readonly machineId: string; + readonly sqmId: string; + readonly devDeviceId: string; +} + +/** + * Builds the env var bag that forwards the resolved telemetry identifiers to + * the agent host process. Empty identifiers are omitted so the host falls back + * to computing them itself. + */ +export function buildAgentHostTelemetryIdEnv(ids: IAgentHostForwardedTelemetryIds): Record { + const env: Record = {}; + if (ids.machineId) { + env[AgentHostMachineIdEnvKey] = ids.machineId; + } + if (ids.sqmId) { + env[AgentHostSqmIdEnvKey] = ids.sqmId; + } + if (ids.devDeviceId) { + env[AgentHostDevDeviceIdEnvKey] = ids.devDeviceId; + } + return env; +} diff --git a/src/vs/platform/agentHost/common/agentModelPricing.ts b/src/vs/platform/agentHost/common/agentModelPricing.ts index 2014c2ec70b09a..a6460d781d9622 100644 --- a/src/vs/platform/agentHost/common/agentModelPricing.ts +++ b/src/vs/platform/agentHost/common/agentModelPricing.ts @@ -84,6 +84,62 @@ export function createAgentModelPricingMeta(pricing: IAgentModelPricingMeta): Re return entries.length > 0 ? Object.fromEntries(entries) : undefined; } +/** + * Normalizes a raw CAPI billing payload (which uses snake_case field names like `token_prices`, + * `input_price`) into the camelCase {@link ICAPIModelBilling} shape that {@link createPricingMetaFromBilling} + * expects. Also handles the case where the billing object already uses camelCase (e.g. from the + * Copilot SDK's `ModelInfo`). Returns `undefined` when `raw` is nullish. + */ +export function normalizeCAPIBilling(raw: unknown): ICAPIModelBilling | undefined { + if (!raw || typeof raw !== 'object') { + return undefined; + } + const billing = raw as Record; + const multiplier = typeof billing.multiplier === 'number' ? billing.multiplier : undefined; + const priceCategory = typeof billing.priceCategory === 'string' ? billing.priceCategory + : typeof (billing as Record).price_category === 'string' ? (billing as Record).price_category as string + : undefined; + const discountPercent = typeof billing.discountPercent === 'number' ? billing.discountPercent + : typeof (billing as Record).discount_percent === 'number' ? (billing as Record).discount_percent as number + : undefined; + + // Resolve token prices: prefer camelCase `tokenPrices`, fall back to snake_case `token_prices`. + const rawTokenPrices = (billing.tokenPrices ?? billing.token_prices) as Record | undefined; + let tokenPrices: ICAPIModelBilling['tokenPrices'] = undefined; + if (rawTokenPrices && typeof rawTokenPrices === 'object') { + // The CAPI snake_case format nests prices under `default` / `long_context` tiers; + // the camelCase format flattens them at the top level of `tokenPrices`. + const defaultTier = rawTokenPrices.default as Record | undefined; + const hasDefault = defaultTier && typeof defaultTier === 'object'; + + const inputPrice = asNumber(rawTokenPrices.inputPrice) ?? asNumber(hasDefault ? defaultTier.input_price : undefined); + const cachePrice = asNumber(rawTokenPrices.cachePrice) ?? asNumber(hasDefault ? defaultTier.cache_price : undefined); + const cacheWritePrice = asNumber(rawTokenPrices.cacheWritePrice) ?? asNumber(hasDefault ? defaultTier.cache_write_price : undefined); + const outputPrice = asNumber(rawTokenPrices.outputPrice) ?? asNumber(hasDefault ? defaultTier.output_price : undefined); + const contextMax = asNumber(rawTokenPrices.contextMax) ?? asNumber(hasDefault ? defaultTier.context_max : undefined); + + const rawLong = (rawTokenPrices.longContext ?? rawTokenPrices.long_context) as Record | undefined; + let longContext: { readonly contextMax?: number; readonly inputPrice?: number; readonly cachePrice?: number; readonly cacheWritePrice?: number; readonly outputPrice?: number } | undefined; + if (rawLong && typeof rawLong === 'object') { + longContext = { + inputPrice: asNumber(rawLong.inputPrice) ?? asNumber(rawLong.input_price), + cachePrice: asNumber(rawLong.cachePrice) ?? asNumber(rawLong.cache_price), + cacheWritePrice: asNumber(rawLong.cacheWritePrice) ?? asNumber(rawLong.cache_write_price), + outputPrice: asNumber(rawLong.outputPrice) ?? asNumber(rawLong.output_price), + contextMax: asNumber(rawLong.contextMax) ?? asNumber(rawLong.context_max), + }; + } + + tokenPrices = { inputPrice, cachePrice, cacheWritePrice, outputPrice, contextMax, longContext }; + } + + return { multiplier, priceCategory, discountPercent, tokenPrices }; +} + +function asNumber(v: unknown): number | undefined { + return typeof v === 'number' ? v : undefined; +} + /** * Runtime shape of the CAPI model billing payload. The published SDK types (`CCAModelBilling`, `ModelBilling`) don't * yet declare `tokenPrices`, `priceCategory`, or `discountPercent`, but the `/models` endpoint already carries them. @@ -115,7 +171,9 @@ export interface ICAPIModelBilling { /** * Converts a CAPI model's billing payload into an {@link IAgentModelPricingMeta} `_meta` bag. Long-context costs are - * only emitted when they differ from the default tier so the model picker can tell them apart. + * only emitted when there is an actual surcharge (at least one long-context price differs from the default tier). + * When emitting, any missing long-context field falls back to the default-tier value so the hover table renders + * complete rows. See {@link hasLongContextSurcharge} for the surcharge detection logic. * * @param billing - The model's billing info, narrowed through {@link ICAPIModelBilling}. * @param priceCategory - An optional override for the price category (e.g. from `modelPickerPriceCategory` on the @@ -125,8 +183,16 @@ export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefi const tokenPrices = billing?.tokenPrices; const longContext = tokenPrices?.longContext; - const differsFromDefault = (longValue: number | undefined, defaultValue: number | undefined): number | undefined => - longValue !== undefined && longValue !== defaultValue ? longValue : undefined; + // Only emit long-context costs when there is an actual surcharge (at least + // one price differs from default). When emitting, fall back to the default- + // tier value for any field the long-context tier does not specify so the + // hover table renders complete rows without gaps. + const showLongContext = longContext !== undefined && ( + (longContext.inputPrice !== undefined && longContext.inputPrice !== tokenPrices?.inputPrice) || + (longContext.outputPrice !== undefined && longContext.outputPrice !== tokenPrices?.outputPrice) || + (longContext.cachePrice !== undefined && longContext.cachePrice !== tokenPrices?.cachePrice) || + (longContext.cacheWritePrice !== undefined && longContext.cacheWritePrice !== tokenPrices?.cacheWritePrice) + ); return createAgentModelPricingMeta({ multiplierNumeric: typeof billing?.multiplier === 'number' ? billing.multiplier : undefined, @@ -134,10 +200,10 @@ export function createPricingMetaFromBilling(billing: ICAPIModelBilling | undefi cacheCost: tokenPrices?.cachePrice, cacheWriteCost: tokenPrices?.cacheWritePrice, outputCost: tokenPrices?.outputPrice, - longContextInputCost: differsFromDefault(longContext?.inputPrice, tokenPrices?.inputPrice), - longContextCacheCost: differsFromDefault(longContext?.cachePrice, tokenPrices?.cachePrice), - longContextCacheWriteCost: differsFromDefault(longContext?.cacheWritePrice, tokenPrices?.cacheWritePrice), - longContextOutputCost: differsFromDefault(longContext?.outputPrice, tokenPrices?.outputPrice), + longContextInputCost: showLongContext ? (longContext.inputPrice ?? tokenPrices?.inputPrice) : undefined, + longContextCacheCost: showLongContext ? (longContext.cachePrice ?? tokenPrices?.cachePrice) : undefined, + longContextCacheWriteCost: showLongContext ? (longContext.cacheWritePrice ?? tokenPrices?.cacheWritePrice) : undefined, + longContextOutputCost: showLongContext ? (longContext.outputPrice ?? tokenPrices?.outputPrice) : undefined, priceCategory: priceCategory ?? (typeof billing?.priceCategory === 'string' ? billing.priceCategory : undefined), discountPercent: typeof billing?.discountPercent === 'number' ? billing.discountPercent : undefined, }); diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index a49cfce19d2f4e..4f7e3c3e4d8860 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -6,7 +6,7 @@ import type { CancellationToken } from '../../../base/common/cancellation.js'; import { Event } from '../../../base/common/event.js'; import { IReference } from '../../../base/common/lifecycle.js'; -import { isWeb } from '../../../base/common/platform.js'; +import { truncate } from '../../../base/common/strings.js'; import { IAuthorizationProtectedResourceMetadata } from '../../../base/common/oauth.js'; import type { IObservable } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; @@ -18,9 +18,9 @@ import type { IRemoteWatchHandle } from './agentHostFileSystemProvider.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from './state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from './state/protocol/channels-changeset/commands.js'; import { ProtectedResourceMetadata, type Changeset, type ConfigSchema, type MessageAttachment, type ModelSelection, type AgentSelection, type SessionActiveClient, type ToolCallPendingConfirmationState, type ToolDefinition, ChangesSummary } from './state/protocol/state.js'; -import type { ActionEnvelope, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; +import type { ActionEnvelope, AuthRequiredParams, INotification, IRootConfigChangedAction, SessionAction, ChatAction, TerminalAction, ClientAnnotationsAction } from './state/sessionActions.js'; import type { ResourceCopyParams, ResourceCopyResult, ResourceDeleteParams, ResourceDeleteResult, ResourceListResult, ResourceMkdirParams, ResourceMkdirResult, ResourceMoveParams, ResourceMoveResult, ResourceReadResult, ResourceResolveParams, ResourceResolveResult, ResourceWatchState, ResourceWriteParams, ResourceWriteResult, CreateResourceWatchParams, CreateResourceWatchResult, IStateSnapshot } from './state/sessionProtocol.js'; -import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; +import { ComponentToState, ChatInputResponseKind, SessionStatus, StateComponents, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, type AgentCapabilities, type ClientPluginCustomization, type Customization, type PendingMessage, type RootState, type ChatInputAnswer, type SessionMeta, type ToolCallResult, type Turn, type PolicyState } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. // Defines all serializable event types, the IAgent provider interface, @@ -41,27 +41,12 @@ export const enum AgentHostIpcChannels { RemoteProxy = 'agentHostProxy', } -/** Configuration key that controls whether the local agent host process is spawned. */ -export const AgentHostEnabledSettingId = 'chat.agentHost.enabled'; - -/** Whether the local/process-backed agent host is enabled in this runtime. */ -export function isAgentHostEnabled(configurationService: IConfigurationService): boolean { - return !isWeb && !!configurationService.getValue(AgentHostEnabledSettingId); -} - /** Configuration key that controls whether AHP JSONL logs are written for agent host transports. */ export const AgentHostAhpJsonlLoggingSettingId = 'chat.agentHost.ahpJsonlLoggingEnabled'; -/** Configuration key that controls whether Agent Host uses its terminal tool override for Copilot SDK sessions. */ -export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled'; - -/** - * Configuration key that controls whether Copilot SDK sessions running a Claude - * Opus 4.8 model apply the Opus 4.8-tuned system-prompt section overrides. - * Forwarded into the agent host's root config (`opus48Prompt`) by - * `AgentHostCopilotPromptContribution`. - */ -export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled'; +// The Copilot-CLI-specific setting IDs (`customTerminalTool`, `opus48Prompt`, +// `reasoningEffortOverride`, `modelCapabilityOverrides`) live with their +// root-config keys in `copilotCliConfig.ts`. /** * Configuration key controlling whether the Claude provider is registered in @@ -85,6 +70,20 @@ export const AgentHostClaudeAgentEnabledSettingId = 'chat.agentHost.claudeAgent. */ export const AgentHostCodexAgentEnabledSettingId = 'chat.agentHost.codexAgent.enabled'; +/** + * Configuration key controlling whether the agent host *wires up* the BYOK + * ("bring your own key") language-model bridge: the renderer LM handler, the + * reverse-RPC channel, and the per-connection link to the node-side OpenAI + * proxy + bridge registry. When `true` (the default), the renderer's BYOK + * server channel and the per-connection bridge are wired so extension-provided + * BYOK models are reachable from agent-host sessions. When `false`, the proxy + * and registry are still constructed but stay inert — the BYOK server channel + * and the per-connection bridge are not wired, so the registry stays empty and + * extension-provided BYOK models are never reachable from agent-host sessions. + * The agent host process must be restarted for changes to take effect. + */ +export const AgentHostByokModelsEnabledSettingId = 'chat.agentHost.byokModels.enabled'; + /** * Optional override that points at an **SDK root directory** containing a * `node_modules/@anthropic-ai/claude-agent-sdk` subtree. When set, the agent @@ -110,6 +109,13 @@ export const AgentHostClaudeAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CLAUDE_AGENT */ export const AgentHostCodexAgentEnabledEnvVar = 'VSCODE_AGENT_HOST_CODEX_AGENT_ENABLED'; +/** + * Environment variable form of {@link AgentHostByokModelsEnabledSettingId}. + * Set by the agent host starters from the setting. Accepts `'true'` / + * `'false'`; absent means "default" (`true`). + */ +export const AgentHostByokModelsEnabledEnvVar = 'VSCODE_AGENT_HOST_BYOK_MODELS_ENABLED'; + /** * Resolves the effective enable state for a Claude/Codex provider from the * env-var value forwarded by the starter. Recognized values (case- and @@ -136,7 +142,7 @@ export function isAgentEnabled(envValue: string | undefined, defaultEnabled: boo /** * Configuration key that controls the sandbox mode for the Copilot SDK's built-in - * shell tool (the path taken when {@link AgentHostCustomTerminalToolEnabledSettingId} + * shell tool (the path taken when `AgentHostCustomTerminalToolEnabledSettingId` * is `false`). Values mirror {@link AgentSandboxEnabledValue}: * * - `'off'` (the default): no sandbox policy is forwarded for the SDK shell @@ -146,7 +152,7 @@ export function isAgentEnabled(envValue: string | undefined, defaultEnabled: boo * Outbound network is enforced via the user's allow/deny host lists. * - `'allowNetwork'`: same as `'on'` but with unrestricted outbound network. * - * Has no effect when {@link AgentHostCustomTerminalToolEnabledSettingId} is + * Has no effect when `AgentHostCustomTerminalToolEnabledSettingId` is * `true` \u2014 the host\u2019s own terminal sandbox engine then handles shell * commands and reads `chat.agent.sandbox.enabled` directly. */ @@ -215,7 +221,7 @@ export function claudePreferAgentHostSettingId(isSessionsWindow: boolean): strin * should unconditionally return `true` and callers can drop the gate entirely. */ export function shouldSurfaceLocalAgentHostProvider(provider: AgentProvider, configurationService: IConfigurationService, isSessionsWindow: boolean): boolean { - if (provider !== 'claude') { + if (provider !== CLAUDE_AGENT_PROVIDER_ID) { return true; } return configurationService.getValue(claudePreferAgentHostSettingId(isSessionsWindow)) === true; @@ -537,6 +543,7 @@ export interface IAgentSdkStarterSettings { readonly codexBinaryArgs?: readonly string[]; readonly claudeAgentEnabled?: boolean; readonly codexAgentEnabled?: boolean; + readonly byokModelsEnabled?: boolean; } export function buildAgentSdkEnv( @@ -561,6 +568,9 @@ export function buildAgentSdkEnv( if (settings.codexAgentEnabled !== undefined) { setIfMissing(AgentHostCodexAgentEnabledEnvVar, settings.codexAgentEnabled ? 'true' : 'false'); } + if (settings.byokModelsEnabled !== undefined) { + setIfMissing(AgentHostByokModelsEnabledEnvVar, settings.byokModelsEnabled ? 'true' : 'false'); + } return out; } @@ -657,7 +667,7 @@ export interface IAgentCreateSessionResult { /** * `true` when the agent only allocated an in-memory placeholder for this * session (no SDK session, no worktree, no on-disk state). Materialization - * happens lazily on the first {@link IAgent.sendMessage}, at which point + * happens lazily on the first {@link IAgentChats.sendMessage}, at which point * the agent fires {@link IAgent.onDidMaterializeSession}. The * {@link IAgentService} uses this flag to defer the `sessionAdded` protocol * notification so observers don't see the session in their list until it @@ -679,11 +689,29 @@ export interface IAgentMaterializeSessionEvent { export type AgentProvider = string; +/** Well-known agent provider id for the Claude agent-host backend. */ +export const CLAUDE_AGENT_PROVIDER_ID = 'claude' as const; + +/** + * Static capability facts an agent backend advertises about itself. Each flag + * is opt-in (absent means unsupported) so single-chat agents (e.g. Codex) can omit + * the bag entirely. Discovered over IPC alongside the rest of + * {@link IAgentDescriptor} and surfaced to the sessions UI so features are + * capability-gated instead of switched on the provider id. + * + * This is the IPC contract alias of the protocol-visible {@link AgentCapabilities} + * type (defined in the root-state protocol); both share a single canonical shape + * so a new flag added in one place is automatically reflected in the other. + */ +export type IAgentCapabilities = AgentCapabilities; + /** Metadata describing an agent backend, discovered over IPC. */ export interface IAgentDescriptor { readonly provider: AgentProvider; readonly displayName: string; readonly description: string; + /** Static capability flags the agent advertises (see {@link IAgentCapabilities}). */ + readonly capabilities?: IAgentCapabilities; } // ---- Auth types (RFC 9728 / RFC 6750 inspired) ----------------------------- @@ -792,6 +820,27 @@ export interface IAgentCreateSessionConfig { */ readonly turnIdMapping?: ReadonlyMap; }; + /** + * Import an existing (e.g. local) conversation into a brand-new session as + * real, editable turns. The provider translates {@link turns} into a + * Copilot event log seeded on disk and resumes the session so the turns are + * reconstituted as genuine backend events (editable / forkable / truncatable). + * + * The service layer assigns fresh UUID turn ids before handing the turns to + * the provider so the seeded event ids and the seeded protocol turns stay + * aligned. Mutually exclusive with {@link fork}. + */ + readonly importConversation?: { + readonly turns: readonly Turn[]; + readonly model?: ModelSelection; + }; + /** + * MCP-style opt-in progress token from the client's `createSession`. When + * set, the service reports any long-running session bring-up work — chiefly + * the lazy first-use SDK download — as `progress` notifications carrying + * this token, so the client can correlate them to this call. + */ + readonly progressToken?: string; } /** Options for creating an additional chat within a session. */ @@ -803,7 +852,7 @@ export interface IAgentCreateChatOptions { /** * Fork an existing chat into this new chat. The new chat starts * pre-populated with the source chat's turns up to and including - * {@link IAgentCreateChatForkSource.turnId}, and its backing conversation + * {@link IAgentCreateChatForkSource.turnId}, and its backing chat * is forked from the source so it can continue independently. */ readonly fork?: IAgentCreateChatForkSource; @@ -818,11 +867,200 @@ export interface IAgentCreateChatForkSource { /** * Maps old source turn IDs to fresh turn IDs for the forked chat. Populated * by the agent service so the agent can remap per-turn data (e.g. SDK event - * ID mappings) in the forked conversation's database. + * ID mappings) in the forked chat's database. */ readonly turnIdMapping?: ReadonlyMap; } +/** Result of {@link IAgentChats.createChat}: the opaque blob to persist for restore. */ +export interface IAgentCreateChatResult { + /** + * Opaque, agent-owned token the orchestrator persists verbatim in the chat + * catalog and hands back to {@link IAgent.materializeChat} on + * restore. The orchestrator never parses it. `undefined` means nothing to + * persist (e.g. the agent keeps no resumable backing). + */ + readonly providerData?: string; + /** + * The SDK-level session URI that backs this peer chat, when the agent mints + * one in the same session store its own {@link IAgent.listSessions} enumerates + * (e.g. Claude). First-class and non-opaque — unlike {@link providerData} the + * orchestrator reads it to correlate and suppress the backing session so it + * never surfaces as a top-level session. `undefined` when the agent keeps no + * separately-enumerable backing session. + */ + readonly backingSession?: URI; +} + +/** Payload of {@link IAgent.onDidChangeChatData}. */ +export interface IAgentChatDataChange { + /** The peer chat whose backing chat's blob changed. */ + readonly chat: URI; + /** The new opaque blob to persist (replaces any previously stored value). */ + readonly providerData: string; +} + +/** A legacy peer chat enumerated by {@link IAgent.listLegacyChats} for one-time migration. */ +export interface IAgentLegacyChat { + /** The peer chat's channel URI (see {@link buildChatUri}). */ + readonly uri: URI; + /** The opaque, agent-owned backing blob, encoded as {@link materializeChat} expects. */ + readonly providerData?: string; +} + +/** + * Identifies the parent that spawned a chat. The orchestrator records + * it as the spawned chat's {@link ChatOriginKind.Tool} origin so clients can + * render the parent/child relationship (e.g. a sub-agent "team" member spawned + * by a tool call in the parent chat). + */ +export interface IAgentSpawnedChatParent { + /** The parent chat (chat) URI whose tool call performed the spawn. */ + readonly chat: URI; + /** The id of the tool call in the parent that spawned this chat. */ + readonly toolCallId: string; +} + +/** + * Payload of {@link IAgent.onDidSpawnChat}: a new chat the + * agent spawned itself (e.g. a sub-agent delegated by a tool call), as opposed + * to a user-driven chat created via + * {@link IAgentChats.createChat}. + */ +export interface IAgentSpawnChatEvent { + /** The session URI the spawned chat belongs to. */ + readonly session: URI; + /** The spawned chat's channel URI (the new chat). */ + readonly chat: URI; + /** + * The parent that spawned it, when the spawn was delegated by a tool call. + * Recorded as the chat's tool origin in the catalog. Absent for a + * top-level, agent-initiated chat with no spawning tool call. + */ + readonly parent?: IAgentSpawnedChatParent; + /** Optional display title for the spawned chat. */ + readonly title?: string; +} + +/** Max characters for a subagent tab title before it is ellipsized. */ +const SUBAGENT_CHAT_TITLE_MAX_LENGTH = 60; + +/** + * Builds the tab title for a subagent peer chat. Prefers the concise + * per-task description (so two subagents of the same type still get + * distinct, meaningful names), truncating it so an over-long value never + * blows out the tab strip or the Subagents dropdown; falls back to the + * agent type's display name, then a generic label. Shared by the live + * spawn path and the restore path so both name subagent tabs identically. + */ +export function subagentChatTitle(taskDescription: string | undefined, agentDisplayName: string | undefined): string { + const task = taskDescription?.trim(); + if (task) { + return truncate(task, SUBAGENT_CHAT_TITLE_MAX_LENGTH); + } + return agentDisplayName?.trim() || 'Subagent'; +} + +/** + * Maps agent `subagent_*` signals to the unified chat catalog's + * spawn/end events. Shared by the agents' spawn bridges and the orchestrator so + * subagent membership has one derivation. + */ +export namespace SubagentChatSignal { + + /** + * Derives the {@link IAgentSpawnChatEvent} for a `subagent_started` signal, + * addressing the subagent by the stable {@link buildSubagentChatUri} and + * recording the spawning tool call as its parent edge. Returns `undefined` + * for any other signal (or an unmappable chat URI). + */ + export function toSpawnEvent(signal: AgentSignal): IAgentSpawnChatEvent | undefined { + if (signal.kind !== 'subagent_started') { + return undefined; + } + let session: string; + try { + session = parseRequiredSessionUriFromChatUri(signal.chat); + } catch { + return undefined; + } + return { + session: URI.parse(session), + chat: URI.parse(buildSubagentChatUri(session, signal.toolCallId)), + parent: { chat: signal.chat, toolCallId: signal.toolCallId }, + // Prefer the concise per-task description so two subagents of the same + // type still get distinct, meaningful tab names; fall back to the agent + // type's display name. Truncate so an over-long description never blows + // out the tab strip or the Subagents dropdown. + title: subagentChatTitle(signal.taskDescription, signal.agentDisplayName), + }; + } +} + +// ---- Chat surface -------------------------------------------------- + +/** + * The chat-addressed operation surface an agent exposes for the chats + * within a session. + * + * Every operation method addresses a chat by a concrete chat channel URI: + * the default chat channel for a session's DEFAULT chat, or an additional + * chat's own channel URI. The orchestrator ({@link IAgentService}) owns the + * feature-level `(session, chat)` to chat-channel mapping and only ever calls + * these operations with a concrete chat URI. This replaces the legacy + * `(session, chat?)` parameter pairs and the per-agent default-chat handling on + * {@link IAgent}. + * + * Optional on {@link IAgent}: agents implement this incrementally (waves + * C2/C3/C4). Until an agent exposes it, {@link IAgentService} falls back to the + * agent's legacy `(session, chat?)` methods via a thin adapter. + */ +export interface IAgentChats { + /** + * Create a fresh additional chat within the session the `chat` URI belongs + * to, sharing the session's working directory, model, agent, and + * customizations. `chat` is the client-chosen channel URI the new chat is + * addressed by; its parent session is derived from it. + * Returns the opaque {@link IAgentCreateChatResult} blob to persist for + * restore (or `void` when the agent keeps no resumable backing). + */ + createChat(chat: URI, options?: IAgentCreateChatOptions): Promise; + + /** + * Fork a new chat from an existing one. The new `chat` + * inherits `source`'s backing up to and including + * {@link IAgentCreateChatForkSource.turnId} and then continues + * independently. The new chat's parent session is derived from its URI. + */ + fork(chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise; + + /** + * Dispose an additional chat created via + * {@link createChat}/{@link fork}, freeing its backing. A session's + * default chat cannot be disposed in isolation; it lives and dies + * with the session. + */ + disposeChat(chat: URI): Promise; + + /** Send a user message into `chat`. */ + sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise; + + /** Abort the in-flight turn for `chat`. */ + abort(chat: URI): Promise; + + /** Change the model for `chat`. */ + changeModel(chat: URI, model: ModelSelection): Promise; + + /** + * Change (or clear) the selected custom agent for `chat`. Passing + * `undefined` clears the selection (provider default behavior). + */ + changeAgent(chat: URI, agent: AgentSelection | undefined): Promise; + + /** Reconstruct the turns for `chat` (used on restore). */ + getMessages(chat: URI): Promise; +} + export interface IAgentResolveSessionConfigParams { readonly provider?: AgentProvider; readonly workingDirectory?: URI; @@ -939,6 +1177,31 @@ export interface IAgentSubagentStartedSignal { readonly agentName: string; readonly agentDisplayName: string; readonly agentDescription?: string; + /** + * The spawning Task tool's short (typically 3-5 word) `description` + * input, e.g. "Review package.json structure". Distinct from + * {@link agentDescription} (the agent *type*'s long role blurb) and + * {@link agentDisplayName} (the agent type's name). Preferred as the + * peer chat's tab title because it is concise and per-task, so two + * subagents of the same type still get distinct, meaningful names. + * Absent when the harness does not surface a task description. + */ + readonly taskDescription?: string; + /** + * If set, the spawning tool call ({@link toolCallId}) itself lives + * inside another subagent's chat — this is the tool call **one level up** + * from the spawning tool (its parent), i.e. the tool that spawned the + * immediate parent chat. The host uses it to route the + * subagent-discovery side effect (the `ChatToolCallContentChanged` + * block that lets clients find the child chat) to that immediate parent + * chat rather than the top-level {@link chat}. Because subagent chats + * are flat (all keyed off the root session + the spawning tool id), + * this single one-hop reference resolves the correct parent chat at + * ANY nesting depth — no per-level chain is needed. Absent for a + * top-level subagent, whose spawning tool call lives directly in + * {@link chat}. + */ + readonly parentToolCallId?: string; } /** @@ -1080,6 +1343,20 @@ export interface IAgent { */ setServerToolHost?(host: IAgentServerToolHost): void; + // ---- Chat surface ------------------------------------------------------ + // + // `chats` is the chat-addressed operation surface. Its chats are addressed + // by concrete chat channel URIs. The orchestrator ({@link IAgentService}) + // owns the feature-level `(session, chat)` to chat-channel mapping. + + /** + * Chat-addressed surface for the chats within a session (send/abort/ + * change model/agent, create/fork/dispose chats, read history). + */ + readonly chats: IAgentChats; + + // ---- Session lifecycle / configuration --------------------------------- + /** Create a new session. Returns server-owned session metadata. */ createSession(config?: IAgentCreateSessionConfig): Promise; @@ -1089,42 +1366,69 @@ export interface IAgent { /** Return dynamic completions for a session configuration property. */ sessionConfigCompletions(params: IAgentSessionConfigCompletionsParams): Promise; - /** Send a user message into a chat within an existing session. */ - sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise; + /** + * Re-attach an agent's in-memory backing for a peer chat on session + * restore, decoding the opaque `providerData` produced earlier by + * {@link IAgentChats.createChat} (or the latest + * {@link onDidChangeChatData}). After this resolves the agent MUST + * be able to serve {@link getSessionMessages}/ + * {@link IAgentChats.sendMessage} for `chat`. + * Best-effort: implementations SHOULD NOT throw on a corrupt/unknown blob — + * log and no-op so the orchestrator restores the chat with history but no + * live backing. `providerData` is `undefined` only for legacy entries with + * no stored blob, in which case the agent MAY consult its own legacy + * persistence once to recover the backing. + */ + materializeChat?(chat: URI, providerData: string | undefined): Promise; /** - * Create an additional chat within an existing session, backed by a new - * conversation that shares the session's scope (working directory, model, - * agent, customizations). Optional: harnesses that do not support multiple - * concurrent chats simply omit it. The `chat` URI is the client-chosen - * channel the new chat will be addressed by. + * Migration-only enumeration of a session's peer chats persisted in the + * agent's OWN legacy format (predating the orchestrator-owned catalog). The + * orchestrator calls this once, when its own catalog is absent, to drain the + * legacy chats into {@link PEER_CHATS_METADATA_KEY}; subsequent restores read + * the orchestrator catalog and never consult this again. Each entry's + * `providerData` uses the same encoding {@link IAgentChats.createChat} + * produces and {@link materializeChat} decodes. Agents with no legacy + * format (e.g. Codex) omit this method. */ - createChat?(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise; + listLegacyChats?(session: URI): Promise; /** - * Dispose an additional chat created via {@link createChat}, freeing its - * backing conversation. The session's default chat cannot be disposed in - * isolation; it lives and dies with the session. + * Fires when a peer chat's opaque `providerData` changes after creation + * (e.g. per-chat model switch, fork remap). The orchestrator re-persists the + * blob. Agents whose blob is immutable never fire this. */ - disposeChat?(session: URI, chat: URI): Promise; + readonly onDidChangeChatData?: Event; + + // ---- Spawned chat (membership) channel ------------------------- + // + // First-class membership channel for chats the agent spawns itself + // (e.g. sub-agent / "team" member chats delegated by a tool call), + // as opposed to user-driven chats created via + // {@link IAgentChats.createChat}. The orchestrator + // ({@link IAgentService}) routes these straight into the chat catalog + // (addChat/removeChat) so harness-spawned and user-driven chats share ONE + // membership path. Agents that never spawn chats omit both events. /** - * Returns the persisted catalog of additional (non-default) peer chats for a - * session as their channel URIs. Used to re-register peer chats (and seed - * their history) when a session is restored after a process restart. - * Optional: harnesses without multi-chat persistence omit it. + * Fires when the agent spawns a new chat within a session (e.g. a + * sub-agent delegated by a tool call). The orchestrator records it in the + * chat catalog, preserving the {@link IAgentSpawnChatEvent.parent} + * spawn edge as the chat's {@link ChatOriginKind.Tool} origin. */ - getChats?(session: URI): Promise; + readonly onDidSpawnChat?: Event; /** * Called when the session's pending (steering) message changes. * The agent harness decides how to react — e.g. inject steering - * mid-turn via `mode: 'immediate'`. + * mid-turn via `mode: 'immediate'`. When `chat` is provided (an additional + * peer chat's URI), the steering targets that chat's chat rather + * than the session's default chat. * * Queued messages are consumed on the server side and are not * forwarded to the agent; `queuedMessages` will always be empty. */ - setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void; + setPendingMessages?(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], chat?: URI): void; /** * Retrieve the reconstructed turns for a session, used when restoring @@ -1149,25 +1453,6 @@ export interface IAgent { /** Dispose a session, freeing resources. */ disposeSession(session: URI): Promise; - /** Abort the current turn, stopping any in-flight processing. When `chat` - * is provided, only that chat's in-flight turn is aborted. */ - abortSession(session: URI, chat?: URI): Promise; - - /** Change the model for an existing session. When `chat` is provided (an - * additional peer chat's URI), the change targets that chat's conversation - * rather than the session's default chat. */ - changeModel(session: URI, model: ModelSelection, chat?: URI): Promise; - - /** - * Change (or clear) the selected custom agent for an existing session. - * Passing `undefined` clears the selection and resets the session to no - * selected custom agent (provider default behavior). Optional so non- - * Copilot agents can opt out. When `chat` is provided (an additional peer - * chat's URI), the change targets that chat's conversation rather than the - * session's default chat. - */ - changeAgent?(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise; - /** Respond to a pending permission request from the SDK. */ respondToPermissionRequest(requestId: string, approved: boolean): void; @@ -1196,6 +1481,15 @@ export interface IAgent { */ readonly onDidCustomizationsChange?: Event; + /** + * Fires when this agent needs the client to (re-)authenticate a + * protected resource — for example after a runtime transport-mode flip + * makes a previously-unneeded credential required. The host stamps the + * root channel and forwards it verbatim as an `auth/required` + * notification; clients respond via {@link authenticate}. + */ + readonly onDidRequireAuth?: Event>; + /** * Returns the host-owned customizations this agent currently exposes. * @@ -1218,11 +1512,22 @@ export interface IAgent { authenticate(resource: string, token: string): Promise; /** - * Truncate a session's history. If `turnId` is provided, keeps turns up to + * Optional hook for provider-owned session resources that are not advertised + * as root agent protected resources, such as MCP server OAuth challenges. + */ + handleAuthenticationToken?(params: AuthenticateParams): Promise; + + /** + * Truncate a chat's history. If `turnId` is provided, keeps turns up to * and including that turn. If omitted, all turns are removed. + * + * `chat` identifies which chat to truncate: the session's default chat + * (addressed by the session's default chat URI) or a peer (non-default) + * chat, which has its own backing. + * * Optional — not all providers support truncation. */ - truncateSession?(session: URI, turnId?: string): Promise; + truncateSession?(session: URI, turnId: string | undefined, chat: URI): Promise; /** * Notifies the provider that a session's archived state has changed. @@ -1261,7 +1566,7 @@ export interface IAgent { * @param session The session the tool call belongs to. * @param chat The chat channel the tool call was issued on, when known. * Agents that track peer chats separately from the default chat (e.g. - * copilot) use this to route the completion to the right conversation; + * copilot) use this to route the completion to the right chat; * agents without peer chats ignore it and resolve by `session`. * @param toolCallId The id of the tool call being completed. * @param result The result of the tool call. @@ -1276,6 +1581,12 @@ export interface IAgent { */ setCustomizationEnabled(id: string, enabled: boolean): void; + /** Request a session MCP server start/restart by customization id. */ + startMcpServer?(session: URI, id: string): Promise; + + /** Request a session MCP server stop by customization id. */ + stopMcpServer?(session: URI, id: string): Promise; + /** Gracefully shut down all sessions. */ shutdown(): Promise; @@ -1345,7 +1656,7 @@ export interface IAgentService { /** * Create an additional chat within an existing session. Spins up the - * backing conversation in the harness (sharing the session's scope) and + * backing chat in the harness (sharing the session's session) and * registers the chat in the session's catalog so subscribers observe a * `session/chatAdded` action. The `chat` URI is the client-chosen channel. */ diff --git a/src/vs/platform/agentHost/common/changesetUri.ts b/src/vs/platform/agentHost/common/changesetUri.ts index 3de44b3dced362..80e182e8c89843 100644 --- a/src/vs/platform/agentHost/common/changesetUri.ts +++ b/src/vs/platform/agentHost/common/changesetUri.ts @@ -87,7 +87,7 @@ export const compareTurnsChangesetDescription = (): string => localize('compareT * Returns `undefined` only when no branch name is known at all, so * callers can omit the description entirely. */ -export function formatSessionChangesetDescription(gitState: ISessionGitState): string | undefined { +export function formatBranchChangesetDescription(gitState: ISessionGitState): string | undefined { const { baseBranchName, branchName, upstreamBranchName } = gitState; // Use branch name @@ -292,10 +292,28 @@ export function parseCompareTurnsChangesetUri(uri: URI): { sessionUri: URI; orig * compare-turns diffs construct the URI themselves from two known * turn ids and subscribe directly. */ -export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { +export function buildDefaultChangesetCatalog(sessionUri: URI, gitState?: ISessionGitState): Changeset[] { + if (!gitState) { + return [{ + label: sessionChangesetLabel(), + description: sessionChangesetDescription(), + uriTemplate: buildSessionChangesetUri(sessionUri), + changeKind: ChangesetKind.Session + }, + { + label: thisTurnChangesetLabel(), + description: thisTurnChangesetDescription(), + uriTemplate: buildTurnChangesetUriTemplate(sessionUri), + changeKind: ChangesetKind.Turn + }] satisfies Changeset[]; + } + return [ { label: branchChangesetLabel(), + description: gitState + ? formatBranchChangesetDescription(gitState) + : undefined, uriTemplate: buildBranchChangesetUri(sessionUri), changeKind: ChangesetKind.Branch }, @@ -323,5 +341,5 @@ export function buildDefaultChangesetCatalogue(sessionUri: URI): Changeset[] { uriTemplate: buildCompareTurnsChangesetUriTemplate(sessionUri), changeKind: ChangesetKind.Compare } - ]; + ] satisfies Changeset[]; } diff --git a/src/vs/platform/agentHost/common/copilotCliConfig.ts b/src/vs/platform/agentHost/common/copilotCliConfig.ts new file mode 100644 index 00000000000000..73d53a4ea08b2e --- /dev/null +++ b/src/vs/platform/agentHost/common/copilotCliConfig.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../nls.js'; +import { createSchema, schemaProperty } from './agentHostSchema.js'; +import type { ModelSelection } from './state/protocol/state.js'; + +/** + * Root-config keys consumed exclusively by the Copilot CLI provider + * (`CopilotSessionLauncher` / `CopilotAgent`) — kept out of the + * provider-agnostic `agentHostCustomizationConfigSchema`. + */ +export const enum CopilotCliConfigKey { + /** Use Agent Host's custom terminal tool instead of the SDK's default. Off by default. */ + EnableCustomTerminalTool = 'enableCustomTerminalTool', + /** Enable the rubber duck critic subagent. */ + RubberDuck = 'rubberDuck', + /** Apply Opus 4.8-tuned system-prompt overrides on Opus 4.8 models. Off by default. */ + Opus48Prompt = 'opus48Prompt', + /** Override reasoning effort regardless of the picker value; unsupported values are ignored. */ + ReasoningEffortOverride = 'reasoningEffortOverride', + /** Per-model capability overrides (family aliases) keyed by model id. */ + ModelCapabilityOverrides = 'modelCapabilityOverrides', +} + +// VS Code `chat.agentHost.*` setting IDs that feed the root-config keys above, +// kept beside the keys they forward to. Registered in `chat.shared.contribution.ts` +// and forwarded into the host's root config by `AgentHostCopilotCliSettingsContribution` +// (and, for the terminal-tool toggle, `AgentHostTerminalContribution`). + +export const AgentHostCustomTerminalToolEnabledSettingId = 'chat.agentHost.customTerminalTool.enabled'; + +export const AgentHostOpus48PromptEnabledSettingId = 'chat.agentHost.opus48Prompt.enabled'; + +export const AgentHostReasoningEffortOverrideSettingId = 'chat.agentHost.reasoningEffortOverride'; + +export const AgentHostModelCapabilityOverridesSettingId = 'chat.agentHost.modelCapabilityOverrides'; + +/** Per-model capability override; the agent-host equivalent of the extension's `IModelCapabilityOverride`. */ +interface ICopilotCliModelCapabilityOverride { + /** Alias the model's family for prompt/capability routing (e.g. `"claude-opus-4-8"`). */ + readonly family?: string; +} + +/** Map of model id → capability override. */ +export type CopilotCliModelCapabilityOverrides = Record; + +export const copilotCliConfigSchema = createSchema({ + [CopilotCliConfigKey.EnableCustomTerminalTool]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.enableCustomTerminalTool.title', "Use Agent Host Terminal Tool"), + description: localize('agentHost.config.enableCustomTerminalTool.description', "When enabled, Copilot SDK sessions use Agent Host's terminal tool override instead of the SDK's default terminal behavior."), + default: false, + }), + [CopilotCliConfigKey.RubberDuck]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.rubberDuck.title', "Rubber Duck Agent"), + description: localize('agentHost.config.rubberDuck.description', "When enabled, the coding agent uses a rubber duck critic subagent to review code changes using a complementary model."), + default: false, + }), + [CopilotCliConfigKey.Opus48Prompt]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.opus48Prompt.title', "Opus 4.8 Agent Prompt"), + description: localize('agentHost.config.opus48Prompt.description', "When enabled, Copilot SDK sessions running a Claude Opus 4.8 model apply Opus 4.8-tuned system-prompt section overrides on top of the default system message."), + default: false, + }), + [CopilotCliConfigKey.ReasoningEffortOverride]: schemaProperty({ + type: 'string', + title: localize('agentHost.config.reasoningEffortOverride.title', "Reasoning Effort Override"), + description: localize('agentHost.config.reasoningEffortOverride.description', "Overrides the reasoning effort for Copilot SDK sessions regardless of the per-model picker value. Set it to a level the selected model supports (e.g. `low`, `medium`, `high`, `xhigh`); a value that isn't a recognized effort level is ignored and the session falls back to the picker value. Only affects Copilot SDK sessions; intended for experimentation."), + default: '', + }), + [CopilotCliConfigKey.ModelCapabilityOverrides]: schemaProperty({ + type: 'object', + title: localize('agentHost.config.modelCapabilityOverrides.title', "Model Capability Overrides"), + description: localize('agentHost.config.modelCapabilityOverrides.description', "Per-model capability overrides for Copilot SDK sessions, keyed by model id. Aliasing a model id to a known `family` routes it to that family's tuned system prompt without changing the model id sent to the runtime. Only affects Copilot SDK sessions; intended for experimentation."), + additionalProperties: { + type: 'object', + title: localize('agentHost.config.modelCapabilityOverrides.entry.title', "Capability Override"), + description: localize('agentHost.config.modelCapabilityOverrides.entry.description', "A single capability override. The property key is the model id."), + properties: { + family: { + type: 'string', + title: localize('agentHost.config.modelCapabilityOverrides.family.title', "Family"), + description: localize('agentHost.config.modelCapabilityOverrides.family.description', "Alias the model's family for prompt/capability routing (e.g. `claude-opus-4-8`)."), + }, + }, + }, + default: {}, + }), +}); + +/** Returns the configured family alias for `modelId`, or `undefined`. Malformed entries are treated as unset. */ +function getModelFamilyAlias(overrides: CopilotCliModelCapabilityOverrides | undefined, modelId: string): string | undefined { + const family = overrides?.[modelId]?.family; + return typeof family === 'string' && family.length > 0 ? family : undefined; +} + +/** + * Substitutes a configured family alias for the model id so an aliased preview model + * routes to a known family's prompt contributor. `model.config` picker values are + * preserved; returns the input unchanged when no alias applies. + */ +export function applyModelFamilyAlias(model: ModelSelection | undefined, overrides: CopilotCliModelCapabilityOverrides | undefined): ModelSelection | undefined { + if (!model) { + return undefined; + } + const family = getModelFamilyAlias(overrides, model.id); + return family ? { ...model, id: family } : model; +} diff --git a/src/vs/platform/agentHost/common/githubEndpoints.ts b/src/vs/platform/agentHost/common/githubEndpoints.ts new file mode 100644 index 00000000000000..c64c98a6f82d2d --- /dev/null +++ b/src/vs/platform/agentHost/common/githubEndpoints.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../base/common/uri.js'; +import { ProtectedResourceMetadata } from './state/protocol/state.js'; + +/** + * The GitHub endpoints an agent host talks to, derived from an optional + * GitHub Enterprise base URI. All values are string URIs with no trailing slash. + */ +export interface IGitHubEndpoints { + /** REST API base (e.g. `https://api.github.com`), used as the resource identifier and the REST host. */ + readonly apiBaseUri: string; + /** GraphQL endpoint (distinct from `apiBaseUri` for on-prem: `/api/graphql`, not `/api/v3/graphql`). */ + readonly graphQlUri: string; + /** OAuth authorization server URI, advertised in `authorization_servers`. */ + readonly oauthServer: string; + /** + * The configured GitHub Enterprise host (authority only, e.g. `acme.ghe.com`), + * or `undefined` for github.com. Used to point the Copilot CLI at an enterprise + * host via `COPILOT_GH_HOST`. + */ + readonly enterpriseHost: string | undefined; +} + +/** Canonical github.com endpoints, used when no enterprise URI is configured. */ +const GITHUB_DOT_COM_ENDPOINTS: IGitHubEndpoints = { + apiBaseUri: 'https://api.github.com', + graphQlUri: 'https://api.github.com/graphql', + oauthServer: 'https://github.com/login/oauth', + enterpriseHost: undefined, +}; + +/** + * Derives the {@link IGitHubEndpoints} for a GitHub Enterprise base URI, mirroring + * the URL derivation in the built-in `github-authentication` extension + * (`githubServer.ts` / `common/env.ts`): + * + * - unset / empty / unparseable → github.com defaults (byte-for-byte, preserving + * the resource identifiers used by every non-enterprise install). + * - GitHub Enterprise **Cloud** (authority ends in `.ghe.com`) → API on an `api.` + * subdomain: `https://api.`. + * - GitHub Enterprise **Server** (on-prem) → API under `/api/v3`, GraphQL under + * `/api/graphql`. + * + * The OAuth server is always `:///login/oauth` for enterprise. + */ +export function deriveGitHubEndpoints(enterpriseUri: string | undefined): IGitHubEndpoints { + if (!enterpriseUri) { + return GITHUB_DOT_COM_ENDPOINTS; + } + + let uri: URI; + try { + uri = URI.parse(enterpriseUri); + } catch { + return GITHUB_DOT_COM_ENDPOINTS; + } + + const authority = uri.authority; + if (!authority) { + return GITHUB_DOT_COM_ENDPOINTS; + } + + // A github.com authority is never a GitHub Enterprise host — treat it as the + // default rather than deriving a nonsensical `github.com/api/v3`. Guards the + // case where the enterprise host can't be resolved and falls back to github.com. + if (authority === 'github.com' || authority === 'www.github.com' || authority === 'api.github.com') { + return GITHUB_DOT_COM_ENDPOINTS; + } + + const scheme = uri.scheme || 'https'; + const isCloud = /\.ghe\.com$/.test(authority); + return { + apiBaseUri: isCloud ? `${scheme}://api.${authority}` : `${scheme}://${authority}/api/v3`, + graphQlUri: isCloud ? `${scheme}://api.${authority}/graphql` : `${scheme}://${authority}/api/graphql`, + oauthServer: `${scheme}://${authority}/login/oauth`, + enterpriseHost: authority, + }; +} + +/** + * The GitHub Copilot protected resource for the given endpoints. Shared by the + * endpoint service and tests so the resource identity is defined once. + */ +export function gitHubCopilotResource(endpoints: IGitHubEndpoints): ProtectedResourceMetadata { + return { + resource: endpoints.apiBaseUri, + resource_name: 'GitHub Copilot', + authorization_servers: [endpoints.oauthServer], + scopes_supported: ['read:user', 'user:email'], + required: true, + }; +} + +/** The GitHub repository protected resource for the given endpoints. */ +export function gitHubRepoResource(endpoints: IGitHubEndpoints): ProtectedResourceMetadata { + return { + resource: `${endpoints.apiBaseUri}/repos`, + resource_name: 'GitHub Repository', + authorization_servers: [endpoints.oauthServer], + scopes_supported: ['repo'], + required: false, + }; +} diff --git a/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts b/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.ts new file mode 100644 index 00000000000000..a072fc84bb89bf --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentChangesetFileMeta.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 { Mutable } from '../../../../base/common/types.js'; +import { ChangesetFile } from '../state/sessionState.js'; + +interface IChangesetFileMeta { + readonly reviewed?: boolean; +} + +export function readChangesetFileMeta(source: ChangesetFile): IChangesetFileMeta | undefined { + const meta = source._meta; + if (!meta) { + return undefined; + } + + const result: Mutable = {}; + if (typeof meta['reviewed'] === 'boolean') { result.reviewed = meta['reviewed']; } + + return result; +} diff --git a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts index 5cdc499e1fd230..92c98a3677ce1d 100644 --- a/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts +++ b/src/vs/platform/agentHost/common/meta/agentCompletionAttachmentMeta.ts @@ -23,6 +23,11 @@ export interface ICommandCompletionAttachmentMeta { readonly command: string; /** Optional human-readable description of the command. */ readonly description?: string; + /** + * Optional hint describing the argument the command expects. Rendered as + * inline placeholder (ghost text) after an accepted command completion. + */ + readonly argumentHint?: string; } /** @@ -65,6 +70,7 @@ export function readCompletionAttachmentMeta(attachment: SimpleMessageAttachment kind: 'command', command: meta['command'], ...(typeof meta['description'] === 'string' ? { description: meta['description'] } : {}), + ...(typeof meta['argumentHint'] === 'string' ? { argumentHint: meta['argumentHint'] } : {}), }; } if (typeof meta['uri'] === 'string') { @@ -90,9 +96,25 @@ export function toCommandCompletionAttachmentMeta(meta: ICommandCompletionAttach if (meta.description !== undefined) { result['description'] = meta.description; } + if (meta.argumentHint !== undefined) { + result['argumentHint'] = meta.argumentHint; + } return result; } +/** + * Reads the well-known `argumentHint` from a raw completion attachment `_meta` + * bag. Kept as the single seam that consumers use to obtain the hint, so a + * future promotion of `argumentHint` to a first-class attachment field only + * needs to change this reader. Returns `undefined` when absent or wrong-typed. + */ +export function getCommandArgumentHint(meta: Record | undefined): string | undefined { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return undefined; + } + return typeof meta['argumentHint'] === 'string' ? meta['argumentHint'] : undefined; +} + /** * Serializes a typed {@link ISkillCompletionAttachmentMeta} into the `_meta` * record, dropping `undefined` entries. Build a skill completion's `_meta` diff --git a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts index f7e1c46b12f7af..ad993f75654f5f 100644 --- a/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts +++ b/src/vs/platform/agentHost/common/meta/agentFeedbackAnnotations.ts @@ -31,6 +31,15 @@ export const FEEDBACK_ANNOTATION_META_KEY = 'vscode.agentFeedback'; */ export const VIEW_UNREVIEWED_COMMENTS_TOOL_NAME = 'viewUnreviewedComments'; +/** + * Name of the agent host server tool that adds a comment (agent feedback) to a + * file range. Shared here (in the layer-neutral `common` module) so the + * node-side server tool implementation and the browser-side chat adapter that + * renders its tool call agree on the name without drifting. The agent sees this + * name directly (Copilot) or prefixed as `mcp__host__` (Claude). + */ +export const ADD_COMMENT_TOOL_NAME = 'addComment'; + /** * Whether {@link toolName} (a tool name as seen on a tool call) refers to the * {@link VIEW_UNREVIEWED_COMMENTS_TOOL_NAME} server tool. Accepts both the bare @@ -40,6 +49,15 @@ export function isViewUnreviewedCommentsTool(toolName: string): boolean { return toolName === VIEW_UNREVIEWED_COMMENTS_TOOL_NAME || toolName.endsWith(`__${VIEW_UNREVIEWED_COMMENTS_TOOL_NAME}`); } +/** + * Whether {@link toolName} (a tool name as seen on a tool call) refers to the + * {@link ADD_COMMENT_TOOL_NAME} server tool. Accepts both the bare name and the + * Claude `mcp____` prefixed form. + */ +export function isAddCommentTool(toolName: string): boolean { + return toolName === ADD_COMMENT_TOOL_NAME || toolName.endsWith(`__${ADD_COMMENT_TOOL_NAME}`); +} + /** * Origin of a feedback item. String values match the client-side * `AgentFeedbackKind` enum so a value written by either side decodes on the diff --git a/src/vs/platform/agentHost/common/meta/browserViewAttachments.ts b/src/vs/platform/agentHost/common/meta/browserViewAttachments.ts new file mode 100644 index 00000000000000..ef8d7637608b8f --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/browserViewAttachments.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isString } from '../../../../base/common/types.js'; +import { MessageAttachmentKind, type MessageAttachment, type SimpleMessageAttachment } from '../state/protocol/state.js'; + +export const BrowserViewAttachmentDisplayKind = 'browser'; +export const BrowserViewAttachmentMetadataKey = 'browserView'; + +export interface IBrowserViewAttachmentMetadata { + readonly browserId: string; + readonly browserUri: string; +} + +export function isBrowserViewAttachment(attachment: MessageAttachment): attachment is SimpleMessageAttachment { + return attachment.type === MessageAttachmentKind.Simple && attachment.displayKind === BrowserViewAttachmentDisplayKind; +} + +export function getBrowserViewAttachmentMetadata(attachment: MessageAttachment): IBrowserViewAttachmentMetadata | undefined { + if (!isBrowserViewAttachment(attachment)) { + return undefined; + } + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced browser view slot; validated below. + const metadata = attachment._meta?.[BrowserViewAttachmentMetadataKey]; + if (!isRecord(metadata) || !isString(metadata.browserId) || !isString(metadata.browserUri)) { + return undefined; + } + return { browserId: metadata.browserId, browserUri: metadata.browserUri }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/vs/platform/agentHost/common/sessionConfigKeys.ts b/src/vs/platform/agentHost/common/sessionConfigKeys.ts index cea7ce03167521..69d341e700c5ff 100644 --- a/src/vs/platform/agentHost/common/sessionConfigKeys.ts +++ b/src/vs/platform/agentHost/common/sessionConfigKeys.ts @@ -26,6 +26,8 @@ export const enum SessionConfigKey { Branch = 'branch', /** `'mode'` — agent execution mode (interactive / plan / autopilot). */ Mode = 'mode', + /** `'worktreeBranchPrefix'` — prefix for the worktree branch name. */ + WorktreeBranchPrefix = 'worktreeBranchPrefix', } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 4800495bc63a61..33bc7e3b597ef0 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -60,8 +60,45 @@ export interface IFileEditContent { afterContent?: Uint8Array; } +// ---- Reviewed-file types ------------------------------------------------ + +/** + * A record of a file having been reviewed by the user at a specific content + * nonce. Returned by {@link ISessionDatabase.getReviewedFiles} and + * {@link ISessionDatabase.getReviewedFilesForUri}. + */ +export interface IReviewedFileRecord { + /** The reviewed file. */ + uri: URI; + /** Content version/hash captured at review time. */ + nonce: string; +} + // ---- Session database --------------------------------------------------- +/** + * A host-injected ("local") turn: a completed protocol `Turn` the agent SDK + * never saw — e.g. the `/rename` acknowledgement or a `!command` terminal run. + * These are persisted separately from SDK turns so they survive reload, and are + * interleaved back into the SDK-derived turns on restore. + */ +export interface ILocalTurnRecord { + /** The local turn's id (matches the payload `Turn.id`). */ + turnId: string; + /** The chat this local turn belongs to (its channel URI string). */ + chatUri: string; + /** + * Id of the preceding concrete (SDK-backed) turn this local turn is + * anchored after, or `undefined` when it precedes any real turn. + */ + anchorTurnId: string | undefined; + /** Monotonic ordering among local turns (used to interleave on restore). */ + seq: number; + /** JSON-serialized protocol `Turn`. */ + payload: string; +} + + /** * A disposable handle to a per-session SQLite database backed by * `@vscode/sqlite3`. @@ -150,6 +187,25 @@ export interface ISessionDatabase extends IDisposable { */ deleteAllTurns(): Promise; + // ---- Local (host-injected) turns ------------------------------------- + + /** + * Persist a host-injected local turn (e.g. `/rename` or `!command`). + * Replaces any existing record with the same `turnId`. + */ + insertLocalTurn(record: ILocalTurnRecord): Promise; + + /** + * Retrieve all persisted local turns in this session, in `seq` order. + * Callers filter by {@link ILocalTurnRecord.chatUri} for a given chat. + */ + getLocalTurns(): Promise; + + /** + * Delete the local turns with the given ids. Ids not present are ignored. + */ + deleteLocalTurns(turnIds: readonly string[]): Promise; + /** * Store a file-edit snapshot (metadata + content) for a tool invocation * within a turn. @@ -220,6 +276,36 @@ export interface ISessionDatabase extends IDisposable { */ remapTurnIds(mapping: ReadonlyMap): Promise; + // ---- Reviewed files -------------------------------------------------- + + /** + * Mark a file (identified by URI + content nonce) as reviewed by the user. + * Idempotent — re-marking the same `(uri, nonce)` pair is a no-op. + */ + markFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Remove the reviewed-file entry for the given URI + content nonce. + * No-op if no such entry exists. + */ + unmarkFileReviewed(uri: URI, nonce: string): Promise; + + /** + * Return every reviewed-file entry in this session, in insertion order. + */ + getReviewedFiles(): Promise; + + /** + * Return all reviewed-file entries for a specific URI (one per reviewed + * content nonce), in insertion order. + */ + getReviewedFilesForUri(uri: URI): Promise; + + /** + * Return whether the given file has been reviewed at the given content nonce. + */ + isFileReviewed(uri: URI, nonce: string): Promise; + /** * Creates a safe, consistent copy of the database at the given path * using SQLite's `VACUUM INTO` command. diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index 0036c31360b6a6..e2cf1ad7514a67 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -80454fd +a03d454 diff --git a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts index 985f37aa5fd2cc..757320e272114f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts +++ b/src/vs/platform/agentHost/common/state/protocol/action-origin.generated.ts @@ -9,7 +9,7 @@ // Generated from types/actions.ts — do not edit // Run `npm run generate` to regenerate. -import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; +import { ActionType, type StateAction, type RootAgentsChangedAction, type RootActiveSessionsChangedAction, type RootTerminalsChangedAction, type RootConfigChangedAction, type SessionReadyAction, type SessionCreationFailedAction, type SessionChatAddedAction, type SessionChatRemovedAction, type SessionChatUpdatedAction, type SessionDefaultChatChangedAction, type SessionTitleChangedAction, type SessionServerToolsChangedAction, type SessionActiveClientSetAction, type SessionActiveClientRemovedAction, type SessionInputNeededSetAction, type SessionInputNeededRemovedAction, type SessionCustomizationsChangedAction, type SessionCustomizationToggledAction, type SessionCustomizationUpdatedAction, type SessionCustomizationRemovedAction, type SessionMcpServerStateChangedAction, type SessionMcpServerStartRequestedAction, type SessionMcpServerStopRequestedAction, type SessionIsReadChangedAction, type SessionIsArchivedChangedAction, type SessionActivityChangedAction, type SessionChangesetsChangedAction, type SessionConfigChangedAction, type SessionMetaChangedAction, type ChatTurnStartedAction, type ChatDeltaAction, type ChatResponsePartAction, type ChatToolCallStartAction, type ChatToolCallDeltaAction, type ChatToolCallReadyAction, type ChatToolCallConfirmedAction, type ChatToolCallCompleteAction, type ChatToolCallResultConfirmedAction, type ChatToolCallContentChangedAction, type ChatTurnCompleteAction, type ChatTurnCancelledAction, type ChatErrorAction, type ChatActivityChangedAction, type ChatUsageAction, type ChatReasoningAction, type ChatPendingMessageSetAction, type ChatPendingMessageRemovedAction, type ChatQueuedMessagesReorderedAction, type ChatDraftChangedAction, type ChatInputRequestedAction, type ChatInputAnswerChangedAction, type ChatInputCompletedAction, type ChatTruncatedAction, type ChatTurnsLoadedAction, type ChangesetStatusChangedAction, type ChangesetFileSetAction, type ChangesetFileRemovedAction, type ChangesetFilesReviewedChangedAction, type ChangesetContentChangedAction, type ChangesetOperationsChangedAction, type ChangesetOperationStatusChangedAction, type ChangesetClearedAction, type AnnotationsSetAction, type AnnotationsUpdatedAction, type AnnotationsRemovedAction, type AnnotationsEntrySetAction, type AnnotationsEntryRemovedAction, type TerminalDataAction, type TerminalInputAction, type TerminalResizedAction, type TerminalClaimedAction, type TerminalTitleChangedAction, type TerminalCwdChangedAction, type TerminalExitedAction, type TerminalClearedAction, type TerminalCommandDetectionAvailableAction, type TerminalCommandExecutedAction, type TerminalCommandFinishedAction, type ResourceWatchChangedAction } from './actions.js'; // ─── Root vs Session vs Chat vs Terminal vs Changeset Action Unions ───────────────── @@ -46,11 +46,15 @@ export type SessionAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction | SessionCustomizationRemovedAction | SessionMcpServerStateChangedAction + | SessionMcpServerStartRequestedAction + | SessionMcpServerStopRequestedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction | SessionActivityChangedAction @@ -65,6 +69,8 @@ export type ClientSessionAction = | SessionActiveClientSetAction | SessionActiveClientRemovedAction | SessionCustomizationToggledAction + | SessionMcpServerStartRequestedAction + | SessionMcpServerStopRequestedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction | SessionConfigChangedAction @@ -79,6 +85,8 @@ export type ServerSessionAction = | SessionChatUpdatedAction | SessionDefaultChatChangedAction | SessionServerToolsChangedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationUpdatedAction | SessionCustomizationRemovedAction @@ -103,6 +111,7 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -113,6 +122,7 @@ export type ChatAction = | ChatInputAnswerChangedAction | ChatInputCompletedAction | ChatTruncatedAction + | ChatTurnsLoadedAction ; /** Union of chat actions that clients may dispatch. */ @@ -141,9 +151,11 @@ export type ServerChatAction = | ChatToolCallReadyAction | ChatTurnCompleteAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatInputRequestedAction + | ChatTurnsLoadedAction ; /** Union of all terminal-scoped actions. */ @@ -185,6 +197,7 @@ export type ChangesetAction = | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction + | ChangesetFilesReviewedChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction @@ -201,6 +214,7 @@ export type ServerChangesetAction = | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction + | ChangesetFilesReviewedChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction @@ -266,11 +280,15 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.SessionServerToolsChanged]: false, [ActionType.SessionActiveClientSet]: true, [ActionType.SessionActiveClientRemoved]: true, + [ActionType.SessionInputNeededSet]: false, + [ActionType.SessionInputNeededRemoved]: false, [ActionType.SessionCustomizationsChanged]: false, [ActionType.SessionCustomizationToggled]: true, [ActionType.SessionCustomizationUpdated]: false, [ActionType.SessionCustomizationRemoved]: false, [ActionType.SessionMcpServerStateChanged]: false, + [ActionType.SessionMcpServerStartRequested]: true, + [ActionType.SessionMcpServerStopRequested]: true, [ActionType.SessionIsReadChanged]: true, [ActionType.SessionIsArchivedChanged]: true, [ActionType.SessionActivityChanged]: false, @@ -290,6 +308,7 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatTurnComplete]: false, [ActionType.ChatTurnCancelled]: true, [ActionType.ChatError]: false, + [ActionType.ChatActivityChanged]: false, [ActionType.ChatUsage]: false, [ActionType.ChatReasoning]: false, [ActionType.ChatPendingMessageSet]: true, @@ -300,9 +319,11 @@ export const IS_CLIENT_DISPATCHABLE: { readonly [K in StateAction['type']]: bool [ActionType.ChatInputAnswerChanged]: true, [ActionType.ChatInputCompleted]: true, [ActionType.ChatTruncated]: true, + [ActionType.ChatTurnsLoaded]: false, [ActionType.ChangesetStatusChanged]: false, [ActionType.ChangesetFileSet]: false, [ActionType.ChangesetFileRemoved]: false, + [ActionType.ChangesetFilesReviewedChanged]: false, [ActionType.ChangesetContentChanged]: false, [ActionType.ChangesetOperationsChanged]: false, [ActionType.ChangesetOperationStatusChanged]: false, diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts index f9cc28e140de64..13874877bb69fe 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/actions.ts @@ -56,6 +56,29 @@ export interface ChangesetFileRemovedAction { fileId: string; } +/** + * Update the {@link ChangesetFile.reviewed} flag for one or more files, + * identified by their {@link ChangesetFile.id}. + * + * Dispatched by the server as the user marks files reviewed or unreviewed + * (e.g. toggling a single file, or a "mark all as reviewed" affordance). + * Only servers that support the "review" functionality dispatch this; a + * server that leaves {@link ChangesetFile.reviewed} `undefined` never does. + * + * The reducer sets `reviewed` on every matching file and ignores any + * `fileIds` entry that does not correspond to a current file. + * + * @category Changeset Actions + * @version 1 + */ +export interface ChangesetFilesReviewedChangedAction { + type: ActionType.ChangesetFilesReviewedChanged; + /** The {@link ChangesetFile.id}s whose reviewed state changed. */ + fileIds: string[]; + /** The new reviewed state to apply to each listed file. */ + reviewed: boolean; +} + /** * The changeset's full content changed. Full replacement semantics: `files` * replaces the previous file list, and `operations`, when present, replaces diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts index 543a61cb12d4ec..b945dc7fa8444f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/reducer.ts @@ -52,6 +52,19 @@ export function changesetReducer(state: ChangesetState, action: ChangesetAction, return { ...state, files: next }; } + case ActionType.ChangesetFilesReviewedChanged: { + let changed = false; + const ids = new Set(action.fileIds); + const next: ChangesetFile[] = state.files.map(f => { + if (!ids.has(f.id) || f.reviewed === action.reviewed) { + return f; + } + changed = true; + return { ...f, reviewed: action.reviewed }; + }); + return changed ? { ...state, files: next } : state; + } + case ActionType.ChangesetContentChanged: { const next = action.operations === undefined ? { ...state, files: action.files } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts index a80ca76fc6f9df..275e856decc37c 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-changeset/state.ts @@ -124,6 +124,13 @@ export interface ChangesetFile { * additions, deletions, and rename/create/delete semantics from this. */ edit: FileEdit; + /** + * Whether the user has reviewed this file. Omit (or set to `undefined`) + * to indicate that the server does not support the "review" functionality; + * in that case clients should not surface any reviewed/unreviewed + * affordance for this file. + */ + reviewed?: boolean; /** * Server-defined opaque metadata, surfaced to operations and tooling * but not interpreted by the protocol. diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts index 94e7d3952761b2..93093464ae416e 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/actions.ts @@ -8,7 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { StringOrMarkdown, ErrorInfo, FileEdit, UsageInfo } from '../common/state.js'; -import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor } from './state.js'; +import { ToolCallConfirmationReason, ToolCallCancellationReason, PendingMessageKind, type Message, type ResponsePart, type ToolCallResult, type ToolResultContent, type ChatInputAnswer, type ChatInputRequest, type ChatInputResponseKind, type ConfirmationOption, type ToolCallContributor, type Turn } from './state.js'; // ─── Tool Call Action Base ─────────────────────────────────────────────────── @@ -138,6 +138,8 @@ export interface ChatToolCallStartAction extends ToolCallActionBase { toolName: string; /** Human-readable tool name */ displayName: string; + /** Human-readable description of what the tool invocation intends to do */ + intention?: string; /** * Reference to the contributor of the tool being called. Absent for * server-side tools that are not contributed by a client or MCP server. @@ -386,6 +388,23 @@ export interface ChatErrorAction { _meta?: Record; } +/** + * The activity description of this chat changed. + * + * Dispatched by the server to indicate what the chat is currently doing + * (e.g. running a tool, thinking). Clear activity by omitting it or setting it + * to `undefined`. + * Producers SHOULD also update the parent session's chat catalog with + * `session/chatUpdated` so `ChatSummary.activity` stays in sync. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatActivityChangedAction { + type: ActionType.ChatActivityChanged; + /** Human-readable description of current activity; omit or set `undefined` to clear */ + activity?: string; +} /** * Token usage report for a turn. @@ -464,6 +483,26 @@ export interface ChatTruncatedAction { turnId?: string; } +/** + * Loads older completed turns into this chat's state. + * + * Hosts dispatch this before responding to `fetchTurns`, and before applying + * any operation that references a turn older than the currently loaded window. + * `turns` is ordered oldest-first and is prepended to the current `turns` + * window. `turnsNextCursor` replaces the state's cursor; omit it when all + * retained turns are now loaded. + * + * @category Chat Actions + * @version 1 + */ +export interface ChatTurnsLoadedAction { + type: ActionType.ChatTurnsLoaded; + /** Older completed turns loaded into the state, ordered oldest-first. */ + turns: Turn[]; + /** Opaque cursor for loading the next older page, if one remains. */ + turnsNextCursor?: string; +} + // ─── Pending Message Actions ───────────────────────────────────────────────── /** @@ -626,9 +665,11 @@ export type ChatAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatTruncatedAction + | ChatTurnsLoadedAction | ChatPendingMessageSetAction | ChatPendingMessageRemovedAction | ChatQueuedMessagesReorderedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts index 0bd64ae6f1fd1a..65bda4e6311482 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/reducer.ts @@ -20,6 +20,7 @@ function tcBase(tc: ToolCallState) { toolCallId: tc.toolCallId, toolName: tc.toolName, displayName: tc.displayName, + intention: tc.intention, contributor: tc.contributor, _meta: tc._meta, }; @@ -312,6 +313,9 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st case ActionType.ChatError: return endTurn(state, action.turnId, TurnState.Error, SessionStatus.Error, action.error); + case ActionType.ChatActivityChanged: + return { ...state, activity: action.activity }; + // ── Tool Call State Machine ─────────────────────────────────────────── case ActionType.ChatToolCallStart: @@ -330,6 +334,7 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st toolCallId: action.toolCallId, toolName: action.toolName, displayName: action.displayName, + intention: action.intention, contributor: action.contributor, _meta: action._meta, status: ToolCallStatus.Streaming, @@ -524,12 +529,25 @@ export function chatReducer(state: ChatState, action: ChatAction, log?: (msg: st modifiedAt: new Date(Date.now()).toISOString(), }; delete next.inputRequests; + if (action.turnId === undefined) { + delete next.turnsNextCursor; + } return { ...next, status: summaryStatus(next), }; } + case ActionType.ChatTurnsLoaded: { + const existingIds = new Set(state.turns.map(turn => turn.id)); + const olderTurns = action.turns.filter(turn => !existingIds.has(turn.id)); + return { + ...state, + turns: [...olderTurns, ...state.turns], + turnsNextCursor: action.turnsNextCursor, + }; + } + // ── Session Input Requests ───────────────────────────────────────────── case ActionType.ChatInputRequested: diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts index ec609cfeb74fd7..5bb42800f11d92 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-chat/state.ts @@ -63,6 +63,15 @@ export interface ChatState { // ── Conversation contents ────────────────────────────────────────── /** Completed turns */ turns: Turn[]; + /** + * Cursor for loading older completed turns into this chat state. + * + * Presence means `turns` is a tail window and more historical turns are + * available. Pass this opaque cursor to `fetchTurns`; the host MUST insert + * the loaded turns into state and update or clear this cursor before + * responding. Absence means the state contains all retained turns. + */ + turnsNextCursor?: string; /** Currently in-progress turn */ activeTurn?: ActiveTurn; /** Message to inject into the current turn at a convenient point */ @@ -959,6 +968,8 @@ interface ToolCallBase { toolName: string; /** Human-readable tool name */ displayName: string; + /** Human-readable description of what the tool invocation intends to do */ + intention?: string; /** * Reference to the contributor of the tool being called. */ @@ -1126,6 +1137,20 @@ export type ToolCallState = | ToolCallCompletedState | ToolCallCancelledState; +/** + * The two tool-call states that block on a client confirmation: parameter + * confirmation before execution ({@link ToolCallPendingConfirmationState}) and + * result confirmation after execution + * ({@link ToolCallPendingResultConfirmationState}). + * + * Surfaced at the session level by {@link SessionToolConfirmationRequest}. + * + * @category Tool Call Types + */ +export type ToolCallConfirmationState = + | ToolCallPendingConfirmationState + | ToolCallPendingResultConfirmationState; + // ─── Tool Result Content ───────────────────────────────────────────────────── @@ -1140,6 +1165,7 @@ export const enum ToolResultContentType { Resource = 'resource', FileEdit = 'fileEdit', Terminal = 'terminal', + TerminalComplete = 'terminalComplete', Subagent = 'subagent', } @@ -1207,6 +1233,37 @@ export interface ToolResultTerminalContent { title: string; } +/** + * Record of a command executed by a terminal-style tool (e.g. a shell tool), + * appended to the tool result when the command exits. + * + * This records the command's exit, not the terminal's — the terminal may + * keep running afterwards. + * + * When live output was exposed through a terminal channel (a + * {@link ToolResultTerminalContent} block in the same tool result), + * {@link resource} identifies that channel; otherwise this block stands alone + * as the retained command result. + * + * @category Tool Result Content + */ +export interface ToolResultTerminalCompleteContent { + type: ToolResultContentType.TerminalComplete; + /** + * URI of the `ahp-terminal:` channel that carried live output for this + * command, if one was exposed. + */ + resource?: URI; + /** Exit code from the completed command, if reported by the runtime */ + exitCode?: number; + /** Working directory where the command was executed */ + cwd?: URI; + /** Preview of the command's output, if available */ + preview?: string; + /** Whether `preview` is known to be incomplete or truncated */ + truncated?: boolean; +} + /** * A reference, embedded in a tool result, to a worker chat spawned by the tool * call (a sub-agent delegation), referenced by a chat URI (`ahp-chat:/...`). @@ -1235,7 +1292,8 @@ export interface ToolResultSubagentContent { * Mirrors the content blocks in MCP `CallToolResult.content`, plus * `ToolResultResourceContent` for lazy-loading large results, * `ToolResultFileEditContent` for file edit diffs, - * `ToolResultTerminalContent` for live terminal output, and + * `ToolResultTerminalContent` for live terminal output, + * `ToolResultTerminalCompleteContent` for terminal-style completion metadata, and * `ToolResultSubagentContent` for tool-spawned worker chats (AHP extensions). * * @category Tool Result Content @@ -1246,5 +1304,5 @@ export type ToolResultContent = | ToolResultResourceContent | ToolResultFileEditContent | ToolResultTerminalContent + | ToolResultTerminalCompleteContent | ToolResultSubagentContent; - diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts index ffa465a4f7158f..019f367dad0c09 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/commands.ts @@ -7,7 +7,7 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import type { URI } from '../common/state.js'; -import type { BaseParams } from '../common/commands.js'; +import type { BaseParams, PaginatedParams, PaginatedResult } from '../common/commands.js'; import type { SessionSummary, SessionConfigSchema } from '../channels-session/state.js'; // Re-export schema types so the legacy `commands.ts` aggregator continues to @@ -24,21 +24,45 @@ export type { SessionConfigPropertySchema, SessionConfigSchema } from '../channe * large. Clients fetch it imperatively and maintain a local cache updated by * `root/sessionAdded` and `root/sessionRemoved` notifications. * + * A large catalogue can be fetched incrementally via the {@link PaginatedParams} + * `limit`/`cursor` inputs (see that type for the full pagination contract). The + * server SHOULD return most-recently-modified entries first, so the first page + * is the immediately useful one. The `root/session*` notifications keep an + * already-fetched page live; pagination governs only the initial and backfill + * fetches. + * * @category Commands * @method listSessions * @direction Client → Server * @messageType Request * @version 1 + * @example + * ```jsonc + * // Client → Server (fetch the first page of up to 50 sessions) + * { "jsonrpc": "2.0", "id": 4, "method": "listSessions", + * "params": { "channel": "ahp-root://", "limit": 50 } } + * + * // Server → Client (a cursor signals more entries exist) + * { "jsonrpc": "2.0", "id": 4, "result": { + * "items": [ { "id": "s1", ... }, { "id": "s2", ... } ], + * "nextCursor": "eyJvIjo1MH0=" + * }} + * + * // Client → Server (fetch the next page) + * { "jsonrpc": "2.0", "id": 5, "method": "listSessions", + * "params": { "channel": "ahp-root://", "limit": 50, "cursor": "eyJvIjo1MH0=" } } + * ``` */ -export interface ListSessionsParams extends BaseParams { +export interface ListSessionsParams extends BaseParams, PaginatedParams { channel: 'ahp-root://'; - /** Optional filter criteria */ - filter?: object; } /** Result of the `listSessions` command. */ -export interface ListSessionsResult { - /** The list of session summaries. */ +export interface ListSessionsResult extends PaginatedResult { + /** + * The list of session summaries. The server SHOULD order them + * most-recently-modified first. + */ items: SessionSummary[]; } diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts index 4373a5fbcc9e19..4032839fbc0690 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/notifications.ts @@ -143,3 +143,82 @@ export interface SessionSummaryChangedParams { */ changes: Partial; } + +// ─── progress ──────────────────────────────────────────────────────────────── + +/** + * Generic progress notification for a long-running operation. + * + * A client opts in to progress for a request by including a `progressToken` in + * that request (today: the `progressToken` field on `createSession`). If the + * server does long-running work to service the request — e.g. lazily + * downloading an agent's native SDK the first time a session of that provider + * is materialized — it emits `progress` notifications carrying the same token. + * + * The notification is operation-agnostic: it says nothing about *what* is + * progressing. The client correlates `progressToken` back to the request it + * originated from (and thus the UI surface awaiting it) and renders its own + * localized indicator. The same channel serves any future long-running + * operation without a new method. + * + * Semantics: + * + * - `progress` is monotonically non-decreasing for a given `progressToken`. + * - `total` is present only when the server knows the magnitude up front + * (e.g. a `Content-Length`); when absent the client SHOULD show an + * indeterminate indicator. + * - The operation is complete when `progress === total`. The server MUST emit a + * final frame satisfying `progress === total`; when the total was never + * known, it sets `total` to the final `progress` on that frame. No further + * frames reference the token afterwards. + * - The server MAY emit no progress at all (e.g. the work was already done); + * the client then never shows an indicator. + * - Like all notifications this is ephemeral and is **not** replayed on + * reconnect. A client that never receives the terminal frame SHOULD expire + * the indicator after an idle timeout. + * + * @category Protocol Notifications + * @method root/progress + * @direction Server → Client + * @messageType Notification + * @version 1 + * @example + * ```json + * { + * "jsonrpc": "2.0", + * "method": "root/progress", + * "params": { + * "channel": "ahp-root://", + * "progressToken": "9b2c1f7e-4a0d-4e2b-8b1a-2f7e4a0d4e2b", + * "progress": 18874368, + * "total": 41957498 + * } + * } + * ``` + */ +export interface ProgressParams { + /** Channel URI this notification belongs to (the root channel). */ + channel: URI; + /** + * Echoes the `progressToken` the client supplied on the originating request + * (e.g. the `progressToken` field of `createSession`), correlating this frame + * to that call. Unique across the client's active requests. + */ + progressToken: string; + /** + * Progress so far, in operation-defined units (e.g. bytes received). + * Monotonically non-decreasing for a given `progressToken`. + */ + progress: number; + /** + * Total when known up front (e.g. from a `Content-Length`); omitted ⇒ + * indeterminate. The operation is complete once `progress === total`. + */ + total?: number; + /** + * Optional human-readable progress message. The client owns its own + * (localized) presentation derived from the originating request; generic + * clients that don't track the token MAY display this instead. + */ + message?: string; +} diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts index e427455085531a..f0d28f53e009f2 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-root/state.ts @@ -83,6 +83,45 @@ export interface AgentInfo { * into the session's `customizations` list. */ customizations?: Customization[]; + /** + * Static capabilities the agent advertises about itself. Clients use these + * to gate features (multi-chat, fork) instead of switching on the provider + * id. + */ + capabilities?: AgentCapabilities; +} + +/** + * Static capabilities an {@link AgentInfo} advertises. Modelled after MCP + * capabilities: each field is opt-in and its presence (an empty object `{}`) + * signals support, while absence means the feature is unsupported and the + * corresponding client commands MUST NOT be used. Sub-fields carry + * per-capability options. + * + * @category Root State + */ +export interface AgentCapabilities { + /** + * The agent can host more than one concurrent chat per session. When absent, + * clients MUST NOT call `createChat` to open chats beyond the default one the + * session starts with. An empty object `{}` advertises multi-chat without + * forking; set {@link MultipleChatsCapability.fork} to also allow forking. + */ + multipleChats?: MultipleChatsCapability; +} + +/** + * Options for the {@link AgentCapabilities.multipleChats} capability. + * + * @category Root State + */ +export interface MultipleChatsCapability { + /** + * The agent can fork a chat from a specific turn. When absent or `false`, + * clients MUST NOT pass a {@link ChatForkSource} (`source`) to `createChat`. + * Forking always implies multi-chat support. + */ + fork?: boolean; } /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts index 4745d9214612d8..aeab330bf8678f 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/actions.ts @@ -8,7 +8,7 @@ import { ActionType } from '../common/actions.js'; import type { ErrorInfo, URI } from '../common/state.js'; -import type { ToolDefinition, SessionActiveClient, Customization, McpServerState } from './state.js'; +import type { ToolDefinition, SessionActiveClient, SessionInputRequest, Customization, McpServerState } from './state.js'; import type { Changeset } from '../channels-changeset/state.js'; import type { ChatSummary } from '../channels-chat/state.js'; @@ -250,6 +250,50 @@ export interface SessionActiveClientRemovedAction { clientId: string; } +// ─── Input Needed Actions ──────────────────────────────────────────────────── + +/** + * A session-level input request was added or updated. + * + * Upsert semantics keyed by {@link SessionInputRequest.id | `request.id`}: the + * host dispatches this with the full {@link SessionInputRequest} to append a new + * entry to {@link SessionState.inputNeeded} or replace the existing entry with + * the same `id`. + * + * Server-originated: the host mirrors chat-level requests (elicitations, tool + * confirmations, client-tool executions) into the session aggregate so clients + * subscribed only to the session channel can discover them. Clients respond by + * dispatching the ordinary `chat/*` action to the entry's `chat` channel — see + * {@link SessionInputRequest}. + * + * @category Session Actions + * @version 1 + */ +export interface SessionInputNeededSetAction { + type: ActionType.SessionInputNeededSet; + /** The input request to add or update, matched by `id`. */ + request: SessionInputRequest; +} + +/** + * A session-level input request was removed. + * + * Removes the entry identified by `id` from + * {@link SessionState.inputNeeded}; a no-op when no entry matches. + * + * Server-originated: the host dispatches this once the underlying request + * resolves (the user answers, the tool call is confirmed, or the client + * reports its result). + * + * @category Session Actions + * @version 1 + */ +export interface SessionInputNeededRemovedAction { + type: ActionType.SessionInputNeededRemoved; + /** The `id` of the input request to remove. */ + id: string; +} + // ─── Customization Actions ─────────────────────────────────────────────────── /** @@ -268,12 +312,16 @@ export interface SessionCustomizationsChangedAction { } /** - * A client toggled a container customization on or off. + * A client toggled a customization on or off. * - * Targets a top-level container (plugin or directory) by `id`. Only - * containers have an `enabled` flag; children are always active when - * their container is enabled. Is a no-op when no matching container is - * found. + * Matches `id` against every top-level customization first — a plugin or + * directory container, or a bare top-level MCP server — then against the + * children inside each container (a skill, agent, or other entry), and + * sets the matched entry's `enabled` flag. Disabling a container still + * disables all of its children — the effective state of a child is + * `container.enabled && (child.enabled ?? true)` — so toggling a child + * only matters while its container is enabled. Is a no-op when no + * customization has the given `id`. * * @category Session Actions * @version 1 @@ -281,9 +329,9 @@ export interface SessionCustomizationsChangedAction { */ export interface SessionCustomizationToggledAction { type: ActionType.SessionCustomizationToggled; - /** The id of the container to toggle. */ + /** The id of the container or child to toggle. */ id: string; - /** Whether to enable or disable the container. */ + /** Whether to enable or disable the targeted customization. */ enabled: boolean; } @@ -361,6 +409,58 @@ export interface SessionMcpServerStateChangedAction { channel?: URI; } +/** + * Requests that the host start or restart an existing + * {@link McpServerCustomization}. + * + * Locates the target entry by `id`, searching both the top-level + * customization list and the `children` array of every container. The + * reducer optimistically moves the server to + * {@link McpServerStatus.Starting | `starting`} and clears any previous + * {@link McpServerCustomization.channel | `channel`}; the host remains + * authoritative and SHOULD follow with + * {@link SessionMcpServerStateChangedAction | `session/mcpServerStateChanged`} + * once the server becomes ready, needs authentication, fails, or is + * rejected. Is a no-op when no matching `McpServerCustomization` is found. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionMcpServerStartRequestedAction { + type: ActionType.SessionMcpServerStartRequested; + /** The id of the {@link McpServerCustomization} to start. */ + id: string; +} + +/** + * Requests that the host stop an existing {@link McpServerCustomization}. + * + * Locates the target entry by `id`, searching both the top-level + * customization list and the `children` array of every container. The + * reducer optimistically moves the server to + * {@link McpServerStatus.Stopped | `stopped`} and clears any previous + * {@link McpServerCustomization.channel | `channel`}. Replacing an + * {@link McpServerStatus.AuthRequired | `authRequired`} lifecycle state with + * `stopped` unblocks the server from waiting on authentication. If the host + * also raised session-level input-needed state solely for that MCP server, it + * SHOULD remove that input-needed entry when accepting the stop. + * + * The host remains authoritative and MAY reject the action or follow with + * {@link SessionMcpServerStateChangedAction | `session/mcpServerStateChanged`} + * if the final lifecycle state differs. Is a no-op when no matching + * `McpServerCustomization` is found. + * + * @category Session Actions + * @version 1 + * @clientDispatchable + */ +export interface SessionMcpServerStopRequestedAction { + type: ActionType.SessionMcpServerStopRequested; + /** The id of the {@link McpServerCustomization} to stop. */ + id: string; +} + // ─── Config Actions ────────────────────────────────────────────────────────── /** diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts index 3c96acf6c88c98..0f682bdd58a1ca 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/commands.ts @@ -9,7 +9,7 @@ import type { URI } from '../common/state.js'; import type { BaseParams } from '../common/commands.js'; import type { SessionActiveClient } from './state.js'; -import type { Turn, MessageAttachment } from '../channels-chat/state.js'; +import type { MessageAttachment } from '../channels-chat/state.js'; // ─── createSession ─────────────────────────────────────────────────────────── @@ -84,6 +84,19 @@ export interface CreateSessionParams extends BaseParams { * `clientId` the creating client supplied in `initialize`. */ activeClient?: SessionActiveClient; + /** + * Opt-in progress token. When set, the client is offering to receive + * `progress` notifications (see `ProgressParams`) for any long-running work + * the server does to bring this session up — most notably the lazy, + * first-use download of the provider's native SDK. The server echoes this + * exact token on every `progress` frame so the client can correlate it to + * this `createSession` call (and the UI awaiting it). + * + * The token MUST be unique across the client's active requests. The server + * MAY ignore it (e.g. when nothing long-running is needed), in which case no + * `progress` notifications are emitted. + */ + progressToken?: string; } // ─── disposeSession ────────────────────────────────────────────────────────── @@ -104,8 +117,16 @@ export interface DisposeSessionParams extends BaseParams { } // ─── fetchTurns ────────────────────────────────────────────────────────────── /** - * Fetches historical turns for a chat. Used for lazy loading of conversation - * history. + * Requests that the host load older historical turns into a chat state. + * + * The command result does not carry turns. Instead, before responding, the host + * MUST dispatch `chat/turnsLoaded` to insert any loaded turns into the chat + * channel's `turns` state, ahead of the already-loaded window, and update or + * clear `turnsNextCursor`. + * + * Before applying any operation that references a turn outside the currently + * loaded window, the host MUST eagerly load enough older turns into state for + * that operation to reduce against valid state. * * @category Commands * @method fetchTurns @@ -114,39 +135,31 @@ export interface DisposeSessionParams extends BaseParams { } * @version 1 * @example * ```jsonc - * // Client → Server (fetch the 20 most recent turns) + * // Client → Server (load the next page indicated by ChatState.turnsNextCursor) * { "jsonrpc": "2.0", "id": 8, "method": "fetchTurns", - * "params": { "channel": "ahp-chat:/", "limit": 20 } } - * - * // Server → Client - * { "jsonrpc": "2.0", "id": 8, "result": { - * "turns": [ { "id": "t1", ... }, { "id": "t2", ... } ], - * "hasMore": true - * }} + * "params": { "channel": "ahp-chat:/", "cursor": "opaque-cursor" } } * - * // Client → Server (fetch 20 turns before t1) - * { "jsonrpc": "2.0", "id": 9, "method": "fetchTurns", - * "params": { "channel": "ahp-chat:/", "before": "t1", "limit": 20 } } + * // Server updates chat state, then responds + * { "jsonrpc": "2.0", "id": 8, "result": {} } * ``` */ export interface FetchTurnsParams extends BaseParams { /** Chat URI */ channel: URI; - /** Turn ID to fetch before (exclusive). Omit to fetch from the most recent turn. */ - before?: string; - /** Maximum number of turns to return. Server MAY impose its own upper bound. */ - limit?: number; + /** + * Opaque cursor from `ChatState.turnsNextCursor`. + * + * The host MUST reject unrecognised cursors with `InvalidParams`. Omit only + * when asking the host to opportunistically load its next older page for the + * chat, if any. + */ + cursor?: string; } /** * Result of the `fetchTurns` command. */ -export interface FetchTurnsResult { - /** The requested turns, ordered oldest-first */ - turns: Turn[]; - /** Whether more turns exist before the returned range */ - hasMore: boolean; -} +export interface FetchTurnsResult { } // ─── completions ───────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts index 0591136bcbe7ac..ffb36b09bf08bc 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/reducer.ts @@ -7,17 +7,83 @@ // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts import { ActionType } from '../common/actions.js'; -import { SessionLifecycle, SessionStatus, CustomizationType, type SessionState, type McpServerCustomization } from './state.js'; +import { SessionLifecycle, SessionStatus, CustomizationType, McpServerStatus, type SessionState, type SessionInputRequest, type McpServerCustomization } from './state.js'; import type { SessionAction } from '../action-origin.generated.js'; import { softAssertNever } from '../common/reducer-helpers.js'; // ─── Helpers ───────────────────────────────────────────────────────────────── +/** Bitmask covering the mutually-exclusive activity bits (bits 0–4). */ +const STATUS_ACTIVITY_MASK = (1 << 5) - 1; + /** Sets or clears a metadata flag on a status value. */ function withStatusFlag(status: SessionStatus, flag: SessionStatus, set: boolean): SessionStatus { return set ? status | flag : status & ~flag; } +/** + * Reflects the session-level {@link SessionState.inputNeeded | input queue} + * into the activity bits of `status`. A non-empty queue promotes the activity + * to {@link SessionStatus.InputNeeded}; emptying it clears the + * input-needed-specific bit. Since `InputNeeded` implies + * {@link SessionStatus.InProgress}, an unblocked turn falls back to + * `InProgress` while an already-idle session stays idle. Orthogonal flags + * (`IsRead` / `IsArchived`) are preserved. + */ +function withInputNeededStatus(status: SessionStatus, inputNeeded: readonly SessionInputRequest[]): SessionStatus { + if (inputNeeded.length > 0) { + return (status & ~STATUS_ACTIVITY_MASK) | SessionStatus.InputNeeded; + } + return status & ~(SessionStatus.InputNeeded & ~SessionStatus.InProgress); +} + +function updateMcpServerCustomization( + state: SessionState, + id: string, + update: (entry: McpServerCustomization) => McpServerCustomization, +): SessionState { + const list = state.customizations; + if (!list) { + return state; + } + const topIdx = list.findIndex(c => c.id === id); + if (topIdx >= 0) { + const entry = list[topIdx]; + if (entry.type !== CustomizationType.McpServer) { + return state; + } + const updated = list.slice(); + updated[topIdx] = update(entry); + return { ...state, customizations: updated }; + } + let changed = false; + const updated = list.map(container => { + if (container.type === CustomizationType.McpServer) { + return container; + } + const children = container.children; + if (!children) { + return container; + } + const childIdx = children.findIndex(c => c.id === id); + if (childIdx < 0) { + return container; + } + const child = children[childIdx]; + if (child.type !== CustomizationType.McpServer) { + return container; + } + changed = true; + const newChildren = children.slice(); + newChildren[childIdx] = update(child); + return { ...container, children: newChildren }; + }); + if (!changed) { + return state; + } + return { ...state, customizations: updated }; +} + // ─── Session Reducer ───────────────────────────────────────────────────────── /** @@ -152,37 +218,44 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, activeClients: updated }; } - // ── Customizations ────────────────────────────────────────────────── + // ── Input Needed ──────────────────────────────────────────────────── - case ActionType.SessionCustomizationsChanged: - return { ...state, customizations: action.customizations }; + case ActionType.SessionInputNeededSet: { + const list = state.inputNeeded ?? []; + const idx = list.findIndex(r => r.id === action.request.id); + const inputNeeded = idx < 0 ? [...list, action.request] : list.slice(); + if (idx >= 0) { + inputNeeded[idx] = action.request; + } + return { ...state, inputNeeded, status: withInputNeededStatus(state.status, inputNeeded) }; + } - case ActionType.SessionCustomizationToggled: { - const list = state.customizations; + case ActionType.SessionInputNeededRemoved: { + const list = state.inputNeeded; if (!list) { return state; } - const idx = list.findIndex(c => c.id === action.id); + const idx = list.findIndex(r => r.id === action.id); if (idx < 0) { return state; } - const updated = [...list]; - updated[idx] = { ...list[idx], enabled: action.enabled }; - return { ...state, customizations: updated }; - } - - case ActionType.SessionCustomizationUpdated: { - const list = state.customizations ?? []; - const idx = list.findIndex(c => c.id === action.customization.id); - if (idx < 0) { - return { ...state, customizations: [...list, action.customization] }; + const remaining = list.slice(); + remaining.splice(idx, 1); + const next: SessionState = { ...state, status: withInputNeededStatus(state.status, remaining) }; + if (remaining.length > 0) { + next.inputNeeded = remaining; + } else { + delete next.inputNeeded; } - const updated = [...list]; - updated[idx] = action.customization; - return { ...state, customizations: updated }; + return next; } - case ActionType.SessionCustomizationRemoved: { + // ── Customizations ────────────────────────────────────────────────── + + case ActionType.SessionCustomizationsChanged: + return { ...state, customizations: action.customizations }; + + case ActionType.SessionCustomizationToggled: { const list = state.customizations; if (!list) { return state; @@ -190,51 +263,51 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: const topIdx = list.findIndex(c => c.id === action.id); if (topIdx >= 0) { const updated = list.slice(); - updated.splice(topIdx, 1); + updated[topIdx] = { ...list[topIdx], enabled: action.enabled }; return { ...state, customizations: updated }; } - let changed = false; - const updated = list.map(container => { + for (let i = 0; i < list.length; i++) { + const container = list[i]; if (container.type === CustomizationType.McpServer) { - return container; + continue; } const children = container.children; if (!children) { - return container; + continue; } const childIdx = children.findIndex(c => c.id === action.id); if (childIdx < 0) { - return container; + continue; } - changed = true; const newChildren = children.slice(); - newChildren.splice(childIdx, 1); - return { ...container, children: newChildren }; - }); - if (!changed) { - return state; + newChildren[childIdx] = { ...children[childIdx], enabled: action.enabled }; + const updated = list.slice(); + updated[i] = { ...container, children: newChildren }; + return { ...state, customizations: updated }; } + return state; + } + + case ActionType.SessionCustomizationUpdated: { + const list = state.customizations ?? []; + const idx = list.findIndex(c => c.id === action.customization.id); + if (idx < 0) { + return { ...state, customizations: [...list, action.customization] }; + } + const updated = [...list]; + updated[idx] = action.customization; return { ...state, customizations: updated }; } - case ActionType.SessionMcpServerStateChanged: { + case ActionType.SessionCustomizationRemoved: { const list = state.customizations; if (!list) { return state; } const topIdx = list.findIndex(c => c.id === action.id); if (topIdx >= 0) { - const entry = list[topIdx]; - if (entry.type !== CustomizationType.McpServer) { - return state; - } - const updatedEntry: McpServerCustomization = { - ...entry, - state: action.state, - channel: action.channel, - }; const updated = list.slice(); - updated[topIdx] = updatedEntry; + updated.splice(topIdx, 1); return { ...state, customizations: updated }; } let changed = false; @@ -250,18 +323,9 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: if (childIdx < 0) { return container; } - const child = children[childIdx]; - if (child.type !== CustomizationType.McpServer) { - return container; - } changed = true; - const updatedChild: McpServerCustomization = { - ...child, - state: action.state, - channel: action.channel, - }; const newChildren = children.slice(); - newChildren[childIdx] = updatedChild; + newChildren.splice(childIdx, 1); return { ...container, children: newChildren }; }); if (!changed) { @@ -270,6 +334,30 @@ export function sessionReducer(state: SessionState, action: SessionAction, log?: return { ...state, customizations: updated }; } + case ActionType.SessionMcpServerStateChanged: { + return updateMcpServerCustomization(state, action.id, entry => ({ + ...entry, + state: action.state, + channel: action.channel, + })); + } + + case ActionType.SessionMcpServerStartRequested: { + return updateMcpServerCustomization(state, action.id, entry => ({ + ...entry, + state: { kind: McpServerStatus.Starting }, + channel: undefined, + })); + } + + case ActionType.SessionMcpServerStopRequested: { + return updateMcpServerCustomization(state, action.id, entry => ({ + ...entry, + state: { kind: McpServerStatus.Stopped }, + channel: undefined, + })); + } + default: softAssertNever(action, log); return state; diff --git a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts index c86f4d6ea6f720..1f706d7aaaeb09 100644 --- a/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/channels-session/state.ts @@ -8,7 +8,7 @@ import type { Changeset } from '../channels-changeset/state.js'; import type { AnnotationsSummary } from '../channels-annotations/state.js'; -import type { ChatSummary } from '../channels-chat/state.js'; +import type { ChatSummary, ChatInputRequest, ToolCallConfirmationState, ToolCallState } from '../channels-chat/state.js'; import type { ConfigPropertySchema, ErrorInfo, Icon, ProtectedResourceMetadata, TextRange, URI } from '../common/state.js'; // ─── Session State ─────────────────────────────────────────────────────────── @@ -159,6 +159,23 @@ export interface SessionState extends SessionMetadata { * {@link /guide/changesets | Changesets} for an overview of the model. */ changesets?: Changeset[]; + /** + * Outstanding input the session is blocked on, aggregated across every chat + * so a client can discover and answer it from the session channel alone, + * without subscribing to individual chats. + * + * Each entry is self-sufficient: it carries the owning chat's URI plus every + * identifier the client needs to respond. A client answers by dispatching the + * ordinary `chat/*` action to that chat's channel — see + * {@link SessionInputRequest} for the per-variant response path. A present, + * non-empty list implies {@link SessionStatus.InputNeeded} on + * {@link SessionSummary.status}. + * + * Host-managed: the host upserts entries with `session/inputNeededSet` as + * chats raise requests and removes them with `session/inputNeededRemoved` + * once the underlying request resolves. + */ + inputNeeded?: SessionInputRequest[]; /** * Additional provider-specific metadata for this session. * @@ -196,6 +213,136 @@ export interface SessionActiveClient { customizations?: ClientPluginCustomization[]; } +// ─── Session Input Requests ────────────────────────────────────────────────── + +/** + * Discriminant for the kinds of outstanding input a session can surface in + * {@link SessionState.inputNeeded}. + * + * This is a general/typological union (not a lifecycle), so the discriminant is + * a `*Kind`. + * + * @category Session Input Types + */ +export const enum SessionInputRequestKind { + /** A user-facing elicitation mirrored from a chat's `inputRequests`. */ + ChatInput = 'chatInput', + /** A tool call awaiting parameter- or result-confirmation. */ + ToolConfirmation = 'toolConfirmation', + /** A running tool the session wants an active client to execute. */ + ToolClientExecution = 'toolClientExecution', +} + +/** + * Fields common to every {@link SessionInputRequest} variant. + * + * @category Session Input Types + */ +interface SessionInputRequestBase { + /** + * Stable key for this entry, unique within the session's + * {@link SessionState.inputNeeded} list. The host derives it however it likes + * (for example from the chat URI plus the underlying request or tool-call + * id); consumers MUST treat it as opaque. It is the key for the + * `session/inputNeededSet` / `session/inputNeededRemoved` upsert convention. + */ + id: string; + /** + * The chat the underlying request lives in. This is the channel a client + * dispatches its response to — it does not need to have subscribed to that + * chat first. + */ + chat: URI; +} + +/** + * A user-input elicitation surfaced at the session level, mirroring one entry + * of the owning chat's {@link ChatState.inputRequests}. + * + * Respond by dispatching `chat/inputCompleted` (or syncing drafts with + * `chat/inputAnswerChanged`) to {@link SessionInputRequestBase.chat | `chat`}, + * keyed by {@link ChatInputRequest.id | `request.id`}. + * + * @category Session Input Types + */ +export interface SessionChatInputRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ChatInput; + /** The mirrored chat input request. */ + request: ChatInputRequest; +} + +/** + * A tool call blocked on confirmation — either parameter confirmation before + * execution or result confirmation after — surfaced at the session level. + * + * Respond by dispatching `chat/toolCallConfirmed` (for + * {@link ToolCallPendingConfirmationState}) or `chat/toolCallResultConfirmed` + * (for {@link ToolCallPendingResultConfirmationState}) to + * {@link SessionInputRequestBase.chat | `chat`}, keyed by `turnId` and + * `toolCall.toolCallId`. + * + * @category Session Input Types + */ +export interface SessionToolConfirmationRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ToolConfirmation; + /** The turn the tool call belongs to. */ + turnId: string; + /** The tool call awaiting confirmation. */ + toolCall: ToolCallConfirmationState; +} + +/** + * A running tool whose execution is delegated to an active client. Surfaced so + * a client that provides the tool can pick up the work without subscribing to + * the owning chat. + * + * The {@link toolCall} is always a {@link ToolCallRunningState} (a + * {@link ToolCallState} in `running` status) whose + * {@link ToolCallRunningState.contributor | `contributor`} is a client + * {@link ToolCallClientContributor} whose `clientId` matches the denormalized + * {@link clientId} here. Execute and report the result by dispatching + * `chat/toolCallComplete` (and optionally streaming with + * `chat/toolCallContentChanged`) to {@link SessionInputRequestBase.chat | + * `chat`}, keyed by `turnId` and `toolCall.toolCallId`. + * + * @category Session Input Types + */ +export interface SessionToolClientExecutionRequest extends SessionInputRequestBase { + kind: SessionInputRequestKind.ToolClientExecution; + /** The turn the tool call belongs to. */ + turnId: string; + /** + * The `clientId` expected to execute the tool. Matches the `clientId` of the + * tool call's client {@link ToolCallContributor}. + */ + clientId: string; + /** + * The running tool call the session wants the owning client to execute. The + * host only ever populates this with a {@link ToolCallRunningState} (i.e. a + * {@link ToolCallState} in `running` status). + */ + toolCall: ToolCallState; +} + +/** + * One outstanding piece of input a session is blocked on, aggregated across all + * chats in {@link SessionState.inputNeeded}. + * + * Each entry is self-sufficient: it carries the owning + * {@link SessionInputRequestBase.chat | `chat`} URI plus every identifier needed + * to construct the response, so a client can answer by dispatching the ordinary + * `chat/*` action (`chat/inputCompleted`, `chat/toolCallConfirmed`, + * `chat/toolCallComplete`, …) to that chat's channel **without having subscribed + * to the chat**. The host removes the entry with `session/inputNeededRemoved` + * once the underlying request resolves. + * + * @category Session Input Types + */ +export type SessionInputRequest = + | SessionChatInputRequest + | SessionToolConfirmationRequest + | SessionToolClientExecutionRequest; + /** * Server-owned project metadata for a session. * @@ -223,11 +370,9 @@ export interface ProjectInfo { * `Error` — bits 0–4) from the * {@link SessionState.defaultChat | default chat} when present, else from * the most recently modified chat. **Promote** `InputNeeded` whenever any - * chat in the session needs input, **promote** `Error` whenever any chat is - * in an error state, and **promote** `InProgress` whenever any chat is - * actively streaming — all override the default-chat bits, with precedence - * `InputNeeded` > `Error` > `InProgress`. The orthogonal flag bits - * (`IsRead`, `IsArchived`) remain session-scoped. + * chat in the session needs input, and **promote** `Error` whenever any + * chat is in an error state — both override the default-chat bits. The + * orthogonal flag bits (`IsRead`, `IsArchived`) remain session-scoped. * - `activity`: mirror the activity string of the default chat, or of the * chat currently driving the promoted status bits when a non-default chat * wins (e.g. the chat that raised `InputNeeded`). @@ -636,6 +781,35 @@ export interface DirectoryCustomization extends ContainerCustomizationBase { writable: boolean; } +/** + * Fields shared by the leaf child customizations that live inside a + * container — {@link AgentCustomization}, {@link SkillCustomization}, + * {@link PromptCustomization}, {@link RuleCustomization}, and + * {@link HookCustomization}. + * + * {@link McpServerCustomization} is also a child but does not extend this + * base: it always carries an explicit {@link McpServerCustomization.enabled} + * because it can appear as a top-level customization too. + * + * @category Customization Types + */ +interface ChildCustomizationBase extends CustomizationBase { + /** + * Whether this child is individually enabled. Absent means enabled, so a + * producer only needs to set it to surface a child that exists but is + * turned off on its own. + * + * This flag is independent of the parent container's: the **effective** + * enabled state of a child is + * `container.enabled && (child.enabled ?? true)`, so a disabled container + * disables every child regardless of each child's own flag. + * + * A child is turned on or off by id with + * {@link SessionCustomizationToggledAction | `session/customizationToggled`}. + */ + enabled?: boolean; +} + /** * A custom agent contributed by a plugin or directory. * @@ -645,13 +819,41 @@ export interface DirectoryCustomization extends ContainerCustomizationBase { * * @category Customization Types */ -export interface AgentCustomization extends CustomizationBase { +export interface AgentCustomization extends ChildCustomizationBase { type: CustomizationType.Agent; /** * Short description of what the agent specializes in and when to * invoke it. Sourced from the agent file's frontmatter `description`. */ description?: string; + /** + * Model the agent is pinned to, sourced from the agent file's + * frontmatter `model`. Absent means the agent inherits the session's + * default model. + */ + model?: string; + /** + * Allowlist of tool names the agent is scoped to, sourced from the + * agent file's frontmatter `tools`. A non-empty list restricts the + * agent to exactly those tools. Absent — or an empty list — imposes no + * restriction beyond the session default: the agent may use any + * available tool. Producers express "no restriction" by omitting the + * field rather than sending an empty array, so an empty list carries no + * meaning distinct from absence. + */ + tools?: string[]; + /** + * When `true`, the agent will not auto-delegate to this custom agent + * as a sub-agent; it can only be selected by the user. Absent or + * `false` means the agent may delegate to it. + */ + disableModelInvocation?: boolean; + /** + * When `true`, the user cannot select this custom agent (for example, + * in a picker); it remains available for the agent to auto-delegate + * to. Absent or `false` means the user may select it. + */ + disableUserInvocation?: boolean; /** * Additional provider-specific metadata for this custom agent. * @@ -670,7 +872,7 @@ export interface AgentCustomization extends CustomizationBase { * * @category Customization Types */ -export interface SkillCustomization extends CustomizationBase { +export interface SkillCustomization extends ChildCustomizationBase { type: CustomizationType.Skill; /** * Short description used for help text and auto-invocation matching. @@ -683,6 +885,12 @@ export interface SkillCustomization extends CustomizationBase { * `disable-model-invocation` flag. */ disableModelInvocation?: boolean; + /** + * When `true`, the user cannot directly invoke this skill (for example, + * as a slash command); it remains available for the agent to + * auto-invoke. Absent or `false` means the user may invoke it. + */ + disableUserInvocation?: boolean; } /** @@ -690,7 +898,7 @@ export interface SkillCustomization extends CustomizationBase { * * @category Customization Types */ -export interface PromptCustomization extends CustomizationBase { +export interface PromptCustomization extends ChildCustomizationBase { type: CustomizationType.Prompt; /** Short description of what the prompt does. */ description?: string; @@ -709,7 +917,7 @@ export interface PromptCustomization extends CustomizationBase { * * @category Customization Types */ -export interface RuleCustomization extends CustomizationBase { +export interface RuleCustomization extends ChildCustomizationBase { type: CustomizationType.Rule; /** * Description of what the rule enforces. @@ -733,7 +941,7 @@ export interface RuleCustomization extends CustomizationBase { * * @category Customization Types */ -export interface HookCustomization extends CustomizationBase { +export interface HookCustomization extends ChildCustomizationBase { type: CustomizationType.Hook; } diff --git a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts index 8cc4a7326520ef..93e88061dace3b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/actions.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/actions.ts @@ -10,11 +10,11 @@ import type { URI } from './state.js'; import type { RootAgentsChangedAction, RootActiveSessionsChangedAction, RootTerminalsChangedAction, RootConfigChangedAction } from '../channels-root/actions.js'; -import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; +import type { SessionReadyAction, SessionCreationFailedAction, SessionChatAddedAction, SessionChatRemovedAction, SessionChatUpdatedAction, SessionDefaultChatChangedAction, SessionTitleChangedAction, SessionServerToolsChangedAction, SessionActiveClientSetAction, SessionActiveClientRemovedAction, SessionInputNeededSetAction, SessionInputNeededRemovedAction, SessionCustomizationsChangedAction, SessionCustomizationToggledAction, SessionCustomizationUpdatedAction, SessionCustomizationRemovedAction, SessionMcpServerStateChangedAction, SessionMcpServerStartRequestedAction, SessionMcpServerStopRequestedAction, SessionIsReadChangedAction, SessionIsArchivedChangedAction, SessionActivityChangedAction, SessionChangesetsChangedAction, SessionConfigChangedAction, SessionMetaChangedAction } from '../channels-session/actions.js'; -import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction } from '../channels-chat/actions.js'; +import type { ChatTurnStartedAction, ChatDeltaAction, ChatResponsePartAction, ChatToolCallStartAction, ChatToolCallDeltaAction, ChatToolCallReadyAction, ChatToolCallConfirmedAction, ChatToolCallCompleteAction, ChatToolCallResultConfirmedAction, ChatToolCallContentChangedAction, ChatTurnCompleteAction, ChatTurnCancelledAction, ChatErrorAction, ChatActivityChangedAction, ChatUsageAction, ChatReasoningAction, ChatPendingMessageSetAction, ChatPendingMessageRemovedAction, ChatQueuedMessagesReorderedAction, ChatDraftChangedAction, ChatInputRequestedAction, ChatInputAnswerChangedAction, ChatInputCompletedAction, ChatTruncatedAction, ChatTurnsLoadedAction } from '../channels-chat/actions.js'; -import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; +import type { ChangesetStatusChangedAction, ChangesetFileSetAction, ChangesetFileRemovedAction, ChangesetFilesReviewedChangedAction, ChangesetContentChangedAction, ChangesetOperationsChangedAction, ChangesetOperationStatusChangedAction, ChangesetClearedAction } from '../channels-changeset/actions.js'; import type { AnnotationsSetAction, AnnotationsUpdatedAction, AnnotationsRemovedAction, AnnotationsEntrySetAction, AnnotationsEntryRemovedAction } from '../channels-annotations/actions.js'; @@ -51,12 +51,15 @@ export const enum ActionType { ChatTurnComplete = 'chat/turnComplete', ChatTurnCancelled = 'chat/turnCancelled', ChatError = 'chat/error', + ChatActivityChanged = 'chat/activityChanged', SessionTitleChanged = 'session/titleChanged', ChatUsage = 'chat/usage', ChatReasoning = 'chat/reasoning', SessionServerToolsChanged = 'session/serverToolsChanged', SessionActiveClientSet = 'session/activeClientSet', SessionActiveClientRemoved = 'session/activeClientRemoved', + SessionInputNeededSet = 'session/inputNeededSet', + SessionInputNeededRemoved = 'session/inputNeededRemoved', ChatPendingMessageSet = 'chat/pendingMessageSet', ChatPendingMessageRemoved = 'chat/pendingMessageRemoved', ChatQueuedMessagesReordered = 'chat/queuedMessagesReordered', @@ -69,7 +72,10 @@ export const enum ActionType { SessionCustomizationUpdated = 'session/customizationUpdated', SessionCustomizationRemoved = 'session/customizationRemoved', SessionMcpServerStateChanged = 'session/mcpServerStateChanged', + SessionMcpServerStartRequested = 'session/mcpServerStartRequested', + SessionMcpServerStopRequested = 'session/mcpServerStopRequested', ChatTruncated = 'chat/truncated', + ChatTurnsLoaded = 'chat/turnsLoaded', SessionIsReadChanged = 'session/isReadChanged', SessionIsArchivedChanged = 'session/isArchivedChanged', SessionActivityChanged = 'session/activityChanged', @@ -79,6 +85,7 @@ export const enum ActionType { ChangesetStatusChanged = 'changeset/statusChanged', ChangesetFileSet = 'changeset/fileSet', ChangesetFileRemoved = 'changeset/fileRemoved', + ChangesetFilesReviewedChanged = 'changeset/filesReviewedChanged', ChangesetContentChanged = 'changeset/contentChanged', ChangesetOperationsChanged = 'changeset/operationsChanged', ChangesetOperationStatusChanged = 'changeset/operationStatusChanged', @@ -152,11 +159,15 @@ export type StateAction = | SessionServerToolsChangedAction | SessionActiveClientSetAction | SessionActiveClientRemovedAction + | SessionInputNeededSetAction + | SessionInputNeededRemovedAction | SessionCustomizationsChangedAction | SessionCustomizationToggledAction | SessionCustomizationUpdatedAction | SessionCustomizationRemovedAction | SessionMcpServerStateChangedAction + | SessionMcpServerStartRequestedAction + | SessionMcpServerStopRequestedAction | SessionIsReadChangedAction | SessionIsArchivedChangedAction | SessionActivityChangedAction @@ -176,6 +187,7 @@ export type StateAction = | ChatTurnCompleteAction | ChatTurnCancelledAction | ChatErrorAction + | ChatActivityChangedAction | ChatUsageAction | ChatReasoningAction | ChatPendingMessageSetAction @@ -186,9 +198,11 @@ export type StateAction = | ChatInputAnswerChangedAction | ChatInputCompletedAction | ChatTruncatedAction + | ChatTurnsLoadedAction | ChangesetStatusChangedAction | ChangesetFileSetAction | ChangesetFileRemovedAction + | ChangesetFilesReviewedChangedAction | ChangesetContentChangedAction | ChangesetOperationsChangedAction | ChangesetOperationStatusChangedAction diff --git a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts index 03ffb40c48ba73..1bc9327fc6ac28 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/commands.ts @@ -36,8 +36,99 @@ export interface BaseParams { channel: URI; } +// ─── Pagination ────────────────────────────────────────────────────────────── + +/** + * Cursor-based pagination inputs, mixed into the params of any list command + * that can page a large result set (e.g. {@link ListSessionsParams | + * `listSessions`}). The paired output is {@link PaginatedResult}. + * + * Pagination is **opaque and cursor-based**, mirroring the shape `fetchTurns` + * already uses for chat history: the server owns the ordering and keyset, and + * the client walks pages by echoing the cursor from the previous + * {@link PaginatedResult.nextCursor} back on the next request. + * + * The contract every paginated command shares: + * + * - To fetch the first page, omit `cursor`. Supply `limit` to bound the page. + * - If the result carries a {@link PaginatedResult.nextCursor}, more entries + * exist — pass it back as `cursor` to fetch the following page. A missing + * `nextCursor` signals the end of the collection. + * - Cursors are **server-defined and opaque**: clients MUST NOT parse, modify, + * or persist them across connections. An unrecognised cursor SHOULD be + * rejected with an `InvalidParams` error. + * - Pagination is **fully additive**: a client that omits `limit`/`cursor` and + * ignores `nextCursor` sees the pre-pagination behaviour (subject to any + * server-imposed cap), and a server that does not paginate ignores the inputs + * and returns everything in a single page. + * + * @category Commands + */ +export interface PaginatedParams { + /** + * Maximum number of entries to return in this page. The server SHOULD respect + * this bound but MAY return fewer entries and MAY impose its own upper cap. + * Omit to let the server choose the page size. + */ + limit?: number; + /** + * Opaque pagination cursor from a previous {@link PaginatedResult.nextCursor}. + * Omit to fetch the first page. Cursors are server-defined and MUST be treated + * as opaque — do not parse, modify, or persist them across connections. An + * unrecognised cursor SHOULD be rejected with an `InvalidParams` error. + */ + cursor?: string; +} + +/** + * Cursor-based pagination output, extended by the result of any list command + * that can page a large result set (e.g. {@link ListSessionsResult | + * `listSessions`}). See {@link PaginatedParams} for the full pagination + * contract shared by every paginated command. + * + * @category Commands + */ +export interface PaginatedResult { + /** + * Opaque cursor for the next page. Present when more entries exist beyond the + * returned page; absent signals the end of the collection. Pass it back as + * {@link PaginatedParams.cursor} to fetch the following page. + */ + nextCursor?: string; +} + // ─── initialize ────────────────────────────────────────────────────────────── +/** + * Identifies a protocol implementation — the software (and build) on one end + * of the connection, as distinct from the {@link AgentInfo | agent persona} it + * hosts. Carried as {@link InitializeParams.clientInfo | `clientInfo`} on the + * client side and {@link InitializeResult.serverInfo | `serverInfo`} on the + * server side, mirroring LSP's `clientInfo`/`serverInfo` and MCP's + * `Implementation`. + * + * This is **informational only**: it exists for logging, telemetry, an + * about/status affordance, and — as a last resort — a known-issue workaround + * for a specific buggy build. It is **not** a feature-detection mechanism. + * Feature availability stays with the capability model + * ({@link ClientCapabilities} and the various `*.capabilities` declarations); + * implementations SHOULD NOT gate protocol behaviour on parsing + * {@link Implementation.version | `version`}. + * + * @category Commands + */ +export interface Implementation { + /** Implementation name, e.g. a product or package identifier. */ + name: string; + /** + * Implementation version. A [SemVer](https://semver.org) string is + * recommended but not required. + */ + version?: string; + /** Optional human-readable display name. */ + title?: string; +} + /** * Establishes a new connection and negotiates the protocol version. * This MUST be the first message sent by the client. @@ -63,6 +154,14 @@ export interface InitializeParams extends BaseParams { protocolVersions: string[]; /** Unique client identifier */ clientId: string; + /** + * Optional identity of the client implementation (name and version). + * Informational only — see {@link Implementation} for how it may and may not + * be used. Distinct from {@link InitializeParams.clientId | `clientId`}, + * which is an opaque per-connection identifier used for reconnection, not a + * human-readable implementation name. + */ + clientInfo?: Implementation; /** URIs to subscribe to during handshake */ initialSubscriptions?: URI[]; /** @@ -125,6 +224,14 @@ export interface InitializeResult { protocolVersion: string; /** Current server sequence number */ serverSeq: number; + /** + * Optional identity of the server implementation (name and version). + * Informational only — see {@link Implementation} for how it may and may not + * be used. Whereas {@link InitializeResult.protocolVersion | `protocolVersion`} + * identifies the negotiated protocol, `serverInfo` identifies the host + * software behind it. + */ + serverInfo?: Implementation; /** Snapshots for each `initialSubscriptions` URI */ snapshots: Snapshot[]; /** Suggested default directory for remote filesystem browsing */ @@ -250,7 +357,57 @@ export type ReconnectResult = ReconnectReplayResult | ReconnectSnapshotResult; * @version 1 * @see {@link /specification/subscriptions | Subscriptions} */ -export interface SubscribeParams extends BaseParams { } +export interface SubscribeParams extends BaseParams { + /** + * Optional delivery preferences for this subscription. + * + * Servers MAY use these preferences to buffer and coalesce high-frequency + * updates while preserving the same reduced state. Omit this field for the + * server's default delivery behavior. + */ + delivery?: SubscriptionDeliveryOptions; + /** + * Optional client-requested shape for the returned snapshot. + * + * Servers that do not understand a requested view ignore it and return their + * default snapshot. Clients MUST tolerate receiving more state than requested. + */ + view?: SubscribeView; +} + +/** + * Optional client-requested shape for a subscription snapshot. + * + * @category Commands + */ +export interface SubscribeView { + /** + * Advisory number of most-recent completed turns to expose in a chat + * snapshot. + * + * Servers MAY return more or fewer turns than requested. When omitted, the + * host MUST return all retained turns. When older turns remain available, the + * returned {@link ChatState} carries `turnsNextCursor`; clients pass that + * cursor to `fetchTurns` to ask the host to page more turns into the chat + * state. + */ + turns?: number; +} + +/** + * Advisory delivery preferences for a single subscription. + * + * @category Commands + */ +export interface SubscriptionDeliveryOptions { + /** + * Maximum time, in milliseconds, that the server may intentionally delay + * delivery while buffering/coalescing updates for this subscription. + * + * A value of `0` requests immediate delivery with no intentional coalescing. + */ + maxLatencyMs?: number; +} /** * Result of the `subscribe` command. diff --git a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts index bf010f1d70aa12..82764b3f711b88 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/messages.ts @@ -15,7 +15,7 @@ import type { CreateResourceWatchParams, CreateResourceWatchResult } from '../ch import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../channels-changeset/commands.js'; import type { ActionEnvelope } from './actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams } from '../channels-root/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams } from '../channels-root/notifications.js'; import type { AuthRequiredParams } from './notifications.js'; import type { OtlpExportLogsParams, OtlpExportTracesParams, OtlpExportMetricsParams } from '../channels-otlp/notifications.js'; import type { AhpError } from './errors.js'; @@ -166,6 +166,7 @@ export interface ServerNotificationMap { 'root/sessionAdded': { params: SessionAddedParams }; 'root/sessionRemoved': { params: SessionRemovedParams }; 'root/sessionSummaryChanged': { params: SessionSummaryChangedParams }; + 'root/progress': { params: ProgressParams }; 'auth/required': { params: AuthRequiredParams }; 'otlp/exportLogs': { params: OtlpExportLogsParams }; 'otlp/exportTraces': { params: OtlpExportTracesParams }; @@ -219,8 +220,8 @@ export type AhpServerRequest = ...; - * result.result.turns; // typed as Turn[] + * const result: AhpSuccessResponse<'listSessions'> = ...; + * result.result.items; // typed as SessionSummary[] * ``` */ export type AhpSuccessResponse = diff --git a/src/vs/platform/agentHost/common/state/protocol/common/state.ts b/src/vs/platform/agentHost/common/state/protocol/common/state.ts index 32500adb19665c..2f956e97cdbd35 100644 --- a/src/vs/platform/agentHost/common/state/protocol/common/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/common/state.ts @@ -251,6 +251,8 @@ export interface ContentRef { sizeHint?: number; /** Content MIME type */ contentType?: string; + /** Content nonce */ + nonce?: string; } // ─── File Edit ─────────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts index 205ab7c4f3f157..1162bc86538658 100644 --- a/src/vs/platform/agentHost/common/state/protocol/version/registry.ts +++ b/src/vs/platform/agentHost/common/state/protocol/version/registry.ts @@ -16,7 +16,7 @@ import type { ServerNotificationMap } from '../messages.js'; * * Formatted as a [SemVer](https://semver.org) `MAJOR.MINOR.PATCH` string. */ -export const PROTOCOL_VERSION = '0.5.0'; +export const PROTOCOL_VERSION = '0.5.2'; /** * Every protocol version a client built from this source tree is willing @@ -35,7 +35,8 @@ export const PROTOCOL_VERSION = '0.5.0'; * `scripts/verify-release-metadata.ts`. */ export const SUPPORTED_PROTOCOL_VERSIONS: readonly string[] = Object.freeze([ - '0.5.0', + '0.5.2', + '0.5.1', ]); // ─── SemVer Comparison ─────────────────────────────────────────────────────── @@ -88,11 +89,15 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.SessionServerToolsChanged]: '0.1.0', [ActionType.SessionActiveClientSet]: '0.5.0', [ActionType.SessionActiveClientRemoved]: '0.5.0', + [ActionType.SessionInputNeededSet]: '0.5.1', + [ActionType.SessionInputNeededRemoved]: '0.5.1', [ActionType.SessionCustomizationsChanged]: '0.1.0', [ActionType.SessionCustomizationToggled]: '0.1.0', [ActionType.SessionCustomizationUpdated]: '0.1.0', [ActionType.SessionCustomizationRemoved]: '0.2.0', [ActionType.SessionMcpServerStateChanged]: '0.3.0', + [ActionType.SessionMcpServerStartRequested]: '0.5.2', + [ActionType.SessionMcpServerStopRequested]: '0.5.2', [ActionType.SessionIsReadChanged]: '0.1.0', [ActionType.SessionIsArchivedChanged]: '0.1.0', [ActionType.SessionActivityChanged]: '0.1.0', @@ -112,6 +117,7 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatTurnComplete]: '0.4.0', [ActionType.ChatTurnCancelled]: '0.4.0', [ActionType.ChatError]: '0.4.0', + [ActionType.ChatActivityChanged]: '0.5.0', [ActionType.ChatUsage]: '0.4.0', [ActionType.ChatReasoning]: '0.4.0', [ActionType.ChatPendingMessageSet]: '0.4.0', @@ -122,9 +128,11 @@ export const ACTION_INTRODUCED_IN: { readonly [K in StateAction['type']]: string [ActionType.ChatInputAnswerChanged]: '0.4.0', [ActionType.ChatInputCompleted]: '0.4.0', [ActionType.ChatTruncated]: '0.4.0', + [ActionType.ChatTurnsLoaded]: '0.5.1', [ActionType.ChangesetStatusChanged]: '0.2.0', [ActionType.ChangesetFileSet]: '0.2.0', [ActionType.ChangesetFileRemoved]: '0.2.0', + [ActionType.ChangesetFilesReviewedChanged]: '0.5.2', [ActionType.ChangesetContentChanged]: '0.4.0', [ActionType.ChangesetOperationsChanged]: '0.2.0', [ActionType.ChangesetOperationStatusChanged]: '0.3.0', @@ -178,6 +186,7 @@ export const NOTIFICATION_INTRODUCED_IN: { readonly [K in ProtocolNotificationMe 'root/sessionAdded': '0.1.0', 'root/sessionRemoved': '0.1.0', 'root/sessionSummaryChanged': '0.1.0', + 'root/progress': '0.5.0', 'auth/required': '0.1.0', 'otlp/exportLogs': '0.2.0', 'otlp/exportTraces': '0.2.0', diff --git a/src/vs/platform/agentHost/common/state/sessionActions.ts b/src/vs/platform/agentHost/common/state/sessionActions.ts index bb5b0caf0fca38..0702e0c0b0548d 100644 --- a/src/vs/platform/agentHost/common/state/sessionActions.ts +++ b/src/vs/platform/agentHost/common/state/sessionActions.ts @@ -77,6 +77,7 @@ export { type SessionAddedParams, type SessionRemovedParams, type SessionSummaryChangedParams, + type ProgressParams, type AuthRequiredParams, } from './protocol/notifications.js'; @@ -90,6 +91,7 @@ export const NotificationType = { SessionAdded: 'root/sessionAdded', SessionRemoved: 'root/sessionRemoved', SessionSummaryChanged: 'root/sessionSummaryChanged', + Progress: 'root/progress', AuthRequired: 'auth/required', } as const; export type NotificationType = typeof NotificationType[keyof typeof NotificationType]; @@ -127,7 +129,7 @@ import type { RootConfigChangedAction, } from './protocol/actions.js'; -import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, AuthRequiredParams } from './protocol/notifications.js'; +import type { SessionAddedParams, SessionRemovedParams, SessionSummaryChangedParams, ProgressParams, AuthRequiredParams } from './protocol/notifications.js'; import type { RootAction as IRootAction_, SessionAction as ISessionAction_, ChatAction as IChatAction_, ClientSessionAction as IClientSessionAction_, ServerSessionAction as IServerSessionAction_, ClientChatAction as IClientChatAction_, ServerChatAction as IServerChatAction_, TerminalAction as ITerminalAction_, ClientTerminalAction as IClientTerminalAction_, ChangesetAction as IChangesetAction_, AnnotationsAction as IAnnotationsAction_, ClientAnnotationsAction as IClientAnnotationsAction_ } from './protocol/action-origin.generated.js'; /** @@ -140,6 +142,7 @@ export type ProtocolNotification = | ({ type: 'root/sessionAdded' } & SessionAddedParams) | ({ type: 'root/sessionRemoved' } & SessionRemovedParams) | ({ type: 'root/sessionSummaryChanged' } & SessionSummaryChangedParams) + | ({ type: 'root/progress' } & ProgressParams) | ({ type: 'auth/required' } & AuthRequiredParams); export type RootAction = IRootAction_; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 0e76e2ef23c566..4375613ddef85d 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -59,12 +59,12 @@ export { SessionLifecycle, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, - TurnState, type ActiveTurn, type AgentCustomization, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, + TurnState, type ActiveTurn, type AgentCustomization, type AgentCapabilities, type AgentInfo, type AgentSelection, type Annotation, type AnnotationEntry, type AnnotationsState, type AnnotationsSummary, type Changeset, type ChangesetFile, type ChangesetOperation, type ChangesetState, type ChatState, type ChatSummary, type ChatInteractivity, type ChatOrigin, type ChildCustomization, type ClientPluginCustomization, type ConfigPropertySchema, type ConfigSchema, type ContentRef, type Customization, type CustomizationDegradedState, type CustomizationErrorState, type CustomizationLoadedState, type CustomizationLoadingState, type CustomizationLoadState, type DirectoryCustomization, type ErrorInfo, type HookCustomization, type FileEdit as ISessionFileDiff, type ToolResultEmbeddedResourceContent as IToolResultBinaryContent, type MarkdownResponsePart, type McpServerCustomization, type MessageAttachment, - type MessageResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, + type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type MessageAnnotationsAttachment, type ModelSelection, type PendingMessage, type PluginCustomization, type ProjectInfo, type PromptCustomization, type ReasoningResponsePart, type ResponsePart, type RootState, type RuleCustomization, type SessionActiveClient, type SessionConfigState, type ChatInputAnswer as SessionInputAnswer, @@ -84,7 +84,9 @@ export { type ToolCallContributor, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, + type ToolResultTerminalCompleteContent, type ToolResultSubagentContent, + type ToolResultTerminalContent, type ToolResultTextContent, type Turn, type URI, type UsageInfo, type Message @@ -120,9 +122,35 @@ export interface UsageInfoMeta { readonly resetDate?: string; } | undefined; }; + /** + * Per-source context-window attribution breakdown reported by the SDK's + * `session.rpc.metadata.getContextAttribution()`. Populated asynchronously + * after each usage event and piped to the context-usage widget as + * `promptTokenDetails`. + */ + contextAttribution?: IContextAttributionData; [key: string]: unknown; } +/** + * Mirrors the SDK's `SessionContextAttribution` shape — a flat list of + * per-source entries describing what occupies the session's context window. + */ +export interface IContextAttributionData { + readonly totalTokens: number; + readonly entries: readonly IContextAttributionEntry[]; + readonly compactions: { readonly count: number }; +} + +export interface IContextAttributionEntry { + readonly kind: string; + readonly id: string; + readonly label: string; + readonly tokens: number; + readonly parentId?: string; + readonly attributes?: Readonly>; +} + type AccountQuotaSnapshot = NonNullable[string]>; function readAccountQuotaSnapshot(value: unknown): AccountQuotaSnapshot | undefined { @@ -171,6 +199,57 @@ export function readUsageInfoMeta(usage: UsageInfo | undefined): UsageInfoMeta { } result.quotaSnapshots = snapshots; } + const contextAttribution = readContextAttribution(meta['contextAttribution']); + if (contextAttribution) { + result.contextAttribution = contextAttribution; + } + return result; +} + +function readContextAttribution(value: unknown): IContextAttributionData | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as Record; + if (typeof raw['totalTokens'] !== 'number' || !Array.isArray(raw['entries'])) { + return undefined; + } + const entries: IContextAttributionEntry[] = []; + for (const item of raw['entries']) { + if (!item || typeof item !== 'object' || Array.isArray(item)) { + continue; + } + const entry = item as Record; + if (typeof entry['kind'] !== 'string' || typeof entry['id'] !== 'string' + || typeof entry['label'] !== 'string' || typeof entry['tokens'] !== 'number') { + continue; + } + entries.push({ + kind: entry['kind'], + id: entry['id'], + label: entry['label'], + tokens: entry['tokens'], + parentId: typeof entry['parentId'] === 'string' ? entry['parentId'] : undefined, + attributes: entry['attributes'] && typeof entry['attributes'] === 'object' && !Array.isArray(entry['attributes']) + ? filterStringAttributes(entry['attributes'] as Record) + : undefined, + }); + } + const compactionsRaw = raw['compactions']; + const compactions = compactionsRaw && typeof compactionsRaw === 'object' && !Array.isArray(compactionsRaw) + && typeof (compactionsRaw as Record)['count'] === 'number' + ? { count: (compactionsRaw as Record)['count'] as number } + : { count: 0 }; + return { totalTokens: raw['totalTokens'] as number, entries, compactions }; +} + +function filterStringAttributes(raw: Record): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (typeof value === 'string' || value === undefined) { + result[key] = value; + } + } return result; } @@ -531,7 +610,12 @@ export function createDefaultChatSummary(session: SessionSummary, chatUri: Proto origin: { kind: ChatOriginKind.User }, }; if (session.activity !== undefined) { summary.activity = session.activity; } - if (session.workingDirectory !== undefined) { summary.workingDirectory = session.workingDirectory; } + // `workingDirectory` is deliberately NOT copied: per the protocol it is a + // per-chat OVERRIDE and, when absent, the chat inherits the session's + // working directory (see `mergeSessionWithDefaultChat`). Seeding it here + // would denormalize the session default onto every chat as a fake override, + // which then goes stale when the session's working directory is resolved + // later (e.g. a worktree resolved at materialization). return summary; } @@ -619,6 +703,11 @@ export function buildDefaultChatUri(sessionUri: ProtocolURI | ResourceURI): stri const SUBAGENT_CHAT_ID = 'subagent'; +export function isSubagentChatUri(uri: ProtocolURI | ResourceURI): boolean { + const parsed = typeof uri === 'string' ? ResourceURI.parse(uri) : uri; + return parsed.scheme === AHP_CHAT_SCHEME && parsed.authority === SUBAGENT_CHAT_ID; +} + export function buildSubagentChatUri(sessionUri: ProtocolURI | ResourceURI, toolCallId: string): string { const session = typeof sessionUri === 'string' ? sessionUri : sessionUri.toString(); const encoded = encodeBase64(VSBuffer.fromString(session), false, true); @@ -682,6 +771,17 @@ export function isDefaultChatUri(uri: ProtocolURI | ResourceURI): boolean { return parseChatUri(uri)?.chatId === DEFAULT_CHAT_ID; } +/** + * Resolves a feature-level `(session, chat)` pair to the single chat URI used by + * the agent session/chat surface. A session always owns a DEFAULT chat addressed + * by the session URI itself; additional (peer) chats are addressed by their own + * chat channel URIs. This is the one place default-chat resolution lives so + * agents never re-derive "is this the default chat?". + */ +export function resolveChatUri(session: ResourceURI, chat: ResourceURI): ResourceURI { + return isDefaultChatUri(chat) ? session : chat; +} + /** Returns `true` when `uri` identifies a chat channel. */ export function isAhpChatChannel(uri: string): boolean { try { @@ -694,36 +794,46 @@ export function isAhpChatChannel(uri: string): boolean { // ---- Session + default-chat composite -------------------------------------- /** - * A {@link SessionState} merged with the conversation contents of its default - * {@link ChatState}. The protocol moved turns and pending/input state off the - * session and onto a per-chat channel; VS Code recombines the session summary - * with its single default chat into this composite so consumers can read - * `turns`/`activeTurn`/pending state through one object as they did before - * multi-chat. + * A single chat's effective session context: the shared {@link SessionState} + * (working directory, active clients, config, customizations/MCP scope, …) + * resolved for one chat and merged with that chat's conversation contents. + * + * The protocol moved turns and pending/input state off the session and onto a + * per-chat channel, and lets a chat override session defaults (e.g. + * {@link ChatState.workingDirectory}). This composite recombines the session + * with one of its chats — default or peer — so consumers read the chat's + * effective context and conversation through one object without walking back to + * the session to re-derive shared state. The inherited + * {@link SessionState.workingDirectory} carries the chat's *effective* working + * directory (its own override when present, else the session default). */ export interface ISessionWithDefaultChat extends SessionState { - /** Completed turns of the default chat. */ + /** Completed turns of this chat. */ turns: Turn[]; - /** Currently in-progress turn of the default chat. */ + /** Currently in-progress turn of this chat. */ activeTurn?: ActiveTurn; - /** Steering message pending on the default chat. */ + /** Steering message pending on this chat. */ steeringMessage?: PendingMessage; - /** Queued messages pending on the default chat. */ + /** Queued messages pending on this chat. */ queuedMessages?: PendingMessage[]; - /** Input requests outstanding on the default chat. */ + /** Input requests outstanding on this chat. */ inputRequests?: ChatInputRequest[]; - /** Draft input of the default chat. */ + /** Draft input of this chat. */ draft?: Message; } /** - * Merges a {@link SessionState} with its default {@link ChatState} into an - * {@link ISessionWithDefaultChat}. When the chat state is absent (e.g. not yet - * hydrated) the conversation fields default to empty. + * Projects a {@link SessionState} and one of its {@link ChatState | chats} + * (default or peer) into that chat's {@link ISessionWithDefaultChat | effective + * session context}. Per-chat overrides (currently the working directory) are + * layered over the session defaults, and the conversation fields are taken from + * the chat. When the chat state is absent (e.g. not yet hydrated) the + * conversation fields default to empty and the session defaults apply. */ export function mergeSessionWithDefaultChat(session: SessionState, chat: ChatState | undefined): ISessionWithDefaultChat { return { ...session, + workingDirectory: chat?.workingDirectory ?? session.workingDirectory, turns: chat?.turns ?? [], activeTurn: chat?.activeTurn, steeringMessage: chat?.steeringMessage, @@ -935,6 +1045,48 @@ export function withSessionGitHubState(meta: SessionSummaryMeta | undefined, git return Object.keys(next).length > 0 ? next : undefined; } +/** + * Reserved key under {@link SessionSummaryMeta} marking a session as + * workspace-less: a session with no workspace/folder binding (surfaced in the + * UI as a "Quick Chat"). Carried on the summary bag (not the full state) so + * clients can group/style such sessions in session lists without subscribing to + * full session state. VS Code-specific convention layered on the protocol's + * generic `_meta` bag. + */ +export const SESSION_META_WORKSPACELESS_KEY = 'workspaceless'; + +/** + * Session-database metadata key recording whether a session is workspace-less (a + * workspace-less chat). Owned by the AH service: `AgentService` writes it centrally at + * create/materialize and overlays it onto every agent's summary `_meta` in + * `listSessions`; agents only read it (e.g. to pick the workspace-less system prompt + * on resume) and never persist it themselves. + */ +export const AH_META_WORKSPACELESS_DB_KEY = 'agentHost.workspaceless'; + +/** + * Reads the workspace-less marker from {@link SessionSummaryMeta}. Returns + * `true` only when the well-known key is present and set to boolean `true`. + */ +export function readSessionWorkspaceless(meta: SessionSummaryMeta | undefined): boolean { + return meta?.[SESSION_META_WORKSPACELESS_KEY] === true; +} + +/** + * Returns a new {@link SessionSummaryMeta} with the workspace-less marker set, + * or with the slot removed when `workspaceless` is `false`. Returns `undefined` + * if the result would be empty. + */ +export function withSessionWorkspaceless(meta: SessionSummaryMeta | undefined, workspaceless: boolean): SessionSummaryMeta | undefined { + const next: { [key: string]: unknown } = { ...meta }; + if (workspaceless) { + next[SESSION_META_WORKSPACELESS_KEY] = true; + } else { + delete next[SESSION_META_WORKSPACELESS_KEY]; + } + return Object.keys(next).length > 0 ? next : undefined; +} + // ---- RootState _meta accessors --------------------------------------------- /** diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index 181c08fbc34ce1..00004de13bea81 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -8,7 +8,7 @@ import { Emitter, Relay } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IReference } from '../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../base/common/observable.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { getDelayedChannel, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; +import { getDelayedChannel, IChannelServer, ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Client as MessagePortClient } from '../../../base/parts/ipc/common/ipc.mp.js'; import { acquirePort } from '../../../base/parts/ipc/electron-browser/ipc.mp.js'; import { ipcRenderer } from '../../../base/parts/sandbox/electron-browser/globals.js'; @@ -16,7 +16,8 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentService } from '../../environment/common/environment.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, isAgentHostEnabled, IMcpNotification, AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings } from '../common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostByokModelsEnabledSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IAgentHostSocketInfo, IConnectionTrackerService, IMcpNotification, AgentHostOTelPolicyIpcChannel, readAgentHostOTelPolicySettings } from '../common/agentService.js'; +import { IAgentHostEnablementService } from '../common/agentHostEnablementService.js'; import { AhpJsonlLogger } from '../common/ahpJsonlLogger.js'; import { wrapAgentServiceWithAhpLogging } from './localAhpJsonlLogging.js'; import { AgentSubscriptionManager, isActionEnvelopeRelevantToSubscriptionUris, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -29,9 +30,11 @@ import { StateComponents, ROOT_STATE_URI, parseChatUri, type RootState } from '. import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AgentHostClientResourceChannel } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../common/agentHostClientByokLmChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL, AgentHostClientProxyChannel } from '../common/agentHostClientProxyChannel.js'; import { TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js'; import { getTelemetryLevel } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; +import { AgentHostTelemetryLevelConfigKey, AgentHostCodexEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostTerminalAutoApproveEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostTerminalAutoApproveRulesConfigKey, getAgentHostTerminalAutoApproveRulesConfig, SESSION_SYNC_ENABLED_SETTING_ID, TERMINAL_AUTO_APPROVE_ENABLED_SETTING_ID, GLOBAL_AUTO_APPROVE_SETTING_ID, AUTO_REPLY_SETTING_ID, PREFER_LONG_CONTEXT_SETTING_ID, TERMINAL_AUTO_APPROVE_SETTING_ID, TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; /** * Renderer-side implementation of {@link IAgentHostService} that connects @@ -88,6 +91,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentService environmentService: IEnvironmentService, @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { super(); @@ -137,6 +141,9 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos if (e.affectsConfiguration(AUTO_REPLY_SETTING_ID)) { this._updateAutoReplyEnabled(); } + if (e.affectsConfiguration(PREFER_LONG_CONTEXT_SETTING_ID)) { + this._updatePreferLongContextEnabled(); + } if (e.affectsConfiguration(TERMINAL_AUTO_APPROVE_SETTING_ID) || e.affectsConfiguration(TERMINAL_IGNORE_DEFAULT_AUTO_APPROVE_RULES_SETTING_ID)) { this._updateTerminalAutoApproveRules(); } @@ -145,7 +152,7 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos } })); - if (isAgentHostEnabled(this._configurationService)) { + if (agentHostEnablementService.enabled) { this._connect(); } } @@ -167,16 +174,14 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos // calls (vscode-agent-client filesystem reads) back to this renderer // via `IPCServer.getChannel(name, c => c.ctx === clientId)`. const client = store.add(new MessagePortClient(port, this.clientId)); - // Serve filesystem reverse-RPCs from the local file service. The - // agent host registers an authority on its - // AgentHostClientFileSystemProvider that calls back through this channel. - client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, this._instantiationService.createInstance(AgentHostClientResourceChannel, this._ahpLogger)); + registerAgentHostClientChannels(client, this._instantiationService, this._logService, this._ahpLogger, this._configurationService.getValue(AgentHostByokModelsEnabledSettingId) === true); this._clientEventually.complete(client); this._updateTelemetryLevel(); this._updateSessionSyncEnabled(); this._updateTerminalAutoApproveEnabled(); this._updateGlobalAutoApproveEnabled(); this._updateAutoReplyEnabled(); + this._updatePreferLongContextEnabled(); this._updateTerminalAutoApproveRules(); this._updateCodexEnabled(); @@ -250,6 +255,14 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos }, this.clientId, 0); } + private _updatePreferLongContextEnabled(): void { + const enabled = this._configurationService.getValue(PREFER_LONG_CONTEXT_SETTING_ID) === true; + this.dispatchAction(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [AgentHostPreferLongContextEnabledConfigKey]: enabled }, + }, this.clientId, 0); + } + private _updateCodexEnabled(): void { // Disabling only takes effect on the next agent host restart. const enabled = this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId) === true; @@ -437,3 +450,39 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._connectionTracker.getInspectInfo(tryEnable); } } + +/** + * Register the reverse-RPC server channels every in-process renderer exposes to + * the agent host's {@link UtilityProcessServer}: the filesystem resource bridge + * ({@link AGENT_HOST_CLIENT_RESOURCE_CHANNEL}), the proxy-resolution bridge + * ({@link AGENT_HOST_CLIENT_PROXY_CHANNEL}), and the BYOK language-model bridge + * ({@link AGENT_HOST_CLIENT_BYOK_LM_CHANNEL}). The agent host reaches these via + * `server.getChannel(name, c => c.ctx === clientId)`. + */ +export function registerAgentHostClientChannels( + client: IChannelServer, + instantiationService: IInstantiationService, + logService: ILogService, + ahpLogger: AhpJsonlLogger | undefined, + byokEnabled: boolean, +): void { + // Serve filesystem reverse-RPCs from the local file service. The agent host + // registers an authority on its AgentHostClientFileSystemProvider that calls + // back through this channel. + client.registerChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, instantiationService.createInstance(AgentHostClientResourceChannel, ahpLogger)); + // Serve proxy-resolution reverse-RPCs from the renderer's request service so + // the agent host resolves proxies through VS Code's Electron session (system + // settings / PAC scripts) rather than guessing from environment variables. + client.registerChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL, instantiationService.createInstance(AgentHostClientProxyChannel)); + // Serve BYOK language-model reverse-RPCs from the renderer LM API, gated + // behind `chat.agentHost.byokModels.enabled`. When disabled, the node-side + // proxy + registry are also skipped, so the channel would never be called. + + if (byokEnabled) { + try { + client.registerChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, instantiationService.createInstance(AgentHostClientByokLmChannel)); + } catch (err) { + logService.warn(`[AgentHost:renderer] BYOK language-model bridge not registered for this window. ${err instanceof Error ? err.message : String(err)}`); + } + } +} diff --git a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts index d633d2d9041d04..3be4356c121c82 100644 --- a/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts +++ b/src/vs/platform/agentHost/electron-main/electronAgentHostStarter.ts @@ -19,9 +19,10 @@ import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { UtilityProcess } from '../../utilityProcess/electron-main/utilityProcess.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; +import { buildAgentHostTelemetryIdEnv, IAgentHostForwardedTelemetryIds } from '../common/agentHostTelemetryEnv.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, AgentHostOTelPolicyIpcChannel, buildAgentHostOTelEnv, buildAgentSdkEnv, IAgentHostOTelSettings, sanitizeAgentHostOTelPolicySettings } from '../common/agentService.js'; import { deepClone } from '../../../base/common/objects.js'; -import '../common/agentHost.config.contribution.js'; +import '../common/agentHostEnablementService.js'; import '../common/agentHostStarter.config.contribution.js'; export class ElectronAgentHostStarter extends Disposable implements IAgentHostStarter { @@ -44,6 +45,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt private _otelPolicyFromRenderer: IAgentHostOTelSettings | undefined = undefined; constructor( + private readonly _telemetryIds: IAgentHostForwardedTelemetryIds, @IConfigurationService private readonly _configurationService: IConfigurationService, @IEnvironmentMainService private readonly _environmentMainService: IEnvironmentMainService, @ILifecycleMainService private readonly _lifecycleMainService: ILifecycleMainService, @@ -94,6 +96,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt codexBinaryArgs: this._configurationService.getValue(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue(AgentHostByokModelsEnabledSettingId), }, process.env); // Translate `chat.agentHost.otel.*` settings into the env vars consumed by @@ -133,6 +136,11 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt args.push('--disable-telemetry'); } + // Forward the host's resolved telemetry identifiers so the agent host + // reuses the same persisted machineId/sqmId/devDeviceId instead of + // recomputing them live (which can diverge). See `agentHostTelemetryEnv`. + const telemetryIdEnv = buildAgentHostTelemetryIdEnv(this._telemetryIds); + this.utilityProcess.start({ type: 'agentHost', name: 'agent-host', @@ -147,6 +155,7 @@ export class ElectronAgentHostStarter extends Disposable implements IAgentHostSt VSCODE_VERBOSE_LOGGING: 'true', ...sdkEnv, ...otelEnv, + ...telemetryIdEnv, } }); diff --git a/src/vs/platform/agentHost/node/activeClientState.ts b/src/vs/platform/agentHost/node/activeClientState.ts index b94c5bf8bd089c..d758a0e2180947 100644 --- a/src/vs/platform/agentHost/node/activeClientState.ts +++ b/src/vs/platform/agentHost/node/activeClientState.ts @@ -123,12 +123,14 @@ export class ActiveClientToolSet { } /** - * The `clientId` that owns the merged tool named `toolName` (the - * first-inserted contributor of that name), or `undefined` when no active - * client provides it. Used to stamp client tool calls with their owning - * client at invocation time. + * The `clientId` that owns the tool named `toolName`, or `undefined` when + * no active client provides it. When `preferredClientId` currently provides + * the tool it wins; otherwise the first-inserted contributor wins. */ - ownerOf(toolName: string): string | undefined { + ownerOf(toolName: string, preferredClientId?: string): string | undefined { + if (preferredClientId && this.get(preferredClientId).some(tool => tool.name === toolName)) { + return preferredClientId; + } for (const [clientId, tools] of this._byClient) { if (tools.some(tool => tool.name === toolName)) { return clientId; diff --git a/src/vs/platform/agentHost/node/agentConfigurationService.ts b/src/vs/platform/agentHost/node/agentConfigurationService.ts index 152ee60d5f1418..f533abf09e0ad8 100644 --- a/src/vs/platform/agentHost/node/agentConfigurationService.ts +++ b/src/vs/platform/agentHost/node/agentConfigurationService.ts @@ -12,6 +12,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, defaultAgentHostCustomizationConfigValues } from '../common/agentHostCustomizationConfig.js'; +import { copilotCliConfigSchema } from '../common/copilotCliConfig.js'; import { sandboxConfigSchema } from '../common/sandboxConfigSchema.js'; import type { ISchema, SchemaDefinition, SchemaValue } from '../common/agentHostSchema.js'; import { ProtocolError } from '../common/state/sessionProtocol.js'; @@ -132,10 +133,11 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi const existing = this._stateManager.rootState.config; const ownSchema = agentHostCustomizationConfigSchema.toProtocol(); const sandboxSchema = sandboxConfigSchema.toProtocol(); + const copilotCliSchema = copilotCliConfigSchema.toProtocol(); this._stateManager.rootState.config = { schema: { type: 'object', - properties: { ...existing?.schema.properties, ...ownSchema.properties, ...sandboxSchema.properties }, + properties: { ...existing?.schema.properties, ...ownSchema.properties, ...sandboxSchema.properties, ...copilotCliSchema.properties }, }, values: { ...existing?.values, ...this._loadPersistedRootConfig() }, }; @@ -280,6 +282,7 @@ export class AgentConfigurationService extends Disposable implements IAgentConfi return { ...agentHostCustomizationConfigSchema.validateOrDefault(parsed, defaults), ...sandboxConfigSchema.validateOrDefault(parsed, {}), + ...copilotCliConfigSchema.validateOrDefault(parsed, {}), }; } catch (err) { const code = err && typeof err === 'object' && hasKey(err, { code: true }) ? String(err.code) : undefined; diff --git a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts index 904dcf1882eca5..18e9901a1224dd 100644 --- a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts @@ -22,6 +22,7 @@ export class AgentHostAuthenticationService { async authenticate(params: AuthenticateParams, providers: Iterable): Promise { this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`); + const providerList = [...providers]; // Multiple providers may share the same protected resource (e.g. // both Copilot CLI and Claude consume the GitHub Copilot token). // Fan out to every matching provider in parallel; the request is @@ -29,7 +30,7 @@ export class AgentHostAuthenticationService { // failures are isolated -- one provider rejecting (e.g. proxy // server bind failure) MUST NOT prevent another provider from // accepting the same token. - const matching = [...providers].filter( + const matching = providerList.filter( p => p.getProtectedResources().some(r => r.resource === params.resource), ); const settled = await Promise.allSettled( @@ -47,6 +48,21 @@ export class AgentHostAuthenticationService { ); } } + const sessionResourceHandlers = providerList.filter(p => p.handleAuthenticationToken); + const sessionResourceSettled = await Promise.allSettled( + sessionResourceHandlers.map(p => p.handleAuthenticationToken ? p.handleAuthenticationToken(params) : Promise.resolve(false)), + ); + for (let i = 0; i < sessionResourceSettled.length; i++) { + const result = sessionResourceSettled[i]; + if (result.status === 'fulfilled') { + authenticated ||= result.value; + } else { + this._logService.error( + result.reason, + `[AgentHostAuthenticationService] Provider '${sessionResourceHandlers[i].id}' handleAuthenticationToken threw for resource=${params.resource}`, + ); + } + } if (authenticated) { const scopes = this._normalizeScopes(params.scopes); this._tokens.set(this._key(params.resource, scopes), { resource: params.resource, scopes, token: params.token }); diff --git a/src/vs/platform/agentHost/node/agentHostBangCommand.ts b/src/vs/platform/agentHost/node/agentHostBangCommand.ts new file mode 100644 index 00000000000000..df6522eaf4f5ab --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostBangCommand.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * The leading character that marks a chat message as a terminal command. + */ +export const BANG_COMMAND_PREFIX = '!'; + +/** + * Parses a leading `!` at the very start of `prompt`. + * + * Like {@link parseRenameCommand}, the marker must be at position 0 (no leading + * whitespace). A lone `!` or `!` followed only by whitespace is not treated as + * a bang command — the caller should forward such messages normally. + * + * Returns the trimmed command string when the prompt is a bang command, or + * `undefined` when it is not. + */ +export function parseBangCommand(prompt: string): string | undefined { + if (!prompt.startsWith(BANG_COMMAND_PREFIX)) { + return undefined; + } + const command = prompt.slice(BANG_COMMAND_PREFIX.length).trim(); + return command.length > 0 ? command : undefined; +} diff --git a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts index 6a63b1395f74de..eef150a67e5cbe 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetCoordinator.ts @@ -6,7 +6,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { IAgentSessionMetadata } from '../common/agentService.js'; -import { buildBranchChangesetUri, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, formatSessionChangesetDescription as formatBranchChangesChangesetDescription, parseChangesetUri } from '../common/changesetUri.js'; +import { buildBranchChangesetUri, ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; import { ChangesetFileMonitorCoordinator } from './agentHostChangesetFileMonitorCoordinator.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostChangesetService, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS } from '../common/agentHostChangesetService.js'; @@ -14,7 +14,7 @@ import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChang import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { IAgentHostGitStateService } from '../common/agentHostGitStateService.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; -import { readSessionGitState } from '../common/state/sessionState.js'; +import { isAhpChatChannel } from '../common/state/sessionState.js'; /** * Raw metadata blob values for the session DB, batch-read by the caller. @@ -71,6 +71,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * `SessionReady` is dispatched. */ onSessionCreated(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); } @@ -82,6 +83,7 @@ export class AgentHostChangesetCoordinator extends Disposable { * keys. */ onSessionRestored(sessionStr: string, metadata: IChangesetSessionMetadata): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.registerStaticChangesets(sessionStr); this._changesets.restorePersistedStaticChangesets(sessionStr, { branchRaw: metadata[META_CHANGESET_BRANCH], @@ -101,7 +103,9 @@ export class AgentHostChangesetCoordinator extends Disposable { * because the working directory was not yet known. */ onSessionMaterialized(sessionStr: string): void { + this._changesets.refreshChangesetCatalog(sessionStr); this._changesets.onWorkingDirectoryAvailable(sessionStr); + this._changesetFileMonitor.onSessionMaterialized(sessionStr); } @@ -141,24 +145,39 @@ export class AgentHostChangesetCoordinator extends Disposable { const resourceStr = resource.toString(); const parsed = parseChangesetUri(resourceStr); + if (!parsed && !isAhpChatChannel(resourceStr) && this._stateManager.getSessionState(resourceStr)) { + // For the session URI, we add a subscription for the branch + // changeset since this is the changeset that is being used to + // track the changes that are being used to calculate the diff + // statistics for the session changes. + this._addSubscription(resourceStr, buildBranchChangesetUri(resourceStr)); + this._changesets.refreshBranchChangeset(resourceStr); + this._changesetFileMonitor.trackSessionChanges(resourceStr, resourceStr); + + return; + } + if (parsed?.kind === ChangesetKind.Branch) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshBranchChangeset(parsed.sessionUri); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Uncommitted) { this._addSubscription(parsed.sessionUri, resourceStr); void this._changesets.computeUncommittedChangeset(parsed.sessionUri); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Session) { this._addSubscription(parsed.sessionUri, resourceStr); this._changesets.refreshSessionChangeset(parsed.sessionUri); this._changesetFileMonitor.trackSessionChanges(resourceStr, parsed.sessionUri); return; } + if (parsed?.kind === ChangesetKind.Turn && parsed.turnId !== undefined) { // Track the new subscriber so the service's per-turn recompute // gating starts including this turn. The initial snapshot is @@ -168,19 +187,6 @@ export class AgentHostChangesetCoordinator extends Disposable { this._addSubscription(parsed.sessionUri, resourceStr); return; } - if (!parsed && this._stateManager.getSessionState(resourceStr)) { - // Plain session-URI subscription (Agents Window list / detail - // observing the session). Track the session URI itself as a - // subscription marker so a later git-state change / - // materialization recompute (driven from the exposed - // subscription list) re-refreshes the static changesets, then - // refresh both now so the catalogue chip doesn't show a stale - // value just because no turn has run since process start. - this._addSubscription(resourceStr, resourceStr); - this._changesets.refreshBranchChangeset(resourceStr); - this._changesets.refreshSessionChangeset(resourceStr); - this._changesetFileMonitor.trackSessionChanges(resourceStr, resourceStr); - } } /** @@ -323,82 +329,12 @@ export class AgentHostChangesetCoordinator extends Disposable { * Called when a session's Git state is refreshed. */ private onDidRunSessionGitStateRefresh(sessionStr: string): void { + // Refresh the list of changesets for the session. + this._changesets.refreshChangesetCatalog(sessionStr); + // Git state has been refreshed so we need to recompute every // changeset currently subscribed for the session (the service // reads the exposed subscription list). this._changesets.recomputeSubscribedChangesets(sessionStr); - - // Remove any changesets that are only relevant to Git state. - this._removeGitOnlyChangesets(sessionStr); - - // Update the description of the branch changeset. - this._updateBranchChangesetDescription(sessionStr); - } - - private _removeGitOnlyChangesets(sessionStr: string): void { - const state = this._stateManager.getSessionState(sessionStr); - const gitState = readSessionGitState(state?._meta); - if (gitState) { - return; - } - - const currentChangesets = state?.changesets; - if (!currentChangesets || currentChangesets.length === 0) { - return; - } - - const branchUri = buildBranchChangesetUri(sessionStr); - const sessionUri = buildSessionChangesetUri(sessionStr); - const uncommittedUri = buildUncommittedChangesetUri(sessionStr); - - const nextChangesets = currentChangesets - .filter(c => c.uriTemplate !== branchUri && - c.uriTemplate !== sessionUri && - c.uriTemplate !== uncommittedUri); - if (nextChangesets.length === currentChangesets.length) { - return; - } - - this._stateManager.setSessionChangesets(sessionStr, nextChangesets); - } - - private _updateBranchChangesetDescription(sessionStr: string): void { - const state = this._stateManager.getSessionState(sessionStr); - const gitState = readSessionGitState(state?._meta); - if (!gitState) { - return; - } - - const changesets = state?.changesets; - if (!changesets || changesets.length === 0) { - return; - } - - const branchUri = buildBranchChangesetUri(sessionStr); - const description = formatBranchChangesChangesetDescription(gitState); - - let changed = false; - const nextChangesets = changesets.map(changeset => { - if (changeset.uriTemplate !== branchUri) { - return changeset; - } - if (changeset.description === description) { - return changeset; - } - - changed = true; - if (description === undefined) { - const { description: _omit, ...rest } = changeset; - return rest; - } - - return { ...changeset, description }; - }); - - if (!changed) { - return; - } - - this._stateManager.setSessionChangesets(sessionStr, nextChangesets); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts index 164a4205327736..88ceac5c677a66 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetOperationService.ts @@ -49,12 +49,12 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] | undefined { + getOperations(sessionKey: string, changeset: string, gitState?: ISessionGitState, gitHubState?: ISessionGitHubState): readonly ChangesetOperation[] { if (!gitState) { const sessionState = this._stateManager.getSessionState(sessionKey); gitState = readSessionGitState(sessionState?._meta); if (!gitState) { - return undefined; + return []; } } @@ -64,7 +64,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA const parsed = parseChangesetUri(changeset); if (!parsed) { - return undefined; + return []; } return this._getOperations({ @@ -76,7 +76,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA }); } - private _getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] | undefined { + private _getOperations(context: IChangesetOperationContext): readonly ChangesetOperation[] { const operations: ChangesetOperation[] = []; for (const contribution of this._handlerRegistrations.keys()) { const contributed = contribution.getOperations(context); @@ -84,9 +84,6 @@ export class AgentHostChangesetOperationService extends Disposable implements IA operations.push(...contributed); } } - if (operations.length === 0) { - return undefined; - } // Operations are disabled while a turn is active so the working tree / // branch state can't be mutated mid-request. @@ -123,7 +120,7 @@ export class AgentHostChangesetOperationService extends Disposable implements IA this._stateManager.dispatchServerAction(changeset, { type: ActionType.ChangesetOperationsChanged, - operations: operations ? [...operations] : undefined, + operations: [...operations], }); } } diff --git a/src/vs/platform/agentHost/node/agentHostChangesetService.ts b/src/vs/platform/agentHost/node/agentHostChangesetService.ts index a2eea6cfbf3aae..931379be5d7209 100644 --- a/src/vs/platform/agentHost/node/agentHostChangesetService.ts +++ b/src/vs/platform/agentHost/node/agentHostChangesetService.ts @@ -16,6 +16,7 @@ import { buildUncommittedChangesetUri, parseChangesetUri, ChangesetKind, + buildDefaultChangesetCatalog, } from '../common/changesetUri.js'; import { IDiffComputeService } from '../common/diffComputeService.js'; import { ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; @@ -28,10 +29,11 @@ import { type URI as ProtocolURI, readSessionGitState, isDefaultChatUri, + SessionLifecycle, } from '../common/state/sessionState.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; -import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../common/agentHostGitService.js'; +import { IAgentHostGitService, META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js'; import { IAgentHostCheckpointService } from '../common/agentHostCheckpointService.js'; import { NodeWorkerDiffComputeService } from './diffComputeService.js'; import { computeSessionDiffs, computeTurnDiffs, computeUnionedDiffs, type IIncrementalDiffOptions, type ISessionDiffSource } from './sessionDiffAggregator.js'; @@ -39,6 +41,8 @@ import { META_CHECKPOINT_WORKING_DIR } from './agentHostCheckpointService.js'; import { IAgentHostChangesetService, IPersistedChangesetMetadata, IRestoredChangesetDiffs, CHANGESET_DB_METADATA_KEYS, META_CHANGES_SUMMARY, META_CHANGESET_BRANCH, META_CHANGESET_SESSION, META_LEGACY_DIFFS, StaticChangesetKind } from '../common/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { relativePath } from '../../../base/common/resources.js'; function staticChangesetUri(session: ProtocolURI, kind: StaticChangesetKind): ProtocolURI { return kind === 'branch' @@ -85,7 +89,7 @@ function summariseDiffs(diffs: readonly ISessionFileDiff[] | undefined): Changes * Only the `changeKind: 'session'` entry feeds the summary; other kinds * (`'uncommitted'`, `'turn'`, `'compare-turns'`) describe slices, not * the session-level footprint. The static catalogue itself (built by - * {@link buildDefaultChangesetCatalogue}) is independent of counts and + * {@link buildDefaultChangesetCatalog}) is independent of counts and * is seeded once at session creation. */ function computeChangesSummaryFromLiveState( @@ -161,6 +165,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostChangesetOperationService private readonly _changesetOperationService: IAgentHostChangesetOperationService, @IAgentHostChangesetSubscriptionService private readonly _changesetSubscriptions: IAgentHostChangesetSubscriptionService, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, ) { super(); this._diffComputeService = this._register(new NodeWorkerDiffComputeService(this._logService)); @@ -257,9 +262,9 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // Read live state for an unopened session: synthesise the aggregate - // from the live `changeKind: 'session'` changeset state. Counts stay + // from the live `changeKind: 'branch'` changeset state. Counts stay // in lockstep with the actual changeset state for the session-list chip. - const liveSession = this._stateManager.getChangesetState(buildSessionChangesetUri(sessionUri)); + const liveSession = this._stateManager.getChangesetState(buildBranchChangesetUri(sessionUri)); const liveChanges = computeChangesSummaryFromLiveState(liveSession); if (liveChanges) { // Migrate the changes summary to the new storage mechanism. @@ -268,18 +273,18 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // No live source — try persisted blobs (if the caller batched them). - const sessionRaw = metadata[META_CHANGESET_SESSION]; + const branchRaw = metadata[META_CHANGESET_BRANCH]; const legacyRaw = metadata[META_LEGACY_DIFFS]; - if (sessionRaw === undefined && legacyRaw === undefined) { + if (branchRaw === undefined && legacyRaw === undefined) { return undefined; } - const restored = this.parsePersistedStaticChangesets(sessionUri, { sessionRaw, legacyRaw }); + const restored = this.parsePersistedStaticChangesets(sessionUri, { branchRaw, legacyRaw }); // `listSessions` must not seed full changeset state for every row; it // only parses persisted blobs enough to render the chip aggregate. // Once the session is opened via `restoreSession`, the live overlay in // `AgentService.listSessions` replaces this parse-only aggregate. - const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.session); + const persistedChanges = computeChangesSummaryFromPersistedDiffs(restored.branch); if (persistedChanges) { // Migrate the changes summary to the new storage mechanism. this.persistChangesSummary(sessionUri, persistedChanges); @@ -304,6 +309,17 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC this.restoreStaticChangeset(session, kind, diffs); } + refreshChangesetCatalog(session: ProtocolURI): void { + const state = this._stateManager.getSessionState(session); + if (state?.lifecycle !== SessionLifecycle.Ready) { + return; + } + + const gitState = readSessionGitState(state?._meta); + const changesets = buildDefaultChangesetCatalog(session, gitState); + this._stateManager.setSessionChangesets(session, changesets); + } + refreshBranchChangeset(session: ProtocolURI): void { if (!this._hasWorkingDirectory(session)) { this._pendingMaterialization.add(session); @@ -795,19 +811,29 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } - this._publishChangesetDiffs(session, changesetUri, diffs); + const reviewed = kind === ChangesetKind.Branch + ? await this._computeReviewedInfo(session, ref.object) + : undefined; + this._publishChangesetDiffs(session, changesetUri, diffs, reviewed); // Persist the file list so a subsequent `listSessions` / // `restoreSession` can reseed the changeset before the first // post-restart compute completes. this._persistSessionFlag(session, persistKeyFor(kind), JSON.stringify(diffs)); - // Migration: also overwrite the legacy `'diffs'` key with the - // session-changeset payload so older readers stay correct - // during the rollout window. - if (kind === 'session') { + + if (kind === ChangesetKind.Branch) { + // Migration: also overwrite the legacy `'diffs'` key with the + // session-changeset payload so older readers stay correct + // during the rollout window. this._persistSessionFlag(session, META_LEGACY_DIFFS, JSON.stringify(diffs)); - // Persist the changes summary and update the in-memory session summary. + // Persist the changes summary and update the in-memory session + // summary from the BRANCH changeset. The session-list chip and the + // inactive-session aggregate (`computeListEntryChanges`) read the + // branch changeset, as does the active session view, so sourcing + // the persisted summary from the same place keeps the count stable + // across the active <-> inactive transition instead of flipping to + // the (different) session changeset's count. const changesSummary = summariseDiffs(diffs) ?? { additions: 0, deletions: 0, files: 0 }; this.persistChangesSummary(session, changesSummary); this._stateManager.setSessionSummaryChanges(session, changesSummary); @@ -861,7 +887,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC * (fileSet, fileRemoved) and moves the changeset to `ready` once the * fresh file list has been applied. */ - private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[]): void { + private _publishChangesetDiffs(session: ProtocolURI, changesetUri: ProtocolURI, diffs: readonly ISessionFileDiff[], reviewed?: { readonly repoRoot: URI; readonly paths: ReadonlySet }): void { // Get the available operations for this changeset. This call assumes that at this point // the git state of the session is up-to-date as it is being used to determine the available // operations. Long term this should be replaced with a more robust mechanism. @@ -873,7 +899,15 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC if (!id) { continue; } - files.push({ id, edit }); + if (reviewed) { + // Surface per-file review status for the Branch changeset. The + // file id is a `file:` URI under the repository root, so the + // repo-relative path keys into the reviewed-paths set. + const relPath = relativePath(reviewed.repoRoot, URI.parse(id)); + files.push({ id, edit, _meta: { reviewed: relPath ? reviewed.paths.has(relPath) : false } }); + } else { + files.push({ id, edit }); + } } this._stateManager.dispatchServerAction(changesetUri, { @@ -1012,12 +1046,7 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } // Branch - const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); - const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName; - const baseBranch = persistedBaseBranch ?? gitStateBaseBranch; - if (!persistedBaseBranch && gitStateBaseBranch) { - this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`); - } + const baseBranch = await this._resolveBranchBaseBranch(session, db); try { return await this._gitService.computeSessionFileDiffs(workingDirectoryUri, { @@ -1030,6 +1059,49 @@ export class AgentHostChangesetService extends Disposable implements IAgentHostC } } + /** + * Resolves the Branch Changes base branch, reused by the diff computation + * and the review-status lookup so both are keyed on the same baseline. + */ + private async _resolveBranchBaseBranch(session: ProtocolURI, db: ISessionDatabase): Promise { + const persistedBaseBranch = await db.getMetadata(META_DIFF_BASE_BRANCH); + const gitStateBaseBranch = readSessionGitState(this._stateManager.getSessionState(session)?._meta)?.baseBranchName; + if (!persistedBaseBranch && gitStateBaseBranch) { + this._logService.debug(`[AgentHostChangesetService] Using _meta.git base branch fallback for Branch Changes in ${session}: ${gitStateBaseBranch}`); + } + return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch); + } + + /** + * Computes the reviewed-paths overlay for the Branch changeset: the + * repository root (used to key file ids to repo-relative paths) and the set + * of reviewed repo-relative paths. Returns `undefined` when the session has + * no git working directory (review status is then simply omitted). + */ + private async _computeReviewedInfo(session: ProtocolURI, db: ISessionDatabase): Promise<{ readonly repoRoot: URI; readonly paths: ReadonlySet } | undefined> { + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + if (!workingDirectory) { + return undefined; + } + + let workingDirectoryUri: URI; + try { + workingDirectoryUri = URI.parse(workingDirectory); + } catch { + return undefined; + } + + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectoryUri); + if (!repoRoot) { + return undefined; + } + + const baseBranch = await this._resolveBranchBaseBranch(session, db); + const paths = await this._reviewService.getReviewedPaths(session, workingDirectoryUri, baseBranch); + + return { repoRoot, paths }; + } + /** * Persists a session metadata key/value pair to the session database. * Counterpart in `agentSideEffects.ts` (`AgentSideEffects._persistSessionFlag`): diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts index 9426a1051e56f9..c90b894cb366af 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationHandler.ts @@ -7,7 +7,8 @@ import { basename } from '../../../base/common/resources.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { GITHUB_COPILOT_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { IAgentService } from '../common/agentService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; @@ -27,6 +28,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, private readonly _onCommitted: (sessionKey: string) => Promise, @IAgentService private readonly _agentService: IAgentService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @ILogService private readonly _logService: ILogService, @@ -75,15 +77,16 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl } this._throwIfCancelled(token); + const copilotResource = this._gitHubEndpointService.getCopilotResource(); const authToken = this._agentService.getAuthToken({ - resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, - scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + resource: copilotResource.resource, + scopes: copilotResource.scopes_supported, }); if (!authToken) { throw new ProtocolError( AHP_AUTH_REQUIRED, localize('agentHost.changeset.commit.authRequired', "Sign in to GitHub Copilot to generate a commit message."), - [GITHUB_COPILOT_PROTECTED_RESOURCE], + [copilotResource], ); } @@ -104,7 +107,7 @@ export class AgentHostCommitOperationHandler implements IChangesetOperationHandl throw new ProtocolError( AHP_AUTH_REQUIRED, localize('agentHost.changeset.commit.authExpired', "Authentication is required to generate a commit message. Please sign in to GitHub Copilot and try again."), - [GITHUB_COPILOT_PROTECTED_RESOURCE], + [copilotResource], ); } throw err; diff --git a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts index 5b6557848e14b1..d7eea1afbfa55b 100644 --- a/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostCommitOperationProvider.ts @@ -32,9 +32,13 @@ export class AgentHostCommitOperationContribution extends Disposable implements return store; } - getOperations({ gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitHubState, gitState }: IChangesetOperationContext): ChangesetOperation[] { if ((gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; + } + + if (!gitHubState?.pullRequestUrl && changesetKind !== 'uncommitted') { + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts index 507eef496f0100..cb6935a324aafc 100644 --- a/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts +++ b/src/vs/platform/agentHost/node/agentHostDiscardChangesOperationProvider.ts @@ -30,9 +30,9 @@ export class AgentHostDiscardChangesOperationContribution extends Disposable imp return store; } - getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] | undefined { + getOperations({ changesetKind, gitState }: IChangesetOperationContext): ChangesetOperation[] { if (changesetKind !== ChangesetKind.Uncommitted || (gitState?.uncommittedChanges ?? 0) <= 0) { - return undefined; + return []; } return [{ diff --git a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts index 4440ed0572a643..a85271cdaba508 100644 --- a/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts +++ b/src/vs/platform/agentHost/node/agentHostFileMonitorService.ts @@ -15,6 +15,7 @@ import { ILogService } from '../../log/common/log.js'; export const IAgentHostFileMonitorService = createDecorator('agentHostFileMonitorService'); export const DEFAULT_AGENT_HOST_WATCH_EXCLUDES: readonly string[] = Object.freeze([ + '**/.git', '**/.git/lfs/**', '**/.git/logs/**', '**/.git/objects/**', diff --git a/src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts b/src/vs/platform/agentHost/node/agentHostGitHubEndpointService.ts new file mode 100644 index 00000000000000..e15e7f022120fe --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostGitHubEndpointService.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 { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../common/agentHostCustomizationConfig.js'; +import { deriveGitHubEndpoints, gitHubCopilotResource, gitHubRepoResource, IGitHubEndpoints } from '../common/githubEndpoints.js'; +import { ProtectedResourceMetadata } from '../common/state/protocol/state.js'; +import { IAgentConfigurationService } from './agentConfigurationService.js'; + +export const IAgentHostGitHubEndpointService = createDecorator('agentHostGitHubEndpointService'); + +/** + * Single source of truth for the GitHub endpoints (protected resources + REST / + * GraphQL hosts) the agent host talks to. Computed from the optional + * `githubEnterpriseUri` root config so that every consumer — agent + * `authenticate` / `getProtectedResources`, changeset operation `getAuthToken` + * lookups, and the REST client — agrees on the same resource identifiers and API + * base. With no enterprise URI configured, the values are byte-for-byte the + * github.com defaults. + */ +export interface IAgentHostGitHubEndpointService { + readonly _serviceBrand: undefined; + + /** + * Fires when the configured GitHub endpoints change (e.g. `githubEnterpriseUri` + * was set, cleared, or repointed). Does NOT fire for unrelated root-config + * changes. + */ + readonly onDidChange: Event; + + /** The GitHub Copilot protected resource, computed against the configured endpoints. */ + getCopilotResource(): ProtectedResourceMetadata; + + /** The GitHub repository protected resource, computed against the configured endpoints. */ + getRepoResource(): ProtectedResourceMetadata; + + /** The REST API base URI (no trailing slash), e.g. `https://api.github.com`. */ + getApiBaseUri(): string; + + /** The GraphQL endpoint URI, e.g. `https://api.github.com/graphql`. */ + getGraphQlUri(): string; + + /** + * The configured GitHub Enterprise host (authority only, e.g. `acme.ghe.com`), + * or `undefined` for github.com. Used to set `COPILOT_GH_HOST` for the Copilot CLI. + */ + getEnterpriseHost(): string | undefined; + + /** + * The raw configured GitHub Enterprise base URI (e.g. `https://acme.ghe.com`), + * or `undefined` for github.com. This is the value the `@vscode/copilot-api` + * `CAPIClient.updateDomains(..., enterpriseUrlConfig)` expects: it derives the + * GitHub API host (`api.`) used for `copilot_internal` endpoints (token + * mint, etc.) from it. Distinct from {@link getApiBaseUri} (the already-derived + * `api.` host) - the package does that derivation itself. + */ + getEnterpriseUri(): string | undefined; +} + +export class AgentHostGitHubEndpointService extends Disposable implements IAgentHostGitHubEndpointService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + private _endpoints: IGitHubEndpoints; + private _enterpriseUri: string | undefined; + + constructor( + @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + const resolved = this._resolve(); + this._endpoints = resolved.endpoints; + this._enterpriseUri = resolved.enterpriseUri; + this._register(this._configurationService.onDidRootConfigChange(() => { + const next = this._resolve(); + // `onDidRootConfigChange` fires for every root-config key; only react + // when the derived GitHub endpoints actually change. + if (next.endpoints.apiBaseUri === this._endpoints.apiBaseUri + && next.endpoints.graphQlUri === this._endpoints.graphQlUri + && next.endpoints.oauthServer === this._endpoints.oauthServer) { + return; + } + this._logService.info(`[AgentHost] GitHub endpoints changed (api=${next.endpoints.apiBaseUri})`); + this._endpoints = next.endpoints; + this._enterpriseUri = next.enterpriseUri; + this._onDidChange.fire(); + })); + } + + private _resolve(): { endpoints: IGitHubEndpoints; enterpriseUri: string | undefined } { + const enterpriseUri = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.GithubEnterpriseUri); + return { endpoints: deriveGitHubEndpoints(enterpriseUri), enterpriseUri: enterpriseUri || undefined }; + } + + getApiBaseUri(): string { + return this._endpoints.apiBaseUri; + } + + getGraphQlUri(): string { + return this._endpoints.graphQlUri; + } + + getEnterpriseHost(): string | undefined { + return this._endpoints.enterpriseHost; + } + + getEnterpriseUri(): string | undefined { + return this._enterpriseUri; + } + + getCopilotResource(): ProtectedResourceMetadata { + return gitHubCopilotResource(this._endpoints); + } + + getRepoResource(): ProtectedResourceMetadata { + return gitHubRepoResource(this._endpoints); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostGitService.ts b/src/vs/platform/agentHost/node/agentHostGitService.ts index 40b75702b378aa..d74d402029fe6c 100644 --- a/src/vs/platform/agentHost/node/agentHostGitService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitService.ts @@ -139,7 +139,7 @@ export class AgentHostGitService implements IAgentHostGitService { async commitAll(workingDirectory: URI, message: string): Promise { await this._runGit(workingDirectory, ['add', '-A', '--', ':/'], { throwOnError: true }); - await this._runGit(workingDirectory, ['commit', '--no-verify', '--no-gpg-sign', '-m', message], { timeout: 60_000, throwOnError: true }); + await this._runGit(workingDirectory, ['commit', '--no-verify', '-m', message], { timeout: 60_000, throwOnError: true }); } async restore(workingDirectory: URI, paths: readonly string[], options?: { readonly staged?: boolean; readonly ref?: string }): Promise { @@ -216,24 +216,8 @@ export class AgentHostGitService implements IAgentHostGitService { return undefined; } - // Resolve the merge-base commit. With a base branch, prefer the - // corresponding origin/ remote-tracking ref when it exists so - // branch changes match a PR-style comparison even if the local base - // branch is stale. Without a usable base, fall back to HEAD itself, - // which surfaces uncommitted work but no committed-on-branch work - - // the best we can do without context. For empty repos with no HEAD, - // fall back to the well-known empty-tree object. - let mergeBaseCommit: string | undefined; - if (options.baseBranch) { - const baseBranch = await this._resolveRemoteTrackingBranch(repositoryRoot, options.baseBranch) ?? options.baseBranch; - mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', baseBranch]))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim(); - } - if (!mergeBaseCommit) { - mergeBaseCommit = EMPTY_TREE_OBJECT; - } + // Resolve the merge-base commit the Branch Changes diff is anchored on. + const mergeBaseCommit = await this._resolveBranchMergeBaseCommit(repositoryRoot, options.baseBranch); // Detect whether the working tree has any untracked files. If so we // have to use the temp-index trick so the untracked content is @@ -260,6 +244,38 @@ export class AgentHostGitService implements IAgentHostGitService { return parseGitDiffRawNumstat(rawDiffOutput, repositoryRoot, options.sessionUri, mergeBaseCommit); } + async resolveBranchBaselineCommit(workingDirectory: URI, baseBranch?: string): Promise { + const repositoryRoot = await this.getRepositoryRoot(workingDirectory); + if (!repositoryRoot) { + return undefined; + } + + return this._resolveBranchMergeBaseCommit(repositoryRoot, baseBranch); + } + + /** + * Resolves the merge-base commit-ish the Branch Changes baseline is anchored + * on. With a base branch, prefers the corresponding `origin/` + * remote-tracking ref when it exists so branch changes match a PR-style + * comparison even if the local base branch is stale. Without a usable base, + * falls back to `HEAD` (surfaces uncommitted work but no committed-on-branch + * work). For empty repos with no `HEAD`, falls back to the empty-tree object. + * Always resolves to a commit-ish (never `undefined`) once the repository + * root is known. + */ + private async _resolveBranchMergeBaseCommit(repositoryRoot: URI, baseBranch?: string): Promise { + let mergeBaseCommit: string | undefined; + if (baseBranch) { + const resolvedBase = await this._resolveRemoteTrackingBranch(repositoryRoot, baseBranch) ?? baseBranch; + mergeBaseCommit = (await this._runGit(repositoryRoot, ['merge-base', 'HEAD', resolvedBase]))?.trim(); + } + if (!mergeBaseCommit) { + mergeBaseCommit = (await this._runGit(repositoryRoot, ['rev-parse', 'HEAD']))?.trim(); + } + + return mergeBaseCommit ?? EMPTY_TREE_OBJECT; + } + private async _runWithTempIndex(repositoryRoot: URI, mergeBaseCommit: string, changedPaths: readonly string[]): Promise { // Build a throwaway index so we can stage the changed working tree // paths (including untracked files) without disturbing the user's real @@ -314,25 +330,17 @@ export class AgentHostGitService implements IAgentHostGitService { return output !== undefined ? remoteBranch : undefined; } - async showBlob(workingDirectory: URI, sha: string, repoRelativePath: string): Promise { - // Validate sha before passing it to git. `git show :` parses - // its argument as a revision, so an attacker-controlled sha that starts - // with `-` could inject options, and a non-hex value could resolve to - // commit could resolve to surprising refs. Object names are 4-64 lowercase hex chars. - if (!/^[0-9a-f]{4,64}$/.test(sha)) { - return undefined; - } - + async showBlob(workingDirectory: URI, ref: string, repoRelativePath: string): Promise { const repositoryRoot = await this.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { return undefined; } // `git show` exits non-zero when the path didn't exist at that - // commit; `_runGit` swallows that into `undefined` which is exactly + // ref; `_runGit` swallows that into `undefined` which is exactly // the contract callers want. return new Promise((resolve) => { - cp.execFile('git', ['show', `${sha}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { + cp.execFile('git', ['show', `${ref}:${repoRelativePath}`], { cwd: workingDirectory.fsPath, timeout: 5000, encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 }, (error, stdout) => { if (error) { resolve(undefined); return; @@ -413,6 +421,58 @@ export class AgentHostGitService implements IAgentHostGitService { return out?.trim() || undefined; } + async overlayPathIntoTree(repositoryRoot: URI, baseTreeOid: string, path: string, sourceTreeOid: string): Promise { + // Build a throwaway index seeded from `baseTreeOid`, replace/remove the + // single `path` using `sourceTreeOid`, and write the result back out as + // a new tree. The user's real index is never touched (mirrors the + // temp-index technique used by `captureWorkingTreeAsTree`). + const tempDir = URI.joinPath(this._environmentService.tmpDir, `agent-host-review-overlay-${generateUuid()}`); + await this._fileService.createFolder(tempDir); + const indexFile = URI.joinPath(tempDir, 'index').fsPath; + const env: Record = { GIT_INDEX_FILE: indexFile, COMMAND_HOOK_LOCK: '1' }; + + try { + const readTreeOut = await this._runGit(repositoryRoot, ['read-tree', baseTreeOid], { env, throwOnError: false }); + if (readTreeOut === undefined) { + return undefined; + } + + // Resolve the source blob (mode + oid) for `path`. `-z` avoids + // path quoting; an empty result means the path is absent in the + // source tree, so the overlay removes it from the base. + const lsTreeOut = await this._runGit(repositoryRoot, ['ls-tree', '-z', sourceTreeOid, '--', path], { env }); + const entry = parseSingleLsTreeEntry(lsTreeOut); + if (entry) { + const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--add', '--cacheinfo', `${entry.mode},${entry.oid},${path}`], { env, throwOnError: false }); + if (updateIndexOut === undefined) { + return undefined; + } + } else { + // `--force-remove` tolerates the path already being absent from + // the index, so removing an untracked/added path is a no-op. + const updateIndexOut = await this._runGit(repositoryRoot, ['update-index', '--force-remove', '--', path], { env, throwOnError: false }); + if (updateIndexOut === undefined) { + return undefined; + } + } + + const writeTreeOut = await this._runGit(repositoryRoot, ['write-tree'], { env }); + return writeTreeOut?.trim(); + } finally { + try { + await this._fileService.del(tempDir, { recursive: true, useTrash: false }); + } catch { /* best-effort */ } + } + } + + async diffTreePaths(repositoryRoot: URI, fromTreeish: string, toTreeish: string): Promise { + const out = await this._runGit(repositoryRoot, ['diff', '--name-only', '--no-renames', '-z', fromTreeish, toTreeish, '--']); + if (out === undefined) { + return undefined; + } + return out.split('\x00').filter(Boolean); + } + async computeFileDiffsBetweenRefs(workingDirectory: URI, options: { readonly sessionUri: string; readonly fromRef: string; readonly toRef: string }): Promise { const repositoryRoot = await this.getRepositoryRoot(workingDirectory); if (!repositoryRoot) { @@ -634,6 +694,30 @@ export function parseChangedPaths(output: string | undefined, includeStatus: (st return result; } +/** + * Parses NUL-terminated `git ls-tree -z -- ` output for a single + * path and returns its `{ mode, oid }`, or `undefined` when the path is absent + * from the tree (empty output). Each entry has the form + * ` SP SP TAB NUL`; we only need the mode and oid. + * + * Exported for tests. + */ +export function parseSingleLsTreeEntry(output: string | undefined): { mode: string; oid: string } | undefined { + if (!output) { + return undefined; + } + const entry = output.split('\x00')[0]; + if (!entry) { + return undefined; + } + const tabIndex = entry.indexOf('\t'); + const meta = (tabIndex === -1 ? entry : entry.substring(0, tabIndex)).split(' '); + if (meta.length < 3) { + return undefined; + } + return { mode: meta[0], oid: meta[2] }; +} + /** * Parses combined `--raw --numstat -z` output produced by * {@link IAgentHostGitService.computeSessionFileDiffs} and converts each diff --git a/src/vs/platform/agentHost/node/agentHostGitStateService.ts b/src/vs/platform/agentHost/node/agentHostGitStateService.ts index 8f5fe9bb332f16..8155df165362df 100644 --- a/src/vs/platform/agentHost/node/agentHostGitStateService.ts +++ b/src/vs/platform/agentHost/node/agentHostGitStateService.ts @@ -13,7 +13,8 @@ import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { IAgentHostOctoKitService } from './shared/agentHostOctoKitService.js'; -import { GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { IAgentService } from '../common/agentService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { ThrottlerByKey, timeout } from '../../../base/common/async.js'; @@ -33,6 +34,7 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, @IAgentService private readonly _agentService: IAgentService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ILogService private readonly _logService: ILogService, @ISessionDataService private readonly _sessionDataService: ISessionDataService, ) { @@ -65,9 +67,10 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } try { + const repoResource = this._gitHubEndpointService.getRepoResource(); const authToken = this._agentService.getAuthToken({ - resource: GITHUB_REPO_PROTECTED_RESOURCE.resource, - scopes: GITHUB_REPO_PROTECTED_RESOURCE.scopes_supported, + resource: repoResource.resource, + scopes: repoResource.scopes_supported, }); if (!authToken) { return; @@ -91,8 +94,13 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi } async refreshSessionGitState(sessionKey: string, workingDirectory: URI | undefined): Promise { + const sessionState = this._stateManager.getSessionState(sessionKey); + if (sessionState?.lifecycle === SessionLifecycle.Creating) { + return; + } + if (!workingDirectory) { - const workingDirectoryStr = this._stateManager.getSessionState(sessionKey)?.workingDirectory; + const workingDirectoryStr = sessionState?.workingDirectory; if (workingDirectoryStr) { workingDirectory = URI.parse(workingDirectoryStr); } @@ -107,21 +115,19 @@ export class AgentHostGitStateService extends Disposable implements IAgentHostGi this._logService.trace(`[AgentHostGitStateService][refreshSessionGitState] Refreshing git state for ${sessionKey}, ${workingDirectory?.fsPath}`); const gitState = await this._gitService.getSessionGitState(workingDirectory); - if (!gitState) { - return; - } - - const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; - if (!objectEquals(readSessionGitState(currentMeta), gitState)) { - // Update the session's git state - await this._setSessionGitState(sessionKey, gitState); - - // Update the session's GitHub state - if (gitState.githubOwner && gitState.githubRepo) { - await this.setSessionGitHubState(sessionKey, { - owner: gitState.githubOwner, - repo: gitState.githubRepo - } satisfies ISessionGitHubState); + if (gitState) { + const currentMeta = this._stateManager.getSessionState(sessionKey)?._meta; + if (!objectEquals(readSessionGitState(currentMeta), gitState)) { + // Update the session's git state + await this._setSessionGitState(sessionKey, gitState); + + // Update the session's GitHub state + if (gitState.githubOwner && gitState.githubRepo) { + await this.setSessionGitHubState(sessionKey, { + owner: gitState.githubOwner, + repo: gitState.githubRepo + } satisfies ISessionGitHubState); + } } } diff --git a/src/vs/platform/agentHost/node/agentHostLocalTurns.ts b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts new file mode 100644 index 00000000000000..7e2c5d1bbb3e43 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostLocalTurns.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IReference } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import type { ILocalTurnRecord, ISessionDatabase, ISessionDataService } from '../common/sessionDataService.js'; +import type { Turn } from '../common/state/sessionState.js'; + +/** + * Tracks host-injected ("local") turns — completed protocol turns the agent SDK + * never saw, such as the `/rename` acknowledgement or a `!command` terminal run. + * + * These turns exist only in the agent host: they are never forwarded to the + * agent SDK, so they are absent from the SDK transcript that + * {@link AgentService} replays on restore. This registry persists them (so they + * survive reload) and remembers, for each, the id of the preceding concrete + * (SDK-backed) turn — the *anchor* — so that fork/truncate operations targeting + * a local turn can be redirected to the concrete SDK message before it. + * + * Everything is scoped to a **chat** (its channel URI): a session's default + * chat and each of its peer chats are handled identically. Persistence lives in + * the owning session's database (one per session, shared across its chats), + * discriminated by {@link ILocalTurnRecord.chatUri}. + */ +export class AgentHostLocalTurns { + + /** chat URI → (localTurnId → { anchorTurnId, seq }). */ + private readonly _byChat = new Map>(); + /** session URI → highest `seq` assigned so far (seq is session-global for stable ordering). */ + private readonly _seqBySession = new Map(); + + constructor( + private readonly _sessionDataService: ISessionDataService, + private readonly _logService: ILogService, + ) { } + + /** Whether `turnId` is a known host-injected local turn in `chat`. */ + isLocal(chat: string, turnId: string): boolean { + return this._byChat.get(chat)?.has(turnId) ?? false; + } + + /** All known local turn ids for `chat`. */ + getLocalTurnIds(chat: string): string[] { + const map = this._byChat.get(chat); + return map ? [...map.keys()] : []; + } + + /** + * Resolves `turnId` to the concrete (SDK-backed) turn a fork/truncate should + * operate on within `chat`. For a local turn this is its anchor (the + * preceding real turn, or `undefined` when it precedes any real turn); for a + * concrete turn it is the turn itself. + */ + resolveConcreteTurnId(chat: string, turnId: string): string | undefined { + const entry = this._byChat.get(chat)?.get(turnId); + return entry ? entry.anchorTurnId : turnId; + } + + /** + * Persist a local turn and remember it in memory. `anchorTurnId` is the id + * of the preceding concrete turn in `chat` (or `undefined` when there is + * none). `session` identifies the database to persist into. + */ + record(session: string, chat: string, turn: Turn, anchorTurnId: string | undefined): void { + const seq = (this._seqBySession.get(session) ?? 0) + 1; + this._noteInMemory(session, chat, turn.id, anchorTurnId, seq); + const record: ILocalTurnRecord = { turnId: turn.id, chatUri: chat, anchorTurnId, seq, payload: JSON.stringify(turn) }; + let ref: IReference; + try { + ref = this._sessionDataService.openDatabase(URI.parse(session)); + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to open database to persist local turn ${turn.id}`, err); + return; + } + ref.object.insertLocalTurn(record).catch(err => { + this._logService.warn(`[AgentHostLocalTurns] Failed to persist local turn ${turn.id}`, err); + }).finally(() => ref.dispose()); + } + + /** + * Loads persisted local turns for `session`, populating the in-memory index + * (keyed by each record's chat), and returns the records for `chat` in + * `seq` order so the caller can interleave them into that chat's SDK-derived + * turns during restore. + */ + async loadForChat(session: string, chat: string): Promise { + const records = await this._load(session); + return records.filter(r => r.chatUri === chat); + } + + /** Note a local turn in memory only (used by fork seeding). */ + noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void { + this._noteInMemory(session, chat, turnId, anchorTurnId, seq); + } + + /** Delete the given local turns from memory and the session database. */ + deleteLocals(session: string, turnIds: readonly string[]): void { + if (turnIds.length === 0) { + return; + } + const idSet = new Set(turnIds); + for (const map of this._byChat.values()) { + for (const id of idSet) { + map.delete(id); + } + } + let ref: IReference; + try { + ref = this._sessionDataService.openDatabase(URI.parse(session)); + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to open database to delete local turns for ${session}`, err); + return; + } + ref.object.deleteLocalTurns(turnIds).catch(err => { + this._logService.warn(`[AgentHostLocalTurns] Failed to delete local turns for ${session}`, err); + }).finally(() => ref.dispose()); + } + + /** Drop all in-memory state for a chat. */ + forgetChat(chat: string): void { + this._byChat.delete(chat); + } + + private async _load(session: string): Promise { + const ref = this._sessionDataService.tryOpenDatabase?.(URI.parse(session)); + if (!ref) { + return []; + } + try { + const db = await ref; + if (!db) { + return []; + } + try { + const records = await db.object.getLocalTurns(); + for (const r of records) { + this._noteInMemory(session, r.chatUri, r.turnId, r.anchorTurnId, r.seq); + } + return records; + } finally { + db.dispose(); + } + } catch (err) { + this._logService.warn(`[AgentHostLocalTurns] Failed to load local turns for ${session}`, err); + return []; + } + } + + private _noteInMemory(session: string, chat: string, turnId: string, anchorTurnId: string | undefined, seq: number): void { + let map = this._byChat.get(chat); + if (!map) { + map = new Map(); + this._byChat.set(chat, map); + } + map.set(turnId, { anchorTurnId, seq }); + this._seqBySession.set(session, Math.max(this._seqBySession.get(session) ?? 0, seq)); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index cc6e9cd1c2b764..655921bc7571ff 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -7,7 +7,7 @@ import { ProxyChannel } from '../../../base/parts/ipc/common/ipc.js'; import { Server as ChildProcessServer } from '../../../base/parts/ipc/node/ipc.cp.js'; import { Server as UtilityProcessServer } from '../../../base/parts/ipc/node/ipc.mp.js'; import { isUtilityProcess } from '../../../base/parts/sandbox/node/electronTypes.js'; -import { Emitter } from '../../../base/common/event.js'; +import { Emitter, type Event } from '../../../base/common/event.js'; import { DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { joinPath } from '../../../base/common/resources.js'; import { isWindows } from '../../../base/common/platform.js'; @@ -15,10 +15,11 @@ import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import * as os from 'os'; import * as inspector from 'inspector'; -import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, AgentHostIpcChannels, IAgentHostInspectInfo, IAgentHostSocketInfo, IAgentService, IConnectionTrackerService, isAgentEnabled } from '../common/agentService.js'; import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentService } from './agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; @@ -29,7 +30,10 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { ByokLmProxyService, IByokLmProxyService } from './copilot/byokLmProxyService.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { ProtocolServerHandler } from './protocolServerHandler.js'; @@ -65,6 +69,8 @@ import { IEditSurvivalReporterFactory, EditSurvivalReporterFactory } from './sha import { AgentHostClientFileSystemProvider } from '../common/agentHostClientFileSystemProvider.js'; import { AGENT_CLIENT_SCHEME } from '../common/agentClientUri.js'; import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL, createAgentHostClientResourceConnection } from '../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, createAgentHostClientByokLmConnection } from '../common/agentHostClientByokLmChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL, createAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; import { IAgentPluginManager } from '../common/agentPluginManager.js'; import { AgentPluginManager } from './agentPluginManager.js'; import { AgentHostGitService } from './agentHostGitService.js'; @@ -132,6 +138,18 @@ async function startAgentHost(): Promise { // Create the real service implementation that lives in this process let agentService: AgentService; let instantiationService: IInstantiationService; + // Hoisted out of the `try` below so the protocol handlers (constructed + // after the block) can forward agent-SDK download progress to clients. + let sdkDownloadProgress: Event | undefined; + let byokLmBridgeRegistry: ByokLmBridgeRegistry; + let proxyResolver: AgentHostProxyResolver | undefined; + // Gate BYOK *use* behind the opt-in `chat.agentHost.byokModels.enabled` + // setting, forwarded from the renderer as an env var. The proxy and bridge + // registry are always constructed below (so the session launcher can inject + // them), but when off they stay inert: the per-connection bridge and the + // renderer's BYOK server channel are not wired, so the registry stays empty + // and the proxy never binds. + const byokLmEnabled = isAgentEnabled(process.env[AgentHostByokModelsEnabledEnvVar], true); try { // Build the DI container early so the git service can be created via // `createInstance` (it needs IFileService + INativeEnvironmentService). @@ -162,20 +180,25 @@ async function startAgentHost(): Promise { // Register the agent SDK downloader BEFORE any service that injects it // (ClaudeAgentSdkService and CodexAgent below). The downloader resolves // dev-override env var → on-disk cache → product.agentSdks download. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); - const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); - diServices.set(ICopilotApiService, copilotApiService); - diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator)); - const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); - diServices.set(IClaudeProxyService, claudeProxyService); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); diServices.set(IClaudeAgentSdkService, claudeAgentSdkService); - const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); - diServices.set(ICodexProxyService, codexProxyService); + // BYOK language-model proxy + bridge registry. Always registered so the + // session launcher can inject them, but BYOK *use* is gated: the + // per-connection bridge below (and the renderer's server channel) are only + // wired when `chat.agentHost.byokModels.enabled` is on, so the registry + // stays empty and the proxy never binds when the feature is off. + byokLmBridgeRegistry = new ByokLmBridgeRegistry(); + diServices.set(IByokLmBridgeRegistry, byokLmBridgeRegistry); + proxyResolver = instantiationService.createInstance(AgentHostProxyResolver); + diServices.set(IAgentHostProxyResolver, proxyResolver); + const byokLmProxyService = disposables.add(instantiationService.createInstance(ByokLmProxyService)); + diServices.set(IByokLmProxyService, byokLmProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); - agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); + agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined); diServices.set(IAgentService, agentService); const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); diServices.set(IAgentPluginManager, pluginManager); @@ -185,7 +208,19 @@ async function startAgentHost(): Promise { diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentConfigurationService, agentService.configurationService); + diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); diServices.set(IAgentHostCompletions, agentService.completionsService); + + // CopilotApiService and the proxies that consume it are created AFTER the + // GitHub endpoint service is re-exported (above) so CAPI endpoint discovery + // can target a GitHub Enterprise host. Matches agentHostServerMain ordering. + const copilotApiService = instantiationService.createInstance(CopilotApiService, undefined); + diServices.set(ICopilotApiService, copilotApiService); + diServices.set(ICopilotBranchNameGenerator, instantiationService.createInstance(CopilotBranchNameGenerator)); + const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); + diServices.set(IClaudeProxyService, claudeProxyService); + const codexProxyService = disposables.add(instantiationService.createInstance(CodexProxyService)); + diServices.set(ICodexProxyService, codexProxyService); agentService.registerProvider(instantiationService.createInstance(CopilotAgent)); // Claude and Codex providers are gated on two things: // 1. The user-facing enable toggle (`chat.agentHost.Agent.enabled`, @@ -227,6 +262,23 @@ async function startAgentHost(): Promise { logService.error('Failed to create AgentService', err); throw err; } + + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both the local (IPC) and any external (WebSocket) renderer + // receive them via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + const agentChannel = ProxyChannel.fromService(agentService, disposables); server.registerChannel(AgentHostIpcChannels.AgentHost, agentChannel); @@ -238,8 +290,9 @@ async function startAgentHost(): Promise { disposables.add(fileService.registerProvider(AGENT_CLIENT_SCHEME, clientFileSystemProvider)); // Wire reverse-RPC for in-process renderer connections. The renderer's - // `MessagePortClient` ctx is its `clientId`, and it exposes - // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` for filesystem reads. + // `MessagePortClient` ctx is its `clientId`, and it exposes the + // `AGENT_HOST_CLIENT_RESOURCE_CHANNEL` (filesystem reads) and + // `AGENT_HOST_CLIENT_BYOK_LM_CHANNEL` (BYOK language-model calls). if (server instanceof UtilityProcessServer) { const authorityRegistrations = new Map(); const registerConnection = (connection: (typeof server.connections)[number]) => { @@ -250,9 +303,20 @@ async function startAgentHost(): Promise { if (typeof clientId !== 'string' || !clientId) { return; } - const channel = server.getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL, c => c.ctx === clientId); - const fsConnection = createAgentHostClientResourceConnection(channel); - authorityRegistrations.set(connection, clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + const connectionStore = new DisposableStore(); + const getChannel = (channelName: string) => server.getChannel(channelName, c => c.ctx === clientId); + const fsConnection = createAgentHostClientResourceConnection(getChannel(AGENT_HOST_CLIENT_RESOURCE_CHANNEL)); + connectionStore.add(clientFileSystemProvider.registerAuthority(clientId, fsConnection)); + const proxyConnection = createAgentHostClientProxyConnection(getChannel(AGENT_HOST_CLIENT_PROXY_CHANNEL)); + connectionStore.add(proxyResolver.register(clientId, proxyConnection)); + // BYOK bridge is gated: only wire it when the feature is enabled, so + // the registry stays empty (and the launcher synthesizes no BYOK + // providers/models) when `chat.agentHost.byokModels.enabled` is off. + if (byokLmEnabled && byokLmBridgeRegistry) { + const byokLmConnection = createAgentHostClientByokLmConnection(getChannel(AGENT_HOST_CLIENT_BYOK_LM_CHANNEL)); + connectionStore.add(byokLmBridgeRegistry.register(clientId, byokLmConnection)); + } + authorityRegistrations.set(connection, connectionStore); }; disposables.add(server.onDidAddConnection(registerConnection)); disposables.add(server.onDidRemoveConnection(connection => { diff --git a/src/vs/platform/agentHost/node/agentHostProxyResolver.ts b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts new file mode 100644 index 00000000000000..eb1fb436057f55 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostProxyResolver.ts @@ -0,0 +1,126 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { LogLevel as ProxyLogLevel, ProxyAgentParams, ProxySupportSetting, createProxyResolver, loadSystemCertificates } from '@vscode/proxy-agent'; +import { IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILogService, LogLevel } from '../../log/common/log.js'; +import { systemCertificatesNodeDefault } from '../../request/common/request.js'; +import { IAgentHostClientProxyConnection } from '../common/agentHostClientProxyChannel.js'; + +export const IAgentHostProxyResolver = createDecorator('agentHostProxyResolver'); + +/** + * Node-side registry of renderer {@link IAgentHostClientProxyConnection}s keyed + * by client id. Populated by the agent host's connection lifecycle (one entry + * per connected renderer) and consumed by {@link CopilotAgent} to resolve the + * CAPI proxy through VS Code's Electron session before spawning the Copilot SDK. + * + * Proxy configuration is a property of the machine, not of a particular window, + * so any connected renderer can serve the lookup; the resolver calls the first + * available connection and falls through to the next on failure. + */ +export interface IAgentHostProxyResolver { + readonly _serviceBrand: undefined; + + /** Register a renderer connection. Disposing the result removes it. */ + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable; + + /** + * Resolve the proxy URL for `url` (e.g. `http://host:port`), or `undefined` + * for a direct connection. Reuses `@vscode/proxy-agent`'s `resolveProxyURL` + * so the same precedence as the rest of VS Code applies: `http.noProxy` → + * `http.proxy` setting → `HTTP(S)_PROXY` env vars → the host proxy resolution + * that runs in VS Code (Electron session) via the reverse channel. + */ + resolveProxy(url: string): Promise; +} + +export class AgentHostProxyResolver implements IAgentHostProxyResolver { + + declare readonly _serviceBrand: undefined; + + private readonly _connections = new Map(); + private _resolveProxyURL: ((url: string) => Promise) | undefined; + + constructor( + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ILogService private readonly _logService: ILogService, + ) { } + + register(clientId: string, connection: IAgentHostClientProxyConnection): IDisposable { + this._connections.set(clientId, connection); + return toDisposable(() => { + if (this._connections.get(clientId) === connection) { + this._connections.delete(clientId); + } + }); + } + + resolveProxy(url: string): Promise { + return this._getResolveProxyURL()(url); + } + + private _getResolveProxyURL(): (url: string) => Promise { + if (!this._resolveProxyURL) { + // Mirror `workbench/api/node/proxyResolver.ts`. + const config = (key: string): T | undefined => this._configurationService.getValue(key); + const systemCertificatesV2 = () => config('http.experimental.systemCertificatesV2') ?? false; + const systemCertificates = () => !!config('http.systemCertificates'); + const params: ProxyAgentParams = { + // The host proxy resolution runs in VS Code: reverse-call a connected + // renderer, whose IRequestService.resolveProxy hits the Electron + // session (system settings / PAC scripts). + resolveProxy: (url) => this._hostResolveProxy(url), + getProxyURL: () => config('http.proxy'), + getProxySupport: () => config('http.proxySupport') || 'off', + getNoProxyConfig: () => config('http.noProxy') || [], + isAdditionalFetchSupportEnabled: () => config('http.fetchAdditionalSupport') ?? true, + isWebSocketPatchEnabled: () => config('http.webSocketAdditionalSupport') ?? true, + addCertificatesV1: () => !systemCertificatesV2() && systemCertificates(), + addCertificatesV2: () => systemCertificatesV2() && systemCertificates(), + loadSystemCertificatesFromNode: () => config('http.systemCertificatesNode') ?? systemCertificatesNodeDefault, + loadAdditionalCertificates: async () => loadSystemCertificates({ + loadSystemCertificatesFromNode: () => config('http.systemCertificatesNode') ?? systemCertificatesNodeDefault, + log: this._logService, + }), + log: this._logService, + getLogLevel: () => { + switch (this._logService.getLevel()) { + case LogLevel.Trace: return ProxyLogLevel.Trace; + case LogLevel.Debug: return ProxyLogLevel.Debug; + case LogLevel.Info: return ProxyLogLevel.Info; + case LogLevel.Warning: return ProxyLogLevel.Warning; + case LogLevel.Error: return ProxyLogLevel.Error; + case LogLevel.Off: return ProxyLogLevel.Off; + default: return ProxyLogLevel.Info; + } + }, + proxyResolveTelemetry: () => { }, + // Only the local agent host wires the reverse proxy channel + // and we want to look up the client's proxy settings only + // when the agent host is local (i.e., on the same machine as + // the client). + isUseHostProxyEnabled: () => this._connections.size > 0, + getNetworkInterfaceCheckInterval: () => (config('http.experimental.networkInterfaceCheckInterval') ?? 300) * 1000, + env: process.env, + }; + this._resolveProxyURL = createProxyResolver(params).resolveProxyURL; + } + return this._resolveProxyURL; + } + + private async _hostResolveProxy(url: string): Promise { + for (const connection of this._connections.values()) { + try { + return await connection.resolveProxy(url); + } catch { + // This renderer could not serve the lookup; try the next one. + } + } + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts index 2d991dfb3717d4..7946d034b233a9 100644 --- a/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts +++ b/src/vs/platform/agentHost/node/agentHostPullRequestOperationHandler.ts @@ -6,7 +6,8 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; -import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IAgentService } from '../common/agentService.js'; +import { IAgentService } from '../common/agentService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { AHP_AUTH_REQUIRED, AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; import { readSessionGitHubState, readSessionGitState, type ChangesetOperationFollowUp, type ISessionFileDiff, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; @@ -72,6 +73,7 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation @IAgentService private readonly _agentService: IAgentService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentHostOctoKitService private readonly _octoKitService: IAgentHostOctoKitService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @ILogService private readonly _logService: ILogService, ) { } @@ -129,15 +131,16 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation // `getDefaultBranch` may return `origin/` — `pulls` API wants the bare name. const base = baseBranchName.startsWith('origin/') ? baseBranchName.substring('origin/'.length) : baseBranchName; + const repoResource = this._gitHubEndpointService.getRepoResource(); const authToken = this._agentService.getAuthToken({ - resource: GITHUB_REPO_PROTECTED_RESOURCE.resource, - scopes: GITHUB_REPO_PROTECTED_RESOURCE.scopes_supported, + resource: repoResource.resource, + scopes: repoResource.scopes_supported, }); if (!authToken) { throw new ProtocolError( AHP_AUTH_REQUIRED, localize('agentHost.changeset.pr.authRequired', "Sign in to GitHub with repository access to create a pull request."), - [GITHUB_REPO_PROTECTED_RESOURCE], + [repoResource], ); } @@ -344,9 +347,10 @@ export class AgentHostPullRequestOperationHandler implements IChangesetOperation signal: AbortSignal, token: CancellationToken, ): Promise<{ title: string; description: string } | undefined> { + const copilotResource = this._gitHubEndpointService.getCopilotResource(); const copilotToken = this._agentService.getAuthToken({ - resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, - scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + resource: copilotResource.resource, + scopes: copilotResource.scopes_supported, }); if (!copilotToken) { return undefined; diff --git a/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts new file mode 100644 index 00000000000000..71a41f529d59fc --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostRestrictedTelemetry.ts @@ -0,0 +1,218 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../base/common/uuid.js'; +import { ILogService } from '../../log/common/log.js'; +import { ICommonProperties } from '../../telemetry/common/telemetry.js'; + +/** + * Public GitHub Copilot telemetry ingestion keys. These are instrumentation keys, not + * secrets; the iKey selects the destination hydro table: + * - standard -> `copilot_v0_copilot_event` + * - enhanced -> `copilot_v0_restricted_copilot_event` + */ +const GH_STANDARD_IKEY = '7d7048df-6dd0-4048-bb23-b716c1461f8f'; +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +/** + * Fallback Copilot telemetry endpoint (the dotcom value of the CAPI token's + * `endpoints.telemetry`, with the `/telemetry` path the Copilot CLI/runtime appends). + * Used until {@link IAgentHostRestrictedTelemetry.setRestrictedTelemetryEndpoint} supplies + * the user's discovered endpoint (dotcom, GHE, or proxy). Accepts unauthenticated POSTs. + */ +const GH_TELEMETRY_URL = 'https://copilot-telemetry.githubusercontent.com/telemetry'; + +/** Event names are namespaced by client category; the CTS name filter requires this. */ +const NAMESPACE = 'copilot-chat'; + +export type TelemetryProps = Record; +export type TelemetryMeasurements = Record; + +/** The subset of the global `fetch` used to POST envelopes; injectable so tests avoid live network calls. */ +type FetchFn = typeof globalThis.fetch; + +/** + * App Insights caps a single property value at ~8192 chars. Long values are split across + * numbered keys (`key`, `key_02`, `key_03`, …) so the Copilot Telemetry Service reassembles + * them, mirroring the Copilot extension's `multiplexProperties` so events look identical on the + * wire and downstream. + */ +const MAX_PROPERTY_LENGTH = 8192; +const MAX_CONCATENATED_PROPERTIES = 50; + +export function multiplexProperties(properties: TelemetryProps): TelemetryProps { + const newProperties: TelemetryProps = { ...properties }; + for (const key in properties) { + const value = properties[key]; + let remaining = value?.length ?? 0; + if (remaining > MAX_PROPERTY_LENGTH) { + let lastStartIndex = 0; + let count = 0; + while (remaining > 0 && count < MAX_CONCATENATED_PROPERTIES) { + count += 1; + let propertyName = key; + if (count > 1) { + propertyName = key + '_' + (count < 10 ? '0' : '') + count; + } + let offsetIndex = lastStartIndex + MAX_PROPERTY_LENGTH; + if (remaining < MAX_PROPERTY_LENGTH) { + offsetIndex = lastStartIndex + remaining; + } + newProperties[propertyName] = value!.slice(lastStartIndex, offsetIndex); + remaining -= MAX_PROPERTY_LENGTH; + lastStartIndex += MAX_PROPERTY_LENGTH; + } + } + } + return newProperties; +} + +/** + * The restricted telemetry surface the agent host exposes, mirroring the Copilot extension's + * `ITelemetryService` restricted methods so agent-host code can emit the same GH/MSFT events. + */ +export interface IAgentHostRestrictedTelemetry { + /** GH standard (non-restricted) telemetry -> `copilot_v0_copilot_event`. */ + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** GH enhanced/restricted telemetry (prompts, tools, etc.) -> `copilot_v0_restricted_copilot_event`. */ + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** MSFT-internal telemetry -> Aria/Collector++ (internal-only table). No-op without an internal key. */ + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void; + /** Sets the Copilot user tracking id (`copilot_trackingId`) carried on every subsequent event. */ + setCopilotTrackingId(trackingId: string | undefined): void; + /** Overrides the POST endpoint with the user's CAPI `endpoints.telemetry`; falsy restores the default. */ + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void; + /** Enables enhanced GH telemetry once the token opts in (`rt=1`); off by default and on flip/logout. */ + setRestrictedTelemetryEnabled(enabled: boolean): void; +} + +/** + * Emits GitHub Copilot restricted/enhanced telemetry from the agent-host process by POSTing + * Application-Insights envelopes to the Copilot telemetry endpoint (the same wire format the + * Copilot extension uses). Fire-and-forget; failures are logged, never thrown. + */ +export class AgentHostRestrictedTelemetrySender implements IAgentHostRestrictedTelemetry { + + private readonly _commonProps: TelemetryProps; + + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Off by + * default so the sole writer to the restricted table never emits for public users — a hard + * safety boundary that holds even if the enclosing service's gate is bypassed. Mirrors the + * Copilot extension, which only creates the restricted reporter for opted-in users. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + commonProperties: ICommonProperties, + private readonly _logService: ILogService, + private _endpointUrl: string = GH_TELEMETRY_URL, + private readonly _internalSink?: (eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements) => void, + private readonly _fetchFn: FetchFn = globalThis.fetch, + ) { + // Map the resolved common properties onto the GH property names the hydro schema reads. + this._commonProps = { + client_machineid: asString(commonProperties['common.machineId']), + client_deviceid: asString(commonProperties['common.devDeviceId']), + client_sessionid: asString(commonProperties['sessionID']), + common_os: asString(commonProperties['common.nodePlatform']) ?? process.platform, + editor_version: asString(commonProperties['version']), + }; + } + + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + this._post(GH_STANDARD_IKEY, eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Hard safety boundary: enhanced/restricted telemetry is the pipeline that may carry prompt + // and tool content, so the only writer to the restricted table refuses to emit unless the + // user's token opted in (`rt=1`). This holds even if a caller reaches the sender without the + // service-level `rt`/telemetry-level gate. + if (!this._restrictedTelemetryEnabled) { + return; + } + this._post(GH_ENHANCED_IKEY, eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + // Internal MSFT telemetry lands in the Aria/Collector++ pipeline via a dedicated key, + // which is not present in the agent-host product config. Route to the optional sink when + // wired; otherwise trace so the event is at least visible in the agent-host log. + if (this._internalSink) { + this._internalSink(eventName, properties, measurements); + return; + } + this._logService.trace(`[ahp-restricted] internal MSFT event (not sent, no internal key): ${eventName}`); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + // `copilot_trackingId` is the Copilot token's `tid` claim: a stable per-user id (one user + // per agent-host process). The Copilot Telemetry Service reads it into the + // `copilot_tracking_id` column, matching the Copilot extension. + this._commonProps.copilot_trackingId = trackingId || undefined; + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + // The user's telemetry host comes from the CAPI `endpoints.telemetry` discovery; fall back + // to the dotcom default when it is unknown so events are never sent to an empty URL. + this._endpointUrl = endpointUrl || GH_TELEMETRY_URL; + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + } + + private _post(iKey: string, eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + const name = eventName.includes('/') ? eventName : `${NAMESPACE}/${eventName}`; + const envelope = { + ver: 1, + name: `Microsoft.ApplicationInsights.${iKey.replace(/-/g, '')}.Event`, + time: new Date().toISOString(), + sampleRate: 100, + seq: '', + iKey, + tags: { 'ai.operation.id': generateUuid() }, + data: { + baseType: 'EventData', + baseData: { + name, + // `unique_id` is a fresh per-event id (its hydro column is read by the Copilot + // Telemetry Service from the snake_case `unique_id` property, NOT `uniqueId`), + // mirroring the Copilot extension so each emitted event stays individually + // addressable. Placed first so explicit properties still win on collision. + properties: { unique_id: generateUuid(), ...this._commonProps, ...properties }, + measurements: measurements ?? {}, + }, + }, + }; + + this._logService.trace(`[ahp-restricted] emit ${name} (iKey ${iKey.slice(0, 8)})`); + + if (typeof this._fetchFn !== 'function') { + this._logService.warn('[ahp-restricted] global fetch unavailable; telemetry not sent'); + return; + } + + // Fire-and-forget: post the event and move on. Delivery/robustness is intentionally kept + // simple here — failures are logged, not retried (a retry loop would only mask local + // telemetry-blocking resolvers, which do not exist in production). + this._fetchFn(this._endpointUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-json-stream' }, + body: JSON.stringify(envelope), + }).then(res => { + if (!res.ok) { + this._logService.warn(`[ahp-restricted] ${name} rejected: HTTP ${res.status}`); + } + }).catch(err => { + this._logService.warn(`[ahp-restricted] ${name} POST failed: ${err instanceof Error ? err.message : String(err)}`); + }); + } +} + +function asString(value: string | boolean | undefined): string | undefined { + return typeof value === 'string' ? value : value === undefined ? undefined : String(value); +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts b/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts new file mode 100644 index 00000000000000..68f655c3d9af0e --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewFileOperationHandler.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { basename } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { localize } from '../../../nls.js'; +import { ILogService } from '../../log/common/log.js'; +import { ChangesetKind, parseChangesetUri } from '../common/changesetUri.js'; +import { type IChangesetOperationHandler } from '../common/agentHostChangesetOperationService.js'; +import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; +import { META_DIFF_BASE_BRANCH, resolveDiffBaseBranchName } from '../common/agentHostGitService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams, type InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; +import { readSessionGitState, type SessionState } from '../common/state/sessionState.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../common/state/sessionProtocol.js'; + +/** + * Handles the `mark-as-reviewed` and `mark-as-unreviewed` resource-scoped + * changeset operations for the **Branch Changes** changeset. A single instance + * handles one direction — marking a file as reviewed or clearing that mark — + * selected by the `_reviewed` flag. + * + * The reviewed state is owned by {@link IAgentHostReviewService}, which tracks + * it as a session-private synthetic git ref. This handler resolves the session's + * working directory + base branch and delegates to that service. + */ +export class AgentHostReviewFileOperationHandler implements IChangesetOperationHandler { + + public static readonly OPERATION_MARK_AS_REVIEWED = 'mark-as-reviewed'; + public static readonly OPERATION_MARK_AS_UNREVIEWED = 'mark-as-unreviewed'; + + constructor( + private readonly _reviewed: boolean, + private readonly _getSessionState: (sessionKey: string) => SessionState | undefined, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, + @IAgentHostChangesetService private readonly _changesetService: IAgentHostChangesetService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { } + + private get _operationId(): string { + return this._reviewed + ? AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED + : AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED; + } + + async invoke(params: InvokeChangesetOperationParams, token: CancellationToken): Promise { + const parsed = parseChangesetUri(params.channel); + if (!parsed || parsed.kind !== ChangesetKind.Branch) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Not a branch changeset URI: ${params.channel}`); + } + this._throwIfCancelled(token); + + const sessionUri = parsed.sessionUri; + const sessionState = this._getSessionState(sessionUri); + if (!sessionState) { + throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${sessionUri}`); + } + + if (params.target?.kind !== ChangesetOperationTargetKind.Resource) { + throw new ProtocolError( + JsonRpcErrorCodes.InvalidParams, + `Operation '${this._operationId}' requires a resource target.`); + } + + const workingDirectoryStr = sessionState.workingDirectory; + if (!workingDirectoryStr) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, `Session has no working directory: ${sessionUri}`); + } + + const workingDirectory = URI.parse(workingDirectoryStr); + const resource = URI.parse(params.target.resource); + const baseBranch = await this._resolveBaseBranch(sessionUri, sessionState); + + try { + if (this._reviewed) { + this._logService.info(`[AgentHostReviewFileOperationHandler] Marking '${resource.fsPath}' as reviewed for session ${sessionUri}`); + await this._reviewService.markFileReviewed(sessionUri, workingDirectory, baseBranch, resource); + this._changesetService.refreshBranchChangeset(sessionUri); + + return { message: { markdown: localize('agentHost.changeset.reviewFile.marked', "Marked `{0}` as reviewed.", basename(resource)) } }; + } + + this._logService.info(`[AgentHostReviewFileOperationHandler] Removing reviewed mark for '${resource.fsPath}' in session ${sessionUri}`); + await this._reviewService.markFileUnreviewed(sessionUri, workingDirectory, baseBranch, resource); + this._changesetService.refreshBranchChangeset(sessionUri); + + return { message: { markdown: localize('agentHost.changeset.reviewFile.unmarked', "Removed the reviewed mark from `{0}`.", basename(resource)) } }; + } catch (err) { + this._throwIfCancelled(token); + throw new ProtocolError( + JsonRpcErrorCodes.InternalError, + `Failed to update reviewed state: ${err instanceof Error ? err.message : String(err)}`); + } + } + + /** + * Resolves the Branch Changes base branch the same way the changeset service + * does, so review status is keyed on the same baseline the diff uses. + */ + private async _resolveBaseBranch(sessionUri: string, sessionState: SessionState): Promise { + const databaseRef = this._sessionDataService.openDatabase(URI.parse(sessionUri)); + try { + const persistedBaseBranch = await databaseRef.object.getMetadata(META_DIFF_BASE_BRANCH); + const gitStateBaseBranch = readSessionGitState(sessionState._meta)?.baseBranchName; + return resolveDiffBaseBranchName(persistedBaseBranch, gitStateBaseBranch); + } finally { + databaseRef.dispose(); + } + } + + private _throwIfCancelled(token: CancellationToken): void { + if (token.isCancellationRequested) { + throw new ProtocolError(JsonRpcErrorCodes.InternalError, localize('agentHost.changeset.reviewFile.cancelled', "Review file operation was cancelled.")); + } + } +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts b/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts new file mode 100644 index 00000000000000..7d10fa7c1bd503 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewOperationProvider.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; +import { IInstantiationService } from '../../instantiation/common/instantiation.js'; +import { ChangesetKind } from '../common/changesetUri.js'; +import type { IChangesetOperationContribution, IChangesetOperationContext, IChangesetOperationRegistry } from '../common/agentHostChangesetOperationService.js'; +import { ChangesetOperationScope, ChangesetOperationStatus, type ChangesetOperation } from '../common/state/sessionState.js'; +import { AgentHostReviewFileOperationHandler } from './agentHostReviewFileOperationHandler.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Contributes the `mark-as-reviewed` / `mark-as-unreviewed` resource-scoped + * operations for the **Branch Changes** changeset, backed by + * {@link AgentHostReviewFileOperationHandler}. + */ +export class AgentHostReviewOperationContribution extends Disposable implements IChangesetOperationContribution { + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + } + + registerHandlers(registry: IChangesetOperationRegistry): IDisposable { + const store = new DisposableStore(); + const getSessionState = (sessionKey: string) => this._stateManager.getSessionState(sessionKey); + const markHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, true, getSessionState); + const unmarkHandler = this._instantiationService.createInstance(AgentHostReviewFileOperationHandler, false, getSessionState); + store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, markHandler)); + store.add(registry.registerChangesetOperationHandler(AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, unmarkHandler)); + return store; + } + + getOperations({ changesetKind }: IChangesetOperationContext): ChangesetOperation[] { + if (changesetKind !== ChangesetKind.Branch) { + return []; + } + + return [ + { + id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + label: localize('agentHost.changeset.markAsReviewed', "Mark as Reviewed"), + icon: 'check', + group: 'review', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }, + { + id: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, + label: localize('agentHost.changeset.markAsUnreviewed', "Mark as Unreviewed"), + icon: 'check', + group: 'review', + scopes: [ChangesetOperationScope.Resource], + status: ChangesetOperationStatus.Idle, + }, + ] satisfies ChangesetOperation[]; + } +} diff --git a/src/vs/platform/agentHost/node/agentHostReviewService.ts b/src/vs/platform/agentHost/node/agentHostReviewService.ts new file mode 100644 index 00000000000000..1715aebffedfc6 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostReviewService.ts @@ -0,0 +1,230 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { SequencerByKey } from '../../../base/common/async.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { relativePath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../log/common/log.js'; +import { AgentSession } from '../common/agentService.js'; +import type { URI as ProtocolURI } from '../common/state/sessionState.js'; +import { EMPTY_TREE_OBJECT, IAgentHostGitService } from '../common/agentHostGitService.js'; +import { buildReviewedRefName, IAgentHostReviewService } from '../common/agentHostReviewService.js'; +import { ISessionDataService } from '../common/sessionDataService.js'; +import { AgentHostStateManager } from './agentHostStateManager.js'; + +/** + * Resolved git context shared by the review operations: the repository root, + * the Branch Changes baseline tree, and the current reviewed ref/tree. + */ +interface IReviewContext { + readonly repoRoot: URI; + /** Tree object of the baseline. */ + readonly baselineTree: string; + /** Name of the session's reviewed ref. */ + readonly reviewedRef: string; + /** Current reviewed commit, or `undefined` when the ref does not exist yet. */ + readonly reviewedCommit: string | undefined; + /** Current reviewed tree; equals `baselineTree` when the ref does not exist. */ + readonly reviewedTree: string; +} + +export class AgentHostReviewService extends Disposable implements IAgentHostReviewService { + declare readonly _serviceBrand: undefined; + + /** + * Serializes mark/unmark/read per session so back-to-back mutations don't + * race on the reviewed ref rebuild and reads observe a consistent ref. + */ + private readonly _sequencer = new SequencerByKey(); + + constructor( + private readonly _stateManager: AgentHostStateManager, + @IAgentHostGitService private readonly _gitService: IAgentHostGitService, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // When a session's data directory is about to be deleted, delete the + // reviewed ref we created for it. The working directory needed to + // resolve the repository root is supplied by the event (resolved from + // live session state) so we don't persist our own copy. + this._register(this._sessionDataService.onWillDeleteSessionData(e => { + e.waitUntil(this.disposeSessionData(e.session.toString())); + })); + } + + markFileReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, true)); + } + + markFileUnreviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise { + return this._sequencer.queue(session, () => this._setReviewed(session, workingDirectory, baseBranch, resource, false)); + } + + getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise> { + return this._sequencer.queue(session, () => this._getReviewedPaths(session, workingDirectory, baseBranch)); + } + + copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise { + return this._sequencer.queue(targetSession, () => this._copyReviewedRef(sourceSession, targetSession, workingDirectory)); + } + + private async _copyReviewedRef(sourceSession: ProtocolURI, targetSession: ProtocolURI, workingDirectory: URI): Promise { + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repoRoot) { + return; + } + + const sourceRef = buildReviewedRefName(this._sanitizedSessionId(sourceSession)); + const sourceCommit = await this._gitService.revParse(repoRoot, sourceRef); + if (!sourceCommit) { + return; + } + + const targetRef = buildReviewedRefName(this._sanitizedSessionId(targetSession)); + await this._gitService.updateRef(repoRoot, targetRef, sourceCommit); + this._logService.trace(`[AgentHostReview][_copyReviewedRef] Copied reviewed ref ${sourceRef} -> ${targetRef} for fork`); + } + + private async _setReviewed(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined, resource: URI, reviewed: boolean): Promise { + const context = await this._resolveContext(session, workingDirectory, baseBranch); + if (!context) { + return; + } + + const path = relativePath(context.repoRoot, resource); + if (!path) { + this._logService.warn(`[AgentHostReview][_setReviewed] '${resource.toString()}' is not under the repository root '${context.repoRoot.toString()}'; skipping`); + return; + } + + // To mark a file reviewed, overlay its current working-tree content into + // the reviewed tree; to unmark, reset it to the baseline content. + let source: string | undefined; + if (reviewed) { + source = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + } else { + source = context.baselineTree; + } + if (!source) { + return; + } + + const newTree = await this._gitService.overlayPathIntoTree(context.repoRoot, context.reviewedTree, path, source); + if (!newTree) { + return; + } + if (newTree === context.reviewedTree) { + // No change (already reviewed / already unreviewed). + // Don't grow the reviewed ref chain with a no-op + // commit. + return; + } + + // The reviewed ref is a session-private chain disconnected from the + // real git history (mirroring the checkpoint baseline): the first + // commit is a parentless root, and subsequent commits chain onto the + // prior reviewed commit. + const message = `review: ${reviewed ? 'mark' : 'unmark'} ${path}`; + const commit = await this._gitService.commitTree(context.repoRoot, newTree, context.reviewedCommit, message); + if (!commit) { + return; + } + + await this._gitService.updateRef(context.repoRoot, context.reviewedRef, commit); + + this._logService.trace(`[AgentHostReview][_setReviewed] ${message} for ${session.toString()} -> ${context.reviewedRef}@${commit}`); + } + + private async _getReviewedPaths(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise> { + const context = await this._resolveContext(session, workingDirectory, baseBranch); + if (!context?.reviewedCommit) { + // No reviewed ref yet means + // nothing has been reviewed. + return new Set(); + } + + const workingTree = await this._gitService.captureWorkingTreeAsTree(workingDirectory); + if (!workingTree) { + return new Set(); + } + + // Changed = files that differ between the baseline and the working tree + // (the Branch Changes universe). Unreviewed = files that still differ + // between the reviewed tree and the working tree. Reviewed is the + // difference: changed files whose reviewed content already matches the + // working tree. + const [changed, unreviewed] = await Promise.all([ + this._gitService.diffTreePaths(context.repoRoot, context.baselineTree, workingTree), + this._gitService.diffTreePaths(context.repoRoot, context.reviewedTree, workingTree), + ]); + if (!changed) { + return new Set(); + } + + const unreviewedSet = new Set(unreviewed ?? []); + return new Set(changed.filter(path => !unreviewedSet.has(path))); + } + + private async _resolveContext(session: ProtocolURI, workingDirectory: URI, baseBranch: string | undefined): Promise { + const repoRoot = await this._gitService.getRepositoryRoot(workingDirectory); + if (!repoRoot) { + return undefined; + } + + const baselineCommit = await this._gitService.resolveBranchBaselineCommit(workingDirectory, baseBranch); + if (!baselineCommit) { + return undefined; + } + + const baselineTree = baselineCommit !== EMPTY_TREE_OBJECT + ? await this._gitService.revParse(repoRoot, `${baselineCommit}^{tree}`) + : EMPTY_TREE_OBJECT; + if (!baselineTree) { + return undefined; + } + + const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session)); + const reviewedCommit = await this._gitService.revParse(repoRoot, reviewedRef); + const reviewedTree = reviewedCommit + ? await this._gitService.revParse(repoRoot, `${reviewedCommit}^{tree}`) ?? baselineTree + : baselineTree; + + return { repoRoot, baselineTree, reviewedRef, reviewedCommit, reviewedTree }; + } + + async disposeSessionData(session: ProtocolURI): Promise { + await this._sequencer.queue(session, () => this._disposeSessionData(session)); + } + + private async _disposeSessionData(session: ProtocolURI): Promise { + const workingDirectory = this._stateManager.getSessionState(session)?.workingDirectory; + if (!workingDirectory) { + // No working directory means we can't resolve the repository root + // (session was never git-backed, or its working directory is gone). + return; + } + + const repoRoot = await this._gitService.getRepositoryRoot(URI.parse(workingDirectory)); + if (!repoRoot) { + return; + } + + try { + const reviewedRef = buildReviewedRefName(this._sanitizedSessionId(session)); + await this._gitService.deleteRefs(repoRoot, [reviewedRef]); + + this._logService.trace(`[AgentHostReview][_disposeSessionData] Deleted reviewed ref for ${session}`); + } catch (err) { + this._logService.warn(`[AgentHostReview][_disposeSessionData] Failed to dispose reviewed ref for ${session}`, err); + } + } + + private _sanitizedSessionId(session: ProtocolURI): string { + return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } +} diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 6b26b821fbf482..4331fef0ea2936 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -15,6 +15,7 @@ globalThis._VSCODE_FILE_ROOT = fileURLToPath(new URL('../../../..', import.meta. import * as fs from 'fs'; import * as os from 'os'; +import type { Event } from '../../../base/common/event.js'; import { DisposableStore } from '../../../base/common/lifecycle.js'; import { raceTimeout } from '../../../base/common/async.js'; import { joinPath } from '../../../base/common/resources.js'; @@ -34,6 +35,9 @@ import { InstantiationService } from '../../instantiation/common/instantiationSe import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { registerAgentHostNetworkServices } from './agentHostBootstrap.js'; import { CopilotAgent } from './copilot/copilotAgent.js'; +import { AgentHostProxyResolver, IAgentHostProxyResolver } from './agentHostProxyResolver.js'; +import { IByokLmBridgeRegistry, NullByokLmBridgeRegistry } from './byokLmBridgeRegistry.js'; +import { IByokLmProxyService, NullByokLmProxyService } from './copilot/byokLmProxyService.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator } from './copilot/copilotBranchNameGenerator.js'; import { CopilotApiService, ICopilotApiService } from './shared/copilotApiService.js'; import { ClaudeAgent } from './claude/claudeAgent.js'; @@ -41,12 +45,13 @@ import { ClaudeAgentSdkService, ClaudeSdkPackage, IClaudeAgentSdkService } from import { ClaudeProxyService, IClaudeProxyService } from './claude/claudeProxyService.js'; import { CodexAgent, CodexSdkPackage } from './codex/codexAgent.js'; import { CodexProxyService, ICodexProxyService } from './codex/codexProxyService.js'; -import { AgentSdkDownloader, IAgentSdkDownloader } from './agentSdkDownloader.js'; +import { AgentSdkDownloader, IAgentSdkDownloader, type IAgentSdkDownloadProgress } from './agentSdkDownloader.js'; import { IAgentHostOTelService } from '../common/otel/agentHostOTelService.js'; import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentService } from './agentService.js'; import { AgentHostClaudeAgentEnabledEnvVar, AgentHostClaudeSdkRootEnvVar, AgentHostCodexAgentEnabledEnvVar, IAgentService, AgentHostCodexAgentSdkRootEnvVar, isAgentEnabled } from '../common/agentService.js'; import { IAgentConfigurationService } from './agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { IAgentHostCompletions } from './agentHostCompletions.js'; import { IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { WebSocketProtocolServer } from './webSocketTransport.js'; @@ -244,11 +249,12 @@ async function main(): Promise { diServices.set(IAgentHostCheckpointService, checkpointService); // Create the agent service (owns AgentHostStateManager + AgentSideEffects internally) - const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService); + const agentService = new AgentService(logService, fileService, sessionDataService, productService, gitService, checkpointService, rootConfigResource, telemetryService, fileMonitorService, undefined); disposables.add(agentService); diServices.set(IAgentService, agentService); // Register agents + let sdkDownloadProgress: Event | undefined; if (!options.quiet) { // Production agents (require DI) const pluginManager = new AgentPluginManager(URI.file(environmentService.userDataPath), fileService, logService); @@ -257,6 +263,7 @@ async function main(): Promise { diServices.set(IEditSurvivalReporterFactory, instantiationService.createInstance(EditSurvivalReporterFactory)); diServices.set(IAgentHostTerminalManager, agentService.terminalManager); diServices.set(IAgentConfigurationService, agentService.configurationService); + diServices.set(IAgentHostGitHubEndpointService, agentService.gitHubEndpointService); diServices.set(IAgentHostCompletions, agentService.completionsService); diServices.set(IAgentHostGitService, gitService); // Register `ICopilotApiService` BEFORE `IClaudeProxyService` — @@ -273,8 +280,9 @@ async function main(): Promise { process.env[AgentHostCodexAgentSdkRootEnvVar] = options.codexSdkRoot; } // Register the agent SDK downloader BEFORE any service that injects it. - const agentSdkDownloader = instantiationService.createInstance(AgentSdkDownloader); + const agentSdkDownloader = disposables.add(instantiationService.createInstance(AgentSdkDownloader)); diServices.set(IAgentSdkDownloader, agentSdkDownloader); + sdkDownloadProgress = agentSdkDownloader.onDidDownloadProgress; const claudeProxyService = disposables.add(instantiationService.createInstance(ClaudeProxyService)); diServices.set(IClaudeProxyService, claudeProxyService); const claudeAgentSdkService = instantiationService.createInstance(ClaudeAgentSdkService); @@ -283,6 +291,12 @@ async function main(): Promise { diServices.set(ICodexProxyService, codexProxyService); const agentHostOTelService = disposables.add(instantiationService.createInstance(AgentHostOTelService)); diServices.set(IAgentHostOTelService, agentHostOTelService); + // BYOK is unsupported in the remote agent host (no extension host runs + // next to it to serve the renderer LM API). Inject null implementations + // to satisfy CopilotAgent / CopilotSessionLauncher DI. + diServices.set(IByokLmBridgeRegistry, new NullByokLmBridgeRegistry()); + diServices.set(IByokLmProxyService, new NullByokLmProxyService()); + diServices.set(IAgentHostProxyResolver, instantiationService.createInstance(AgentHostProxyResolver)); const copilotAgent = disposables.add(instantiationService.createInstance(CopilotAgent)); agentService.registerProvider(copilotAgent); log('CopilotAgent registered'); @@ -311,6 +325,22 @@ async function main(): Promise { } } + // Surface agent-SDK download progress to clients as generic `progress` + // notifications. The downloader fires process-global frames keyed by package + // id; the agent service fans each out to the `createSession` progress tokens + // of the sessions waiting on that provider's SDK, routed through the state + // manager so both local (IPC) and remote (WebSocket) renderers receive them + // via the same path as session updates. + if (sdkDownloadProgress) { + disposables.add(sdkDownloadProgress(p => agentService.emitDownloadProgress( + p.packageId, + p.displayName, + p.receivedBytes, + p.totalBytes, + p.phase === 'completed' || p.phase === 'failed', + ))); + } + if (options.enableMockAgent) { // Dynamic import to avoid bundling test code in production import('../test/node/mockAgent.js').then(({ ScriptedMockAgent }) => { diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 48a7baca9a29ab..79548f0f2f8792 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -9,7 +9,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { equals } from '../../../base/common/objects.js'; import { ILogService } from '../../log/common/log.js'; import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; -import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction } from '../common/state/sessionActions.js'; +import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; @@ -18,7 +18,7 @@ import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; import { buildAnnotationsUri, isAnnotationsUri } from '../common/annotationsUri.js'; import { AgentHostChangesetStateCache, type IAgentHostChangesetStateRetentionOptions } from './agentHostChangesetStateCache.js'; -import { ChangesSummary, type ChatOrigin } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/state/protocol/state.js'; import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; export interface IAgentHostStateManagerOptions { @@ -180,6 +180,21 @@ export class AgentHostStateManager extends Disposable { */ private readonly _chatStates = new Map(); + /** + * Opaque, agent-owned `providerData` blobs keyed by peer-chat channel URI. + * + * Each entry is the verbatim token the owning agent produced for a peer + * chat (see {@link IAgentCreateChatResult.providerData}). The orchestrator + * persists it with the session and hands it back to the agent on restore so + * the agent can re-materialize its SDK conversation; the StateManager itself + * **never parses, validates, or mutates it** — it stores and returns the + * string as-is. The map is kept separate from the protocol-visible + * {@link ChatState}/{@link ChatSummary} catalog so the private blob is not + * streamed to clients. The default chat carries no `providerData`, so it + * never appears here. + */ + private readonly _chatProviderData = new Map(); + /** Expanded changeset states, separated from protocol sequencing so cache policy stays local. */ private readonly _changesets: AgentHostChangesetStateCache; @@ -362,6 +377,18 @@ export class AgentHostStateManager extends Disposable { return this._chatStates.get(chat); } + /** + * Returns the opaque, agent-owned `providerData` blob previously recorded + * for a peer chat via {@link addChat} or {@link restoreChat}, or `undefined` + * when none was stored (e.g. the default chat, or a peer chat the agent had + * nothing resumable to persist for). The value is returned verbatim — the + * StateManager never interprets it; callers persist it with the session and + * hand it back to the owning agent on restore. + */ + getChatProviderData(chat: URI): string | undefined { + return this._chatProviderData.get(chat); + } + /** * Seeds the conversation contents (turns) of a session's default chat. * Used by the fork flow, which materializes a new session pre-populated @@ -598,7 +625,7 @@ export class AgentHostStateManager extends Disposable { * notification because the session is already known to clients via * `listSessions`. */ - restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message }): SessionState { + restoreSession(summary: SessionSummary, turns: Turn[], options?: { readonly draft?: Message; readonly defaultChatTitle?: string }): SessionState { const key = summary.resource; const existing = this._sessionStates.get(key); if (existing) { @@ -611,7 +638,7 @@ export class AgentHostStateManager extends Disposable { lifecycle: SessionLifecycle.Ready, }; this._sessionStates.set(key, this._newEntry(state, summary)); - this._ensureDefaultChat(key, summary, turns, options?.draft); + this._ensureDefaultChat(key, summary, turns, options?.draft, options?.defaultChatTitle); this._summaryNotifier.announce(key, summary); this._logService.trace(`[AgentHostStateManager] Restored session: ${key} (${turns.length} turns)`); @@ -631,13 +658,11 @@ export class AgentHostStateManager extends Disposable { * at creation/restore time, so the snapshot a client later receives on * subscribe already reflects the default chat. */ - private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message): void { + private _ensureDefaultChat(sessionKey: string, summary: SessionSummary, turns?: Turn[], draft?: Message, defaultChatTitle?: string): void { const chatUri = buildDefaultChatUri(sessionKey); - // The default chat starts with an empty title so it inherits the session - // title for display. It only gets its own title when renamed independently - // (via a per-chat `SessionChatUpdated`). This keeps the session title and - // the default chat tab title independent. - const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: '' }; + // Empty title means "inherit the session title"; a persisted independent + // rename (`defaultChatTitle`) is seeded back here so it survives restore. + const chatSummary: ChatSummary = { ...createDefaultChatSummary(summary, chatUri), title: defaultChatTitle ?? '' }; this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: turns ?? [], draft }); const entry = this._sessionStates.get(sessionKey); if (entry) { @@ -661,8 +686,13 @@ export class AgentHostStateManager extends Disposable { * The chat inherits the session's model/agent/working-directory scope. It * is a no-op (returning the existing summary) when a chat with the same URI * already exists. + * + * When `options.providerData` is supplied it is recorded verbatim as the + * peer chat's opaque, agent-owned restore blob (see + * {@link getChatProviderData}); the StateManager never parses it. The + * default chat never carries `providerData`. */ - addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin }): ChatSummary | undefined { + addChat(session: URI, chatUri: URI, options?: { readonly title?: string; readonly turns?: Turn[]; readonly origin?: ChatOrigin; readonly providerData?: string; readonly interactivity?: ChatInteractivity }): ChatSummary | undefined { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] addChat for unknown session: ${session}`); @@ -690,8 +720,12 @@ export class AgentHostStateManager extends Disposable { title: options?.title ?? '', status: SessionStatus.Idle, origin: options?.origin, + interactivity: options?.interactivity, }; this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options?.turns ?? [] }); + if (options?.providerData !== undefined) { + this._chatProviderData.set(chatUri, options.providerData); + } this.dispatchServerAction(session, { type: ActionType.SessionChatAdded, summary: chatSummary }); return chatSummary; } @@ -705,8 +739,12 @@ export class AgentHostStateManager extends Disposable { * in place so the object identity returned by {@link restoreSession} stays * live; no {@link ActionType.SessionChatAdded} is dispatched because restore * runs before clients subscribe. + * + * When `options.providerData` is supplied it is recorded verbatim as the + * peer chat's opaque, agent-owned restore blob (see + * {@link getChatProviderData}); the StateManager never parses it. */ - restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message }): void { + restoreChat(session: URI, chatUri: URI, options: { readonly title?: string; readonly turns: Turn[]; readonly draft?: Message; readonly providerData?: string }): void { const entry = this._sessionStates.get(session); if (!entry) { this._logService.warn(`[AgentHostStateManager] restoreChat for unknown session: ${session}`); @@ -722,6 +760,9 @@ export class AgentHostStateManager extends Disposable { status: SessionStatus.Idle, }; this._chatStates.set(chatUri, { ...createChatState(chatSummary), turns: options.turns, draft: options.draft }); + if (options.providerData !== undefined) { + this._chatProviderData.set(chatUri, options.providerData); + } sessionState.chats = [...sessionState.chats, chatSummary]; } @@ -750,6 +791,7 @@ export class AgentHostStateManager extends Disposable { // (activeSessions > 0) and leaving changeset operations disabled. this._removeChatActiveTurn(session, chatUri); this._chatStates.delete(chatUri); + this._chatProviderData.delete(chatUri); this.dispatchServerAction(session, { type: ActionType.SessionChatRemoved, chat: chatUri }); } @@ -818,6 +860,7 @@ export class AgentHostStateManager extends Disposable { // chat: additional peer chats each hold their own ChatState. for (const chat of entry.state.chats) { this._chatStates.delete(chat.resource); + this._chatProviderData.delete(chat.resource); } this._chatStates.delete(buildDefaultChatUri(session)); this._sessionStates.delete(session); @@ -1354,4 +1397,37 @@ export class AgentHostStateManager extends Disposable { const activityBits = chatStatus & ~(SessionStatus.IsRead | SessionStatus.IsArchived); return activityBits | metaFlags; } + + /** + * Emit a generic progress notification on the root channel, correlated to + * the originating request by {@link ProgressParams.progressToken}. Routed to + * clients through the same {@link onDidEmitNotification} path as session + * notifications, so both the local (IPC proxy) and remote (WebSocket + * {@link ProtocolServerHandler}) renderers receive it without any + * transport-specific special casing. Progress for host-level work (e.g. a + * shared SDK download) rides the root channel rather than a per-session one. + */ + emitProgress(progress: Omit): void { + this._onDidEmitNotification.fire({ + type: 'root/progress', + channel: ROOT_STATE_URI, + ...progress, + }); + } + + /** + * Emit an `auth/required` notification on the root channel, asking the + * client to obtain a fresh token and push it via `authenticate`. Rides the + * same {@link onDidEmitNotification} path as {@link emitProgress}, so both + * local (IPC proxy) and remote (WebSocket) renderers receive it. Used for + * host-level auth requirements (e.g. an agent whose transport flip makes a + * credential newly required) rather than a per-session one. + */ + emitAuthRequired(params: Omit): void { + this._onDidEmitNotification.fire({ + type: 'auth/required', + channel: ROOT_STATE_URI, + ...params, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts index be3cff1db76022..2e19e2904cd137 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryReporter.ts @@ -3,10 +3,13 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../telemetry/common/languageModelToolTelemetry.js'; import type { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { AgentSession } from '../common/agentService.js'; -import type { MessageAttachment } from '../common/state/protocol/state.js'; -import { isAhpChatChannel, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import type { MessageAttachment, SessionInputRequestKind, ToolDefinition } from '../common/state/protocol/state.js'; +import { isAhpChatChannel, isSubagentChatUri, isSubagentSession, parseRequiredSessionUriFromChatUri, type ISessionWithDefaultChat } from '../common/state/sessionState.js'; +import type { ToolInvokedResult } from './agentHostToolCallTracker.js'; +import { multiplexProperties, type IAgentHostRestrictedTelemetry } from './agentHostRestrictedTelemetry.js'; export type AgentHostUserMessageSentSource = 'direct' | 'queued'; @@ -70,10 +73,109 @@ export interface IAgentHostTurnCompletedReport { permissionLevel: string | undefined; } +export interface IAgentHostToolInvokedReport { + provider: string; + session: string; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + invocationTimeMs: number; +} + +export interface IAgentHostToolCallDetailsReport { + session: string; + turnId: string; + model: string | undefined; + responseType: string; + /** Count of invocations keyed by tool name, across all rounds in the turn. */ + toolCounts: Record; + /** Names of the tools offered to the model for this turn. */ + availableTools: readonly string[]; + /** Number of model-call rounds in the turn, including the final tool-free response round (matches the extension's `toolCallRounds.length`). */ + numRequests: number; + totalToolCalls: number; + parallelToolCallRounds: number; + parallelToolCallsTotal: number; +} + +export interface IAgentHostToolCallStalledEvent { + provider: string; + agentSessionId: string; + isSubagentSession: boolean; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + stalledTimeMs: number; +} + +export type IAgentHostToolCallStalledClassification = { + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the stalled agent host tool call.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; + isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the stalled tool call belongs to a subagent session.' }; + blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call is waiting for confirmation or client execution.' }; + toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the stalled tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool is provided by the agent host, an MCP server, or a client.' }; + stalledTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds that the tool call has remained blocked.' }; + owner: 'roblourens'; + comment: 'Tracks agent host tool calls that remain blocked beyond the stall threshold.'; +}; + +export interface IAgentHostToolCallStalledReport { + provider: string; + session: string; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + stalledTimeMs: number; +} + +export interface IAgentHostStalledToolCallCompletedEvent { + provider: string; + agentSessionId: string; + isSubagentSession: boolean; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + totalTimeMs: number; + timeAfterStallMs: number; +} + +export type IAgentHostStalledToolCallCompletedClassification = { + provider: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The provider handling the completed agent host tool call.' }; + agentSessionId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The agent host session identifier.' }; + isSubagentSession: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Whether the completed tool call belongs to a subagent session.' }; + blockerKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the tool call had stalled waiting for confirmation or client execution.' }; + toolId: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The identifier of the completed tool.' }; + toolSourceKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the completed tool is provided by the agent host, an MCP server, or a client.' }; + result: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether the stalled tool call eventually completed successfully, with an error, or through user cancellation.' }; + totalTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Total time in milliseconds from tool call start to completion.' }; + timeAfterStallMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds from the stall report to tool call completion.' }; + owner: 'roblourens'; + comment: 'Tracks agent host tool calls that complete after previously exceeding the stall threshold.'; +}; + +export interface IAgentHostStalledToolCallCompletedReport { + provider: string; + session: string; + blockerKind: SessionInputRequestKind.ToolConfirmation | SessionInputRequestKind.ToolClientExecution; + toolId: string; + toolSourceKind: string; + result: ToolInvokedResult; + totalTimeMs: number; + timeAfterStallMs: number; +} + export class AgentHostTelemetryReporter { constructor(private readonly _telemetryService: ITelemetryService) { } + /** The restricted GH/MSFT telemetry surface, present when the agent-host telemetry service is wired. */ + private get _restricted(): IAgentHostRestrictedTelemetry | undefined { + const ts = this._telemetryService as Partial; + return typeof ts.sendEnhancedGHTelemetryEvent === 'function' ? ts as IAgentHostRestrictedTelemetry : undefined; + } + userMessageSent(provider: string, session: string, sessionState: ISessionWithDefaultChat | undefined, source: AgentHostUserMessageSentSource, attachments: readonly MessageAttachment[] | undefined): void { const attachmentCount = attachments?.length ?? 0; const activeClients = sessionState?.activeClients ?? []; @@ -93,6 +195,129 @@ export class AgentHostTelemetryReporter { }); } + /** + * Mirrors the Copilot extension's enhanced GH `request.options.tools` event for the agent-host + * flow. The extension emits it per LLM request from its model fetcher; the agent host observes + * the equivalent boundary when an `assistant.message` arrives (one per model call). The + * extension populates `headerRequestId` with the client-minted `x-request-id`, which the SDK + * does not surface on success; we keep the same field name (so science queries are undisturbed) + * but fill it with the model call's `x-copilot-service-request-id`, the per-call id the SDK does + * expose. `messagesJson` is the raw tool definitions offered for the call, multiplexed across + * ~8192-char chunks like the extension, so it lands identically downstream. + * + * @param session Session URI string; its id becomes `conversationId`. + * @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to the extension's `headerRequestId`. No-ops when absent (e.g. providers that don't surface it). + * @param tools The tool definitions offered to the model for this call. + */ + assistantMessageReceived(session: string, serviceRequestId: string | undefined, tools: readonly ToolDefinition[]): void { + const restricted = this._restricted; + if (!restricted || !serviceRequestId || tools.length === 0) { + return; + } + restricted.sendEnhancedGHTelemetryEvent('request.options.tools', multiplexProperties({ + headerRequestId: serviceRequestId, + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + })); + } + + /** + * Mirrors the Copilot extension's restricted `conversation.messageText` event (the panel-chat + * prefix of `sendConversationalMessageTelemetry`) for the user's prompt. The extension emits it + * for every user and model message, carrying the raw message text to the enhanced GH + * (`copilot_v0_restricted_copilot_event`) and internal MSFT pipelines; the agent host observes + * the same boundary at the SDK `user.message` event. The text is multiplexed across ~8192-char + * chunks (`messageText`, `messageText_02`, …) so long prompts land untruncated, matching the + * extension's `multiplexProperties`. + * + * @param session Session URI string; its id becomes `conversationId`. + * @param content The user's prompt text. No-ops when empty. + * @param turnIndex The 0-based ordinal of the turn this message belongs to, matching the extension's numeric `turnIndex` (`conversation.turns.length`). CTS parses `turn_index` as an integer, so a numeric ordinal is required here (a non-numeric id lands empty). + */ + userMessageText(session: string, content: string, turnIndex: number): void { + const restricted = this._restricted; + if (!restricted || !content) { + return; + } + const properties = multiplexProperties({ + source: 'user', + conversationId: AgentSession.id(session), + turnIndex: String(turnIndex), + messageText: content, + }); + const measurements = { messageCharLen: content.length }; + restricted.sendEnhancedGHTelemetryEvent('conversation.messageText', properties, measurements); + restricted.sendInternalMSFTTelemetryEvent('conversation.messageText', properties, measurements); + } + + /** + * The model-message counterpart to {@link userMessageText}. Emitted when an `assistant.message` + * arrives (the agent host's per-model-call boundary), carrying the assistant's response text. + * `headerRequestId` is filled with the model call's `x-copilot-service-request-id` (the id the + * SDK exposes), mirroring the field the extension populates from the client-minted request id. + * VS Code-only enrichment dims (code-block languages/counts) are not reconstructed here. + * + * @param session Session URI string; its id becomes `conversationId`. + * @param content The assistant's response text. No-ops when empty. + * @param turnIndex The 0-based ordinal of the turn this message belongs to, matching the extension's numeric `turnIndex` (`conversation.turns.length`). CTS parses `turn_index` as an integer, so a numeric ordinal is required here. + * @param serviceRequestId The model call's `x-copilot-service-request-id`, mapped to `headerRequestId`. + */ + modelMessageText(session: string, content: string, turnIndex: number, serviceRequestId: string | undefined): void { + const restricted = this._restricted; + if (!restricted || !content) { + return; + } + const properties = multiplexProperties({ + source: 'model', + conversationId: AgentSession.id(session), + turnIndex: String(turnIndex), + ...(serviceRequestId ? { headerRequestId: serviceRequestId } : {}), + messageText: content, + }); + const measurements = { messageCharLen: content.length }; + restricted.sendEnhancedGHTelemetryEvent('conversation.messageText', properties, measurements); + restricted.sendInternalMSFTTelemetryEvent('conversation.messageText', properties, measurements); + } + + /** + * Mirrors the Copilot extension's restricted `toolCallDetailsExternal` / `toolCallDetailsInternal` + * events (`chatParticipantTelemetry.ts` -> `sendToolCallingTelemetry`) — the per-turn tool-call + * aggregate. The extension emits it once at the end of a turn's tool-calling loop; the agent host + * accumulates the same counts across the turn's `assistant.message` rounds and emits on turn + * completion. The tool-definition token count, per-round token/char counts, invalid-round count, + * and turn index (the extension emits it only as a non-landing measurement) are not surfaced at the + * AH turn boundary and are omitted. Like the extension, this fires for every turn that had tools + * available — even one that made no tool calls (empty `toolCounts`) — and no-ops only when no tools + * were offered. + * + * @param report The per-turn tool-call aggregate. + */ + toolCallDetails(report: IAgentHostToolCallDetailsReport): void { + const restricted = this._restricted; + if (!restricted || report.availableTools.length === 0) { + return; + } + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + const properties = multiplexProperties({ + conversationId: AgentSession.id(session), + requestId: report.turnId, + messageId: report.turnId, + responseType: report.responseType, + ...(report.model ? { model: report.model } : {}), + toolCounts: JSON.stringify(report.toolCounts), + availableTools: JSON.stringify(report.availableTools), + }); + const measurements = { + numRequests: report.numRequests, + availableToolCount: report.availableTools.length, + totalToolCalls: report.totalToolCalls, + parallelToolCallRounds: report.parallelToolCallRounds, + parallelToolCallsTotal: report.parallelToolCallsTotal, + }; + restricted.sendEnhancedGHTelemetryEvent('toolCallDetailsExternal', properties, measurements); + restricted.sendInternalMSFTTelemetryEvent('toolCallDetailsInternal', properties, measurements); + } + turnCompleted(report: IAgentHostTurnCompletedReport): void { const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; this._telemetryService.publicLog2('agentHost.turnCompleted', { @@ -105,4 +330,48 @@ export class AgentHostTelemetryReporter { permissionLevel: report.permissionLevel, }); } + + toolInvoked(report: IAgentHostToolInvokedReport): void { + // `chatSessionId` is the full session URI string (matching the value + // previously emitted by `CopilotAgentSession`). Action signals are keyed + // by their chat-channel URI, so normalize it back to the session URI. + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('languageModelToolInvoked', { + result: report.result, + chatSessionId: session, + toolId: report.toolId, + toolExtensionId: undefined, + toolSourceKind: report.toolSourceKind, + invocationTimeMs: report.invocationTimeMs, + provider: report.provider, + }); + } + + toolCallStalled(report: IAgentHostToolCallStalledReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('agentHost.toolCallStalled', { + provider: report.provider, + agentSessionId: AgentSession.id(session), + isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + blockerKind: report.blockerKind, + toolId: report.toolId, + toolSourceKind: report.toolSourceKind, + stalledTimeMs: report.stalledTimeMs, + }); + } + + stalledToolCallCompleted(report: IAgentHostStalledToolCallCompletedReport): void { + const session = isAhpChatChannel(report.session) ? parseRequiredSessionUriFromChatUri(report.session) : report.session; + this._telemetryService.publicLog2('agentHost.stalledToolCallCompleted', { + provider: report.provider, + agentSessionId: AgentSession.id(session), + isSubagentSession: isSubagentChatUri(report.session) || isSubagentSession(session), + blockerKind: report.blockerKind, + toolId: report.toolId, + toolSourceKind: report.toolSourceKind, + result: report.result, + totalTimeMs: report.totalTimeMs, + timeAfterStallMs: report.timeAfterStallMs, + }); + } } diff --git a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts index 6cc0a3f9bf6e5d..429e52a8fd8613 100644 --- a/src/vs/platform/agentHost/node/agentHostTelemetryService.ts +++ b/src/vs/platform/agentHost/node/agentHostTelemetryService.ts @@ -21,6 +21,8 @@ import { TelemetryLogAppender } from '../../telemetry/common/telemetryLogAppende import { TelemetryService } from '../../telemetry/common/telemetryService.js'; import { getPiiPathsFromEnvironment, isInternalTelemetry, isLoggingOnly, NullTelemetryService, supportsTelemetry, type ITelemetryAppender } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostTelemetryLevelConfigKey, agentHostConfigValueToTelemetryLevel } from '../common/agentHostSchema.js'; +import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey } from '../common/agentHostTelemetryEnv.js'; +import { AgentHostRestrictedTelemetrySender, IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from './agentHostRestrictedTelemetry.js'; export interface IAgentHostTelemetryServiceOptions { readonly environmentService: INativeEnvironmentService; @@ -32,7 +34,7 @@ export interface IAgentHostTelemetryServiceOptions { readonly disableTelemetry?: boolean; } -export interface IAgentHostTelemetryService extends ITelemetryService { +export interface IAgentHostTelemetryService extends ITelemetryService, IAgentHostRestrictedTelemetry { updateTelemetryLevel(telemetryLevel: TelemetryLevel): void; } @@ -41,7 +43,17 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT private _telemetryLevel = TelemetryLevel.USAGE; - constructor(private readonly _delegate: ITelemetryService) { + /** + * Whether the current Copilot token opts into enhanced/restricted telemetry (`rt=1`). Defaults + * to `false` so nothing restricted is sent until an authenticated token confirms the opt-in, + * keeping public users off the enhanced pipeline the way the Copilot extension does. + */ + private _restrictedTelemetryEnabled = false; + + constructor( + private readonly _delegate: ITelemetryService, + private readonly _restricted?: IAgentHostRestrictedTelemetry, + ) { super(); if (isDisposable(_delegate)) { this._register(_delegate); @@ -108,6 +120,42 @@ export class AgentHostTelemetryService extends Disposable implements IAgentHostT this._delegate.publicLogError2(eventName, data); } + sendGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendGHTelemetryEvent(eventName, properties, measurements); + } + + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE || !this._restrictedTelemetryEnabled) { + return; + } + this._restricted?.sendEnhancedGHTelemetryEvent(eventName, properties, measurements); + } + + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, measurements?: TelemetryMeasurements): void { + if (this.telemetryLevel < TelemetryLevel.USAGE) { + return; + } + this._restricted?.sendInternalMSFTTelemetryEvent(eventName, properties, measurements); + } + + setCopilotTrackingId(trackingId: string | undefined): void { + this._restricted?.setCopilotTrackingId(trackingId); + } + + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this._restricted?.setRestrictedTelemetryEndpoint(endpointUrl); + } + + setRestrictedTelemetryEnabled(enabled: boolean): void { + this._restrictedTelemetryEnabled = enabled; + // Mirror onto the sender so the restricted-table writer enforces the same `rt` gate + // independently (defense in depth), matching the extension's opted-in-only reporter. + this._restricted?.setRestrictedTelemetryEnabled(enabled); + } + setExperimentProperty(name: string, value: string): void { this._delegate.setExperimentProperty(name, value); } @@ -130,7 +178,7 @@ export function updateAgentHostTelemetryLevelFromConfig(telemetryService: ITelem telemetryService.updateTelemetryLevel(telemetryLevelValue); } -function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { +export function isAgentHostTelemetryService(telemetryService: ITelemetryService): telemetryService is IAgentHostTelemetryService { return typeof (telemetryService as IAgentHostTelemetryService).updateTelemetryLevel === 'function'; } @@ -153,18 +201,26 @@ export async function createAgentHostTelemetryService(options: IAgentHostTelemet appenders.push(collectorAppender); } + // Prefer the host-forwarded identifiers (see `agentHostTelemetryEnv`) so the + // agent host reports the same persisted machineId/sqmId/devDeviceId as the + // workbench. Fall back to computing them live when not provided (e.g. the + // remote/server agent host, which does not forward them). const [machineId, sqmId, devDeviceId] = await Promise.all([ - getMachineId(error => logService.error(error)), - getSqmMachineId(error => logService.error(error)), - getDevDeviceId(error => logService.error(error)), + process.env[AgentHostMachineIdEnvKey] || getMachineId(error => logService.error(error)), + process.env[AgentHostSqmIdEnvKey] || getSqmMachineId(error => logService.error(error)), + process.env[AgentHostDevDeviceIdEnvKey] || getDevDeviceId(error => logService.error(error)), ]); + const commonProperties = resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date); + const telemetryService = new TelemetryService({ appenders, sendErrorTelemetry: true, - commonProperties: resolveCommonProperties(release(), hostname(), process.arch, productService.commit, productService.version, machineId, sqmId, devDeviceId, internalTelemetry, productService.date), + commonProperties, piiPaths: getPiiPathsFromEnvironment(environmentService), }, configurationService, productService); - return disposables.add(new AgentHostTelemetryService(telemetryService)); + const restricted = new AgentHostRestrictedTelemetrySender(commonProperties, logService); + + return disposables.add(new AgentHostTelemetryService(telemetryService, restricted)); } diff --git a/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts new file mode 100644 index 00000000000000..7511ac9afbe801 --- /dev/null +++ b/src/vs/platform/agentHost/node/agentHostToolCallTracker.ts @@ -0,0 +1,203 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { disposableTimeout } from '../../../base/common/async.js'; +import { Disposable, DisposableMap } from '../../../base/common/lifecycle.js'; +import { StopWatch } from '../../../base/common/stopwatch.js'; +import type { SessionToolClientExecutionRequest, SessionToolConfirmationRequest } from '../common/state/protocol/state.js'; +import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../common/state/sessionState.js'; +import type { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; + +export type ToolInvokedResult = 'success' | 'error' | 'userCancelled'; + +const TOOL_CALL_STALL_THRESHOLD_MS = 5 * 60 * 1000; + +type ToolCallBlockerRequest = SessionToolConfirmationRequest | SessionToolClientExecutionRequest; + +/** + * Maps a completed tool call's result to the telemetry result bucket. Mirrors + * the derivation previously done inline in `CopilotAgentSession`: a denied, + * rejected, or cancelled tool call counts as `userCancelled`; any other + * failure counts as `error`. + */ +export function deriveToolInvokedResult(result: ToolCallResult): ToolInvokedResult { + if (result.success) { + return 'success'; + } + const code = result.error?.code; + if (code === 'rejected' || code === 'denied' || code === 'cancelled') { + return 'userCancelled'; + } + return 'error'; +} + +/** + * Maps a tool call's contributor to the telemetry `toolSourceKind`. A tool with + * no contributor is provided by the agent host itself; an MCP contributor maps + * to `mcp` and a client contributor to `client`. + */ +export function toolSourceKindFromContributor(contributor: ToolCallContributor | undefined): string { + if (!contributor) { + return 'agentHost'; + } + // Widen to `string` so an unrecognized kind from a newer protocol version + // falls through to a valid telemetry value rather than `undefined`. + const kind: string = contributor.kind; + switch (kind) { + case ToolCallContributorKind.MCP: + return 'mcp'; + case ToolCallContributorKind.Client: + return 'client'; + default: + return kind; + } +} + +/** Per-tool-call timing state, keyed by `session:toolCallId`. */ +interface IToolCallTiming { + readonly stopWatch: StopWatch; + readonly provider: string; + readonly session: string; + readonly toolId: string; + readonly toolSourceKind: string; +} + +interface IStalledToolCall { + readonly blockerKind: ToolCallBlockerRequest['kind']; + readonly completionStopWatch: StopWatch; +} + +/** + * Tracks completed and stalled tool calls for agent host sessions. + * + * Lifecycle per tool call: + * 1. {@link toolCallStarted} — begins a stopwatch and records the tool's + * name and source kind (only the start action carries these) + * 2. {@link toolCallCompleted} — emits the telemetry event and clears state + * 3. {@link toolCallBlocked} / {@link toolCallUnblocked} — emits once when a + * confirmation or client execution remains unresolved past the threshold + * + * In-flight tool calls that never complete (e.g. the turn is cancelled mid + * tool call) are dropped via {@link clearSession} / {@link clear} so the + * tracking map cannot leak. + */ +export class AgentHostToolCallTracker extends Disposable { + + private readonly _toolCalls = new Map(); + private readonly _toolCallStallTimers = this._register(new DisposableMap()); + private readonly _stalledToolCalls = new Map(); + + constructor(private readonly _reporter: AgentHostTelemetryReporter) { + super(); + } + + toolCallStarted(provider: string, session: string, toolCallId: string, toolName: string, contributor: ToolCallContributor | undefined): void { + this._toolCalls.set(this._key(session, toolCallId), { + stopWatch: StopWatch.create(true), + provider, + session, + toolId: toolName, + toolSourceKind: toolSourceKindFromContributor(contributor), + }); + } + + toolCallCompleted(session: string, toolCallId: string, result: ToolCallResult): void { + const key = this._key(session, toolCallId); + const timing = this._toolCalls.get(key); + if (!timing) { + // No matching start: either the start was never observed, or this is + // a duplicate completion (the entry was already consumed). Either + // way, do not emit so volume stays accurate. + return; + } + this._toolCalls.delete(key); + const resultBucket = deriveToolInvokedResult(result); + const totalTimeMs = timing.stopWatch.elapsed(); + + this._reporter.toolInvoked({ + provider: timing.provider, + session: timing.session, + toolId: timing.toolId, + toolSourceKind: timing.toolSourceKind, + result: resultBucket, + invocationTimeMs: totalTimeMs, + }); + + const stalled = this._stalledToolCalls.get(key); + if (stalled) { + this._stalledToolCalls.delete(key); + this._reporter.stalledToolCallCompleted({ + provider: timing.provider, + session: timing.session, + blockerKind: stalled.blockerKind, + toolId: timing.toolId, + toolSourceKind: timing.toolSourceKind, + result: resultBucket, + totalTimeMs, + timeAfterStallMs: stalled.completionStopWatch.elapsed(), + }); + } + } + + toolCallBlocked(provider: string, session: string, request: ToolCallBlockerRequest): void { + const key = this._key(session, request.id); + const toolCallKey = this._key(session, request.toolCall.toolCallId); + if (this._toolCallStallTimers.has(key) || this._stalledToolCalls.has(toolCallKey)) { + return; + } + + const stopWatch = StopWatch.create(true); + this._toolCallStallTimers.set(key, disposableTimeout(() => { + const stalledTimeMs = stopWatch.elapsed(); + this._stalledToolCalls.set(toolCallKey, { blockerKind: request.kind, completionStopWatch: StopWatch.create(true) }); + this._reporter.toolCallStalled({ + provider, + session, + blockerKind: request.kind, + toolId: request.toolCall.toolName, + toolSourceKind: toolSourceKindFromContributor(request.toolCall.contributor), + stalledTimeMs, + }); + }, TOOL_CALL_STALL_THRESHOLD_MS)); + } + + toolCallUnblocked(session: string, requestId: string): void { + this._toolCallStallTimers.deleteAndDispose(this._key(session, requestId)); + } + + /** + * Drops any in-flight (never-completed) tool calls for a session. Called + * when a turn ends or a session is torn down so the tracking map cannot + * leak. A no-op in the normal case where every tool call completes. + */ + clearSession(session: string): void { + const prefix = `${session}\0`; + for (const key of this._toolCalls.keys()) { + if (key.startsWith(prefix)) { + this._toolCalls.delete(key); + } + } + for (const key of this._toolCallStallTimers.keys()) { + if (key.startsWith(prefix)) { + this._toolCallStallTimers.deleteAndDispose(key); + } + } + for (const key of this._stalledToolCalls.keys()) { + if (key.startsWith(prefix)) { + this._stalledToolCalls.delete(key); + } + } + } + + clear(): void { + this._toolCalls.clear(); + this._toolCallStallTimers.clearAndDisposeAll(); + this._stalledToolCalls.clear(); + } + + private _key(session: string, toolCallId: string): string { + return `${session}\0${toolCallId}`; + } +} diff --git a/src/vs/platform/agentHost/node/agentPeerChats.ts b/src/vs/platform/agentHost/node/agentPeerChats.ts new file mode 100644 index 00000000000000..8b1e88d5cd631d --- /dev/null +++ b/src/vs/platform/agentHost/node/agentPeerChats.ts @@ -0,0 +1,182 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { type ModelSelection } from '../common/state/protocol/state.js'; + +/** + * In-memory backing for an additional (non-default) peer chat. Records the SDK + * chat id that backs the chat so it can be re-resumed after a process restart, + * along with any model override chosen at creation time. This is also the shape + * serialized into the opaque, agent-owned `providerData` blob the orchestrator + * persists in its chat catalog and hands back on restore. + */ +export interface IPersistedChat { + readonly sdkSessionId: string; + readonly model?: ModelSelection; +} + +export interface IResolvedAgentChat { + readonly chatSession: TSession; + readonly isDefault: boolean; +} + +/** + * Serializes a peer-chat backing into the opaque `providerData` token the + * orchestrator persists verbatim. The encoding is the agent's private business + * — today it is the JSON of {@link IPersistedChat}. + */ +export function encodeProviderData(backing: IPersistedChat): string { + return JSON.stringify(backing); +} + +/** + * Decodes an opaque `providerData` token produced by {@link encodeProviderData} + * back into a peer-chat backing, tolerating corrupt/foreign blobs by returning + * `undefined` (the same drop-on-corrupt policy as the legacy chat catalog read). + */ +export function decodeProviderData(providerData: string): IPersistedChat | undefined { + try { + const value = JSON.parse(providerData) as { sdkSessionId?: unknown; model?: unknown }; + if (!value || typeof value !== 'object') { + return undefined; + } + const { sdkSessionId, model } = value; + if (typeof sdkSessionId !== 'string' || !sdkSessionId) { + return undefined; + } + // The blob is client-influenced and may be corrupted or shape-shifted by + // a future serialization change: only accept a `model` that actually + // looks like a `ModelSelection`. + const validModel = model && typeof model === 'object' && typeof (model as { id?: unknown }).id === 'string' + ? model as ModelSelection + : undefined; + return { sdkSessionId, ...(validModel ? { model: validModel } : {}) }; + } catch { + return undefined; + } +} + +/** + * Per-session container shared by the multi-chat agents. Keeps ALL chats of a + * session — the default (main) chat and any additional peer chats — together in + * ONE per-agent map keyed by each chat's channel URI string (no parallel maps, + * no default-vs-peer storage split). The default chat is just the entry marked + * as default, so send/abort/model/agent/history operations resolve any chat by a + * single uniform {@link getChat} lookup with no default-chat resolution branch. + * + * Each entry can act as a leaf (wrapping one {@link ownSession} plus its + * event-forwarding disposables) or as the container (holding the chat map). + * Disposing the container disposes every chat leaf it holds. + */ +export class AgentSessionEntry extends Disposable { + /** All chats of the session (default + peers) as leaf entries, keyed by chat-URI string. */ + private readonly _chats = this._register(new DisposableMap>()); + /** The key of the session's default (main) chat within {@link _chats}. */ + private _defaultChatKey: string | undefined; + /** This leaf's own chat session (set when the entry wraps a single chat). */ + private _ownSession: TSession | undefined; + + constructor(session?: TSession) { + super(); + if (session) { + this._ownSession = session; + this._register(session); + } + } + + /** This leaf's own chat session, or `undefined` for a bare container. */ + get ownSession(): TSession | undefined { + return this._ownSession; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } + + // ---- Uniform chat map (default + peers) -------------------------------- + + /** Register the session's default (main) chat leaf under its chat-URI key. */ + setDefaultChat(chatKey: string, entry: AgentSessionEntry): void { + this._chats.set(chatKey, entry); + this._defaultChatKey = chatKey; + } + + /** Dispose the default chat leaf (e.g. a config-driven restart) while keeping peer chats. */ + clearDefaultChat(): void { + if (this._defaultChatKey !== undefined) { + this._chats.deleteAndDispose(this._defaultChatKey); + this._defaultChatKey = undefined; + } + } + + /** The session's materialized default (main) chat, or `undefined` while provisional. */ + get defaultChat(): TSession | undefined { + return this._defaultChatKey !== undefined ? this._chats.get(this._defaultChatKey)?.ownSession : undefined; + } + + /** Uniform lookup: the chat's session (default OR peer) by its chat-URI key. */ + getChat(chatKey: string): TSession | undefined { + return this._chats.get(chatKey)?.ownSession; + } + + /** Uniform lookup with default-vs-peer identity from the entry that resolved the chat. */ + resolveChat(chatKey: string): IResolvedAgentChat | undefined { + const chatSession = this._chats.get(chatKey)?.ownSession; + if (!chatSession) { + return undefined; + } + return { chatSession, isDefault: chatKey === this._defaultChatKey }; + } + + /** Every live chat session — the default chat plus all peers. */ + allChatSessions(): TSession[] { + const sessions: TSession[] = []; + for (const entry of this._chats.values()) { + if (entry.ownSession) { + sessions.push(entry.ownSession); + } + } + return sessions; + } + + // ---- Peer chats (every chat except the default) ------------------------ + + getPeerChat(chatKey: string): TSession | undefined { + return chatKey === this._defaultChatKey ? undefined : this._chats.get(chatKey)?.ownSession; + } + + hasPeerChat(chatKey: string): boolean { + return chatKey !== this._defaultChatKey && this._chats.has(chatKey); + } + + registerPeerChat(chatKey: string, entry: AgentSessionEntry): void { + this._chats.set(chatKey, entry); + } + + disposePeerChat(chatKey: string): void { + if (chatKey !== this._defaultChatKey) { + this._chats.deleteAndDispose(chatKey); + } + } + + peerChatKeys(): string[] { + return [...this._chats.keys()].filter(key => key !== this._defaultChatKey); + } + + peerChatSessions(): TSession[] { + const sessions: TSession[] = []; + for (const key of this._chats.keys()) { + if (key === this._defaultChatKey) { + continue; + } + const session = this._chats.get(key)?.ownSession; + if (session) { + sessions.push(session); + } + } + return sessions; + } +} diff --git a/src/vs/platform/agentHost/node/agentSdkDownloader.ts b/src/vs/platform/agentHost/node/agentSdkDownloader.ts index 32c86eacaa17e4..29d0686d35d7d6 100644 --- a/src/vs/platform/agentHost/node/agentSdkDownloader.ts +++ b/src/vs/platform/agentHost/node/agentSdkDownloader.ts @@ -8,9 +8,12 @@ import * as tar from 'tar'; import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import * as path from '../../../base/common/path.js'; import { format2 } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; +import { generateUuid } from '../../../base/common/uuid.js'; import { detectLibcSync, type LibcFamily } from '../../../base/node/libc.js'; import { INativeEnvironmentService } from '../../environment/common/environment.js'; import { FileOperationError, FileOperationResult, IFileService, toFileOperationResult } from '../../files/common/files.js'; @@ -47,6 +50,12 @@ import { IRequestContext } from '../../../base/parts/request/common/request.js'; export interface IAgentSdkPackage { /** Key under `product.agentSdks` — e.g. `'claude'`, `'codex'`. */ readonly id: string; + /** + * Brand display name for user-facing progress, e.g. `'Claude'`, `'Codex'`. + * The downloader puts this on {@link IAgentSdkDownloadProgress.displayName} + * so clients can build a localized "Downloading {displayName} agent" label. + */ + readonly displayName: string; /** Env var that, when set, becomes the SDK root and short-circuits the download. */ readonly devOverrideEnvVar: string; /** @@ -111,9 +120,46 @@ export function resolveSdkTarget( export const IAgentSdkDownloader = createDecorator('agentSdkDownloader'); +/** Lifecycle phase of a single SDK download (downloader-internal). */ +export type AgentSdkDownloadPhase = 'started' | 'progress' | 'completed' | 'failed'; + +/** + * A process-global download-progress sample fired on + * {@link IAgentSdkDownloader.onDidDownloadProgress}. The downloader owns the + * lifecycle: one `started`, throttled `progress` frames, then exactly one + * terminal `completed` / `failed` — all sharing a `downloadId`. Concurrent + * `loadSdkRoot` callers for the same tarball are deduped, so they observe one + * shared download (one `downloadId`). + */ +export interface IAgentSdkDownloadProgress { + /** Stable id for one download; coalesces frames and distinguishes concurrent fetches. */ + readonly downloadId: string; + /** Package id, e.g. `'claude'` / `'codex'`. */ + readonly packageId: string; + /** Brand display name, e.g. `'Claude'`. */ + readonly displayName: string; + /** Lifecycle phase of this frame. */ + readonly phase: AgentSdkDownloadPhase; + /** Bytes written so far. Monotonically non-decreasing within a `downloadId`. */ + readonly receivedBytes: number; + /** Total bytes from `Content-Length`, or `undefined` when unknown (indeterminate). */ + readonly totalBytes: number | undefined; + /** Short, non-localized failure reason; present only when `phase: 'failed'`. */ + readonly error?: string; +} + export interface IAgentSdkDownloader { readonly _serviceBrand: undefined; + /** + * Fires while a tarball is being fetched (cold cache only): one `started`, + * throttled `progress` samples, then one terminal `completed` / `failed`. + * Never fires for dev-override or cache-hit resolutions (no bytes move). + * Process-global so a single subscriber (the protocol server) can forward + * progress to clients regardless of which session triggered the fetch. + */ + readonly onDidDownloadProgress: Event; + /** * Returns the absolute path of the SDK root directory — the directory that * contains the package's `node_modules/` subtree. Callers resolve the @@ -138,6 +184,20 @@ export interface IAgentSdkDownloader { * download. */ isAvailable(pkg: IAgentSdkPackage): boolean; + + /** + * True iff {@link loadSdkRoot} would resolve WITHOUT a network download — + * the dev override is set, or a completed cache for the configured version + * already exists on disk. False when product config is present but the + * cache is cold (a fetch would be required), and false when neither an + * override nor product config is configured. + * + * Performs at most a single sentinel `exists` check and never downloads. + * Eager / background callers (e.g. a provider listing its sessions at + * startup) use this to avoid kicking off a multi-second cold download + * before the user has asked for anything. + */ + isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise; } // #endregion @@ -147,9 +207,31 @@ export interface IAgentSdkDownloader { /** How long a `loadSdkRoot` failure latches before we try again. */ const LOAD_FAILURE_NEGATIVE_CACHE_MS = 30_000; -export class AgentSdkDownloader implements IAgentSdkDownloader { +/** + * Minimum gap between download-progress samples. A 70-95MB tarball over a fast + * link produces thousands of chunks; without throttling we'd flood the progress + * channel. ~250ms keeps the percentage visibly moving without spamming. + */ +const PROGRESS_EMIT_THROTTLE_MS = 250; + +/** + * Parses a `Content-Length` header into a positive integer byte count, or + * `undefined` when the header is absent, an array, or not a clean integer. + */ +function parseContentLength(header: string | string[] | undefined): number | undefined { + if (typeof header !== 'string' || !/^\d+$/.test(header)) { + return undefined; + } + const parsed = parseInt(header, 10); + return parsed > 0 ? parsed : undefined; +} + +export class AgentSdkDownloader extends Disposable implements IAgentSdkDownloader { declare readonly _serviceBrand: undefined; + private readonly _onDidDownloadProgress = this._register(new Emitter()); + readonly onDidDownloadProgress: Event = this._onDidDownloadProgress.event; + /** * In-flight downloads keyed by the destination `cacheDir` (which * already encodes `//`). Concurrent @@ -180,7 +262,9 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { @IRequestService private readonly _requestService: IRequestService, @IFileService private readonly _fileService: IFileService, @ILogService private readonly _logService: ILogService, - ) { } + ) { + super(); + } isAvailable(pkg: IAgentSdkPackage): boolean { if (process.env[pkg.devOverrideEnvVar]) { @@ -189,6 +273,22 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return !!this._productService.agentSdks?.[pkg.id] && resolveSdkTarget(pkg) !== undefined; } + async isSdkResolvableWithoutDownload(pkg: IAgentSdkPackage): Promise { + if (process.env[pkg.devOverrideEnvVar]) { + return true; + } + const config = this._productService.agentSdks?.[pkg.id]; + if (!config) { + return false; + } + const sdkTarget = resolveSdkTarget(pkg); + if (!sdkTarget) { + return false; + } + const sentinel = URI.joinPath(URI.file(this._cacheDir(pkg.id, config.version, sdkTarget)), '.complete'); + return this._fileService.exists(sentinel); + } + async loadSdkRoot(pkg: IAgentSdkPackage, token: CancellationToken): Promise { // 1. Dev override. const override = process.env[pkg.devOverrideEnvVar]; @@ -310,9 +410,21 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { await this._delIgnoringMissing(tmpDirUri); await this._fileService.createFolder(tmpDirUri); + // Fire the download lifecycle on the process-global event so a single + // subscriber (the protocol server) can forward it to clients. One + // `started`, throttled `progress` from `_fetch`, then a terminal frame. + const downloadId = generateUuid(); + let lastReceived = 0; + let lastTotal: number | undefined; + this._fireProgress(pkg, downloadId, 'started', 0, undefined); + try { const tarballPath = path.join(tmpDir, 'sdk.tgz'); - await this._fetch(url, tarballPath, token); + await this._fetch(url, tarballPath, token, (receivedBytes, totalBytes) => { + lastReceived = receivedBytes; + lastTotal = totalBytes; + this._fireProgress(pkg, downloadId, 'progress', receivedBytes, totalBytes); + }); await this._extractTarGz(tarballPath, tmpDir); await this._fileService.del(URI.file(tarballPath)); @@ -334,6 +446,7 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { } catch (err) { if (await this._handleRenameLoser(err, sentinel, tmpDirUri)) { this._logService.info(`[AgentSdkDownloader] ${pkg.id}: lost rename race, using existing cache`); + this._fireProgress(pkg, downloadId, 'completed', lastReceived, lastTotal); return cacheDir; } throw err; @@ -341,21 +454,44 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { const elapsed = Math.round((Date.now() - start) / 1000); this._logService.info(`[AgentSdkDownloader] ${pkg.id}: downloaded in ${elapsed}s`); + this._fireProgress(pkg, downloadId, 'completed', lastTotal ?? lastReceived, lastTotal); return cacheDir; } catch (err) { await this._delIgnoringMissing(tmpDirUri); if (token.isCancellationRequested) { + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, 'cancelled'); throw new CancellationError(); } + const message = err instanceof Error ? err.message : String(err); + this._fireProgress(pkg, downloadId, 'failed', lastReceived, lastTotal, message); throw new Error( `Failed to download ${pkg.id} SDK from ${url} ` + `(cache target: ${cacheDir}). ` + `Set ${pkg.devOverrideEnvVar} to a local SDK root to bypass. ` + - `Cause: ${err instanceof Error ? err.message : String(err)}`, + `Cause: ${message}`, ); } } + private _fireProgress( + pkg: IAgentSdkPackage, + downloadId: string, + phase: AgentSdkDownloadPhase, + receivedBytes: number, + totalBytes: number | undefined, + error?: string, + ): void { + this._onDidDownloadProgress.fire({ + downloadId, + packageId: pkg.id, + displayName: pkg.displayName, + phase, + receivedBytes, + totalBytes, + ...(error !== undefined ? { error } : {}), + }); + } + private async _handleRenameLoser( err: unknown, sentinel: URI, @@ -375,7 +511,12 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { return true; } - private async _fetch(url: string, dest: string, token: CancellationToken): Promise { + private async _fetch( + url: string, + dest: string, + token: CancellationToken, + onBytes?: (receivedBytes: number, totalBytes: number | undefined) => void, + ): Promise { // Delegate to IRequestService (corporate proxy, strictSSL, kerberos, // retries, redirect follow). `fs.createWriteStream` (not // `IFileService.writeFile`) so that cancelling a multi-MB download @@ -401,9 +542,31 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { throw new Error(`HTTP ${statusCode} fetching ${url}`); } + // The CDN sends `Content-Length` for these static tarballs, which lets + // us report determinate percentage progress. A missing/garbled header + // degrades gracefully to an indeterminate (byte-count only) report. + const totalBytes = parseContentLength(context.res.headers['content-length']); + await new Promise((resolve, reject) => { const out = fs.createWriteStream(dest); let settled = false; + // Throttle progress so a fast link doesn't fire thousands of + // samples. The first chunk always passes (lastEmit starts at 0) + // and 'end' forces a final sample, so consumers see a start and a + // 100% finish regardless of chunk timing. + let receivedBytes = 0; + let lastEmitTime = 0; + const emitBytes = (force: boolean) => { + if (!onBytes) { + return; + } + const now = Date.now(); + if (!force && now - lastEmitTime < PROGRESS_EMIT_THROTTLE_MS) { + return; + } + lastEmitTime = now; + onBytes(receivedBytes, totalBytes); + }; const settleResolve = () => { if (settled) { return; } settled = true; @@ -428,11 +591,16 @@ export class AgentSdkDownloader implements IAgentSdkDownloader { // resume on 'drain'. out.on('drain', () => context.stream.resume()); context.stream.on('data', chunk => { + receivedBytes += chunk.byteLength; + emitBytes(false); if (!out.write(chunk.buffer)) { context.stream.pause(); } }); - context.stream.on('end', () => out.end()); + context.stream.on('end', () => { + emitBytes(true); + out.end(); + }); context.stream.on('error', settleReject); }); } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index ab51dc9b33d530..6bbc831d3186e3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -20,29 +20,31 @@ import { FileChangeType, FileOperationError, FileOperationResult, FileSystemProv import { InstantiationService } from '../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../instantiation/common/serviceCollection.js'; import { ILogService } from '../../log/common/log.js'; -import { AgentProvider, AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession } from '../common/agentService.js'; +import { AgentProvider, AgentSession, AgentSignal, IAgent, IAgentChatDataChange, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentHostAuthTokenRequest, IAgentMaterializeSessionEvent, IAgentResolveSessionConfigParams, IAgentService, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSpawnChatEvent, AuthenticateParams, AuthenticateResult, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../common/agentService.js'; import { ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../common/sessionDataService.js'; -import { buildDefaultChangesetCatalogue, parseChangesetUri } from '../common/changesetUri.js'; -import { ActionType, ActionEnvelope, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; +import { parseChangesetUri } from '../common/changesetUri.js'; +import { ActionType, ActionEnvelope, AuthRequiredReason, INotification, type ChatAction, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../common/state/sessionActions.js'; import type { CompletionsParams, CompletionsResult, CreateTerminalParams, ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../common/state/protocol/commands.js'; import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js'; import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, ResourceChangeType, ResourceType, ResourceWriteMode, type CreateResourceWatchParams, type CreateResourceWatchResult, type DirectoryEntry, type ResourceCopyParams, type ResourceCopyResult, type ResourceDeleteParams, type ResourceDeleteResult, type ResourceListResult, type ResourceMkdirParams, type ResourceMkdirResult, type ResourceMoveParams, type ResourceMoveResult, type ResourceReadResult, type ResourceResolveParams, type ResourceResolveResult, type ResourceWatchState, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; -import { ChangesSummary, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; +import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKind, type Message, type MessageAttachment, type MessageResourceAttachment } from '../common/state/protocol/state.js'; import type { ChatPendingMessageSetAction, ChatTurnStartedAction } from '../common/state/protocol/actions.js'; -import { ISessionGitHubState, ISessionGitState, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, withSessionGitHubState, withSessionGitState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; +import { ISessionGitHubState, ISessionGitState, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, hostBuildInfoFromProduct, isAhpChatChannel, isSubagentSession, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSubagentSessionUri, readSessionGitState, readSessionWorkspaceless, withSessionGitHubState, withSessionGitState, withSessionWorkspaceless, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn } from '../common/state/sessionState.js'; import { IProductService } from '../../product/common/productService.js'; import { AgentConfigurationService, IAgentConfigurationService } from './agentConfigurationService.js'; -import { AgentHostTerminalManager, type IAgentHostTerminalManager } from './agentHostTerminalManager.js'; +import { AgentHostTerminalManager, IAgentHostTerminalManager } from './agentHostTerminalManager.js'; import { ISessionDbUriFields, parseSessionDbUri } from './shared/fileEditTracker.js'; import { IGitBlobUriFields, parseGitBlobUri } from './gitDiffContent.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostGitService } from '../common/agentHostGitService.js'; import { AgentSideEffects } from './agentSideEffects.js'; +import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentServerToolHost } from './shared/agentServerToolHost.js'; import { serverToolGroups } from './shared/serverToolGroups.js'; import { AgentHostChangesetService } from './agentHostChangesetService.js'; import { AgentHostFileMonitorService, IAgentHostFileMonitorService } from './agentHostFileMonitorService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService } from '../common/agentHostReviewService.js'; import { AgentHostChangesetCoordinator } from './agentHostChangesetCoordinator.js'; import { AgentHostCompletions, IAgentHostCompletions } from './agentHostCompletions.js'; import { AgentHostFileCompletionProvider } from './agentHostFileCompletionProvider.js'; @@ -54,6 +56,7 @@ import { parseMcpChannelUri } from './shared/mcpCustomizationController.js'; import { toAgentClientUri } from '../common/agentClientUri.js'; import { AgentHostChangesetOperationService } from './agentHostChangesetOperationService.js'; import { AgentHostGitStateService } from './agentHostGitStateService.js'; +import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; @@ -66,8 +69,10 @@ import { GIT_DB_METADATA_KEYS, IAgentHostGitStateService, META_GIT_STATE, META_G import { IAgentHostChangesetOperationService } from '../common/agentHostChangesetOperationService.js'; import { AgentHostCommitOperationContribution } from './agentHostCommitOperationProvider.js'; import { AgentHostDiscardChangesOperationContribution } from './agentHostDiscardChangesOperationProvider.js'; +import { AgentHostReviewOperationContribution } from './agentHostReviewOperationProvider.js'; import { AgentHostPullRequestOperationContribution } from './agentHostPullRequestOperationProvider.js'; import { AgentHostSyncOperationContribution } from './agentHostSyncOperationProvider.js'; +import { AgentHostReviewService } from './agentHostReviewService.js'; /** * Grace period before an empty, unsubscribed session is garbage-collected @@ -86,6 +91,40 @@ const SESSION_GC_GRACE_MS = 30_000; */ const RESOURCE_WATCH_GRACE_MS = 30_000; +/** + * Session-database metadata key under which the orchestrator persists its own + * catalog of additional (non-default) peer chats for a session. The value is a + * JSON array of {@link IPersistedPeerChat}. This is the orchestrator's single + * source of truth for peer-chat enumeration on restore. When the key is absent + * the session predates orchestrator-owned persistence and a one-time migration + * drains the agent's legacy `*.chats` (see + * {@link AgentService._migrateLegacyPeerChats}). + */ +const PEER_CHATS_METADATA_KEY = 'peerChats'; + +/** + * Session-database metadata key written on a peer chat's *backing* SDK session + * (see {@link IAgentCreateChatResult.backingSession}). Its presence marks that + * session as an internal peer-chat backing that must never surface as a + * top-level session; the value is the owning peer chat's channel URI string. + * Persisted, so it survives a host restart without re-stamping. + */ +const PEER_CHAT_BACKING_METADATA_KEY = 'peerChatBacking'; + +/** + * A single entry in the orchestrator's persisted peer-chat catalog. `uri` is + * the peer chat's channel URI; `providerData` is the opaque, agent-owned blob + * (see {@link IAgentCreateChatResult.providerData}) handed back to the agent on + * restore — the orchestrator never parses it. `providerData` may be omitted, + * in which case the agent recovers its backing from its own persistence on + * {@link IAgent.materializeChat}. + */ +interface IPersistedPeerChat { + readonly uri: string; + readonly providerData?: string; +} + + /** * The agent service implementation that runs inside the agent-host utility * process. Dispatches to registered {@link IAgent} instances based @@ -115,12 +154,35 @@ export class AgentService extends Disposable implements IAgentService { /** Exposes the configuration service so agent providers can share root config plumbing. */ get configurationService(): IAgentConfigurationService { return this._configurationService; } + /** Exposes the GitHub endpoint service so agent providers share GitHub (Enterprise) resource resolution. */ + get gitHubEndpointService(): IAgentHostGitHubEndpointService { return this._gitHubEndpointService; } + /** Registered providers keyed by their {@link AgentProvider} id. */ private readonly _providers = new Map(); /** Maps each active session URI (toString) to its owning provider. */ private readonly _sessionToProvider = new Map(); + /** + * Sessions that have opted in to bring-up progress, keyed by provider id. + * A session is added here when its `createSession` carries a + * {@link IAgentCreateSessionConfig.progressToken} and removed once it + * materializes (the SDK is now resolved) or is disposed. The SDK download is + * host-level and shared across every session of a provider, so this only + * records *interest*: as long as one or more sessions of a provider is + * registered, {@link emitDownloadProgress} surfaces that provider's download as a single + * progress stream keyed by the download's own identity (the package id), + * rather than one stream per session. + */ + private readonly _downloadProgressInterest = new Map>(); /** Subscriptions to provider progress events; cleared when providers change. */ private readonly _providerSubscriptions = this._register(new DisposableStore()); + /** + * Per-session tail of in-flight persisted peer-chat catalog writes, keyed by + * session URI string. Read-modify-write updates to the {@link + * PEER_CHATS_METADATA_KEY} blob are chained per session so a `createChat`, + * `disposeChat`, and `onDidChangeChatData` racing for the same + * session can't clobber each other's edits. + */ + private readonly _peerChatCatalogWrites = new Map>(); private readonly _authService: AgentHostAuthenticationService; /** Default provider used when no explicit provider is specified. */ private _defaultProvider: AgentProvider | undefined; @@ -140,9 +202,13 @@ export class AgentService extends Disposable implements IAgentService { private readonly _gitStateService: IAgentHostGitStateService; /** Manages PTY-backed terminals for the agent host protocol. */ private readonly _terminalManager: AgentHostTerminalManager; + /** Persists host-injected `/rename` / `!command` turns for restore & fork/truncate. */ + private readonly _localTurns: AgentHostLocalTurns; /** Server-side host for the agent host's server tools. */ private readonly _serverToolHost: AgentServerToolHost; private readonly _configurationService: IAgentConfigurationService; + /** Single source of truth for GitHub (Enterprise) endpoints and protected resources. */ + private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService; /** Pluggable completion item providers (e.g. workspace file completions, agent-specific @-mentions). */ private readonly _completions: IAgentHostCompletions; private _skillCompletionProviderRegistered = false; @@ -253,6 +319,18 @@ export class AgentService extends Disposable implements IAgentService { [ISessionDataService, this._sessionDataService], ); const instantiationService = this._register(new InstantiationService(services, /*strict*/ true)); + this._gitHubEndpointService = this._register(instantiationService.createInstance(AgentHostGitHubEndpointService)); + services.set(IAgentHostGitHubEndpointService, this._gitHubEndpointService); + // A GitHub Enterprise URI change repoints every agent's GitHub resource + // identity to a different authorization server, so the client must obtain a + // token for the new resource. One root-channel `auth/required` covers all + // agents (the URI is host-level config). + this._register(this._gitHubEndpointService.onDidChange(() => { + this._stateManager.emitAuthRequired({ + resource: this._gitHubEndpointService.getCopilotResource().resource, + reason: AuthRequiredReason.Required, + }); + })); const agentHostOctoKitService = instantiationService.createInstance(AgentHostOctoKitService, undefined); services.set(IAgentHostOctoKitService, agentHostOctoKitService); const effectiveCopilotApiService = copilotApiService ?? instantiationService.createInstance(CopilotApiService, undefined); @@ -276,6 +354,10 @@ export class AgentService extends Disposable implements IAgentService { this._changesetOperationService = this._register(instantiationService.createInstance(AgentHostChangesetOperationService, this._stateManager)); services.set(IAgentHostChangesetOperationService, this._changesetOperationService); + // The changes review service is responsible for managing review/unreview state for changeset changes. + const reviewService = this._register(instantiationService.createInstance(AgentHostReviewService, this._stateManager)); + services.set(IAgentHostReviewService, reviewService); + // The changeset service is responsible for computing, publishing, and persisting changesets. this._changesets = this._register(instantiationService.createInstance(AgentHostChangesetService, this._stateManager)); services.set(IAgentHostChangesetService, this._changesets); @@ -290,6 +372,7 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostPullRequestOperationContribution, this._stateManager))); this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostSyncOperationContribution, this._stateManager))); this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostDiscardChangesOperationContribution, this._stateManager))); + this._register(this._changesetOperationService.registerContribution(instantiationService.createInstance(AgentHostReviewOperationContribution, this._stateManager))); this._completions = this._register(instantiationService.createInstance(AgentHostCompletions)); // Built-in generic provider: completes files in the session's workspace folder. @@ -306,15 +389,26 @@ export class AgentService extends Disposable implements IAgentService { ), )); + // Terminal management — the terminal manager listens to the state + // manager's action stream and dispatches PTY output back through it. + // Created before AgentSideEffects and registered in the local scope so + // AgentSideEffects can consume it via DI (for inline `!command` + // execution). + this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager)); + services.set(IAgentHostTerminalManager, this._terminalManager); + + this._localTurns = new AgentHostLocalTurns(this._sessionDataService, this._logService); + this._sideEffects = this._register(instantiationService.createInstance(AgentSideEffects, this._stateManager, { getAgent: session => this._findProviderForSession(session), sessionDataService: this._sessionDataService, + localTurns: this._localTurns, agents: this._agents, copilotApiService: effectiveCopilotApiService, getGitHubCopilotToken: () => { return this.getAuthToken({ - resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, - scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported, + resource: this._gitHubEndpointService.getCopilotResource().resource, + scopes: this._gitHubEndpointService.getCopilotResource().scopes_supported, }); }, onTurnComplete: async session => { @@ -327,10 +421,6 @@ export class AgentService extends Disposable implements IAgentService { }, })); - // Terminal management — the terminal manager listens to the state - // manager's action stream and dispatches PTY output back through it. - this._terminalManager = this._register(instantiationService.createInstance(AgentHostTerminalManager, this._stateManager)); - // Server-side tools, executed in-process against each session's own // state. Tool groups are contributed here at startup (feedback today) and // handed to providers that support them during registration (see @@ -347,6 +437,14 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`Registering agent provider: ${provider.id}`); this._providers.set(provider.id, provider); provider.setServerToolHost?.(this._serverToolHost); + // Deterministic subagent membership ordering: apply a spawned subagent's + // catalog membership (via the spawn-channel handlers) BEFORE + // AgentSideEffects — registered next — handles the same signal and starts + // a turn on the subagent chat, which requires that chat to already exist. + // Registering this listener ahead of the side-effects listener makes the + // ordering independent of when the agent registers its own subagent->spawn + // bridge; addChat/removeChat are idempotent, so the overlap is safe. + this._providerSubscriptions.add(provider.onDidSessionProgress(signal => this._sequenceSpawnedChat(signal))); this._providerSubscriptions.add(this._sideEffects.registerProgressListener(provider)); if (provider.onDidMaterializeSession) { this._providerSubscriptions.add(provider.onDidMaterializeSession(e => this._onDidMaterializeSession(e))); @@ -354,6 +452,12 @@ export class AgentService extends Disposable implements IAgentService { if (provider.onMcpNotification) { this._providerSubscriptions.add(provider.onMcpNotification(e => this._onMcpNotification.fire(e))); } + if (provider.onDidChangeChatData) { + this._providerSubscriptions.add(provider.onDidChangeChatData(e => this._onChatDataChanged(e))); + } + if (provider.onDidSpawnChat) { + this._providerSubscriptions.add(provider.onDidSpawnChat(e => this._onChatSpawned(e))); + } this._registerSkillCompletionProvider(); if (!this._defaultProvider) { this._defaultProvider = provider.id; @@ -415,7 +519,7 @@ export class AgentService extends Disposable implements IAgentService { const flat = results.flat(); // Overlay persisted custom titles from per-session databases. - const result = await Promise.all(flat.map(async s => { + const overlaid = await Promise.all(flat.map(async (s): Promise => { try { const ref = await this._sessionDataService.tryOpenDatabase(s.session); if (!ref) { @@ -431,9 +535,17 @@ export class AgentService extends Disposable implements IAgentService { const sessionStr = s.session.toString(); const changesetKeys = this._changesetCoordinator.getListMetadataKeys(sessionStr); const metadataKeys: Record = changesetKeys - ? { customTitle: true, isRead: true, isArchived: true, isDone: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } - : { customTitle: true, isRead: true, isArchived: true, isDone: true, ...GIT_DB_METADATA_KEYS }; + ? { customTitle: true, isRead: true, isArchived: true, isDone: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, ...GIT_DB_METADATA_KEYS, ...changesetKeys } + : { customTitle: true, isRead: true, isArchived: true, isDone: true, [AH_META_WORKSPACELESS_DB_KEY]: true, [PEER_CHAT_BACKING_METADATA_KEY]: true, ...GIT_DB_METADATA_KEYS }; const m = await ref.object.getMetadataObject(metadataKeys); + // This session is an internal peer-chat backing (e.g. a + // Claude peer chat's SDK session, enumerated by the agent's + // own `listSessions`). Drop it so it never leaks as a + // standalone top-level session — mirrors the subagent filter + // on the state-manager overlay path below. + if (m[PEER_CHAT_BACKING_METADATA_KEY]) { + return undefined; + } let updated = s; if (m.customTitle) { updated = { ...updated, summary: m.customTitle }; @@ -463,6 +575,10 @@ export class AgentService extends Disposable implements IAgentService { } } + if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + updated = { ...updated, _meta: withSessionWorkspaceless(updated._meta, m[AH_META_WORKSPACELESS_DB_KEY] === 'true') }; + } + return this._changesetCoordinator.decorateListEntry(updated, m as Record); } finally { ref.dispose(); @@ -472,6 +588,7 @@ export class AgentService extends Disposable implements IAgentService { } return s; })); + const result = overlaid.filter((s): s is IAgentSessionMetadata => s !== undefined); // Overlay live session state from the state manager. // For the title, prefer the state manager's value when it is @@ -500,6 +617,13 @@ export class AgentService extends Disposable implements IAgentService { summary: liveSummary.title || s.summary, status: liveSummary.status, activity: liveSummary.activity, + modifiedTime: Date.parse(liveSummary.modifiedAt), + project: liveSummary.project + ? { uri: URI.parse(liveSummary.project.uri), displayName: liveSummary.project.displayName } + : s.project, + workingDirectory: typeof liveSummary.workingDirectory === 'string' + ? URI.parse(liveSummary.workingDirectory) + : s.workingDirectory, changes: liveSummary.changes ?? s.changes, changesets: this._stateManager.getSessionState(s.session.toString())?.changesets ?? s.changesets, ...(_meta !== undefined ? { _meta } : {}), @@ -578,13 +702,28 @@ export class AgentService extends Disposable implements IAgentService { for (const t of sourceTurns) { turnIdMapping.set(t.id, generateUuid()); } + // The SDK fork boundary must be a concrete (SDK-backed) turn. + // When the client forked at a host-injected local turn + // (`/rename` / `!command`), redirect the agent to the preceding + // concrete turn while still seeding the local turns up to the + // fork point into the new session's protocol state below. + const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(buildDefaultChatUri(config.fork.session).toString(), config.fork.turnId); config = { ...config, - fork: { ...config.fork, turnIdMapping }, + fork: { ...config.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) }, }; } } + // When importing a conversation, assign fresh UUID turn ids up front so + // the provider seeds an event log whose ids match the protocol turns we + // seed below — keeping edit / fork / truncate addressable at the SDK + // boundary. + if (config?.importConversation) { + const importedTurns = config.importConversation.turns.map(t => ({ ...t, id: generateUuid() })); + config = { ...config, importConversation: { ...config.importConversation, turns: importedTurns } }; + } + // Ensure the command auto-approver is ready before any session events // can arrive. This makes shell command auto-approval fully synchronous. // Safe to run in parallel with createSession since no events flow until @@ -592,7 +731,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: initializing auto-approver and creating session...`); const [, created] = await Promise.all([ this._sideEffects.initialize(), - provider.createSession(config), + this._createSession(provider, config), ]); const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); @@ -606,6 +745,19 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] createSession: provider=${provider.id} model=${config?.model?.id ?? '(default)'}`); this._sessionToProvider.set(session.toString(), provider.id); + // Record this session's opt-in so a cold SDK download triggered at + // materialization (first message) is surfaced as progress. The download + // is provider-global, so we only track interest here; emission is keyed + // by the download's own identity, not this token. Cleared on + // materialize/dispose. + if (config?.progressToken) { + let sessions = this._downloadProgressInterest.get(provider.id); + if (!sessions) { + sessions = new Set(); + this._downloadProgressInterest.set(provider.id, sessions); + } + sessions.add(session.toString()); + } this._logService.trace(`[AgentService] createSession returned: ${session.toString()}`); // Resolve config and seed the initial customization set in parallel so @@ -633,10 +785,18 @@ export class AgentService extends Disposable implements IAgentService { // the source session's turns so the client sees the forked history. if (config?.fork) { const sourceState = this._stateManager.getSessionState(config.fork.session.toString()); + const sourceChatUri = buildDefaultChatUri(config.fork.session).toString(); + const newChatUri = buildDefaultChatUri(session).toString(); let sourceTurns: Turn[] = []; if (sourceState && config.fork.turnIdMapping) { - sourceTurns = sourceState.turns.slice(0, config.fork.turnIndex + 1) - .map(t => ({ ...t, id: config!.fork!.turnIdMapping!.get(t.id) ?? generateUuid() })); + const originalSlice = sourceState.turns.slice(0, config.fork.turnIndex + 1); + const mapping = config.fork.turnIdMapping; + sourceTurns = originalSlice.map(t => ({ ...t, id: mapping.get(t.id) ?? generateUuid() })); + // Re-persist forked local turns (`/rename`, `!command`) under the + // new session's default chat. `record` (keyed by turn id) + // overwrites any rows a DB copy carried with the SOURCE chat URI, + // and seeds the in-memory index for same-process fork/truncate. + this._persistForkedLocalTurns(session.toString(), sourceChatUri, newChatUri, originalSlice, sourceTurns, mapping); } // Prefix the forked session's title so consumers (sidebar, chat @@ -658,12 +818,28 @@ export class AgentService extends Disposable implements IAgentService { } // Refine the forked session's placeholder `Forked: …` title into one - // derived from the inherited conversation. Forks seed pre-existing + // derived from the inherited chat. Forks seed pre-existing // turns, so the normal first-message/first-turn title generation // never fires for them — this is the fork-time equivalent. if (sourceTurns.length > 0) { this._sideEffects.generateForkedTitle(summary.resource, undefined, sourceTurns, forkedTitle, sourceTitle); } + } else if (config?.importConversation) { + // An imported conversation arrives with pre-existing turns (assigned + // fresh UUID ids above). Seed them into the new session's protocol + // state so the client renders the imported history immediately; the + // provider has already seeded the matching SDK event log so those + // turns are editable / forkable / truncatable. + const importedTurns = [...config.importConversation.turns]; + const importedTitle = this._buildImportedTitle(importedTurns); + const summary = this._buildInitialSummary(provider, session, config, created, importedTitle); + const state = this._stateManager.createSession(summary); + state.config = sessionConfig; + this._stateManager.seedDefaultChatTurns(summary.resource, importedTurns); + state.activeClients = config.activeClient ? [config.activeClient] : []; + if (initialCustomizations && initialCustomizations.length > 0) { + state.customizations = [...initialCustomizations]; + } } else { // Provisional sessions defer the `sessionAdded` notification and // the `SessionReady` lifecycle transition until the agent fires @@ -688,22 +864,14 @@ export class AgentService extends Disposable implements IAgentService { this._persistConfigValues(session, sessionConfig.values); } - // Initial changeset state is established as part of session creation, - // never deferred to materialization. Two halves: (1) the catalogue - // is seeded on `state.changesets` via `setSessionChangesets` right - // after `createSession`; (2) the backing per-changeset states are - // registered by `_changesetCoordinator.onSessionCreated` here. Both - // run before `SessionReady` is dispatched. Any future change must - // keep both halves at create time so client subscriptions resolve - // `_attachGitState` strips them once the git probe confirms the - // resolved working directory is not a git repo. Pinned by item-2 - // regression tests in `agentService.test.ts`. - const changesets = buildDefaultChangesetCatalogue(session.toString()); - this._stateManager.setSessionChangesets(session.toString(), changesets); - this._changesetCoordinator.onSessionCreated(session.toString()); if (!created.provisional) { + // Persist the AH-owned workspace-less marker now that the session DB + // exists, from the value `_buildInitialSummary` inferred. Provisional + // sessions defer this to `_onDidMaterializeSession`. + this._persistWorkspaceless(session, readSessionWorkspaceless(this._stateManager.getSessionSummary(session.toString())?._meta)); + // `SessionReady` transitions the session lifecycle from // `Creating` to `Ready`. For provisional sessions we defer // this to {@link _onDidMaterializeSession} so subscribers @@ -725,13 +893,13 @@ export class AgentService extends Disposable implements IAgentService { if (!provider) { throw new Error(`[AgentService] createChat: no provider for session ${sessionKey}`); } - if (!provider.createChat) { + if (!this._supportsChats(provider)) { throw new Error(`[AgentService] createChat: provider ${provider.id} does not support multiple chats`); } // When forking, resolve the source chat's turns up to the fork point and // mint fresh turn IDs for the new chat. The agent uses the mapping to - // remap per-turn data in the forked conversation; the seeded turns make + // remap per-turn data in the forked chat; the seeded turns make // the new chat surface the forked history immediately. let forkedTurns: Turn[] | undefined; let forkedTitle: string | undefined; @@ -739,14 +907,18 @@ export class AgentService extends Disposable implements IAgentService { let createOptions = options; if (options?.fork) { const sourceKey = options.fork.source.toString(); - const sourceState = this._stateManager.getChatState(sourceKey) - ?? this._stateManager.getDefaultChatState(sourceKey); + const peerState = this._stateManager.getChatState(sourceKey); + const sourceState = peerState ?? this._stateManager.getDefaultChatState(sourceKey); + // Canonical chat URI the source's local turns are keyed by: when the + // source was found as a peer chat it is `sourceKey`; otherwise it was + // addressed by session URI and its default chat URI is canonical. + const sourceChatUri = peerState ? sourceKey : buildDefaultChatUri(sourceKey); const sourceTurns = sourceState?.turns ?? []; const forkIndex = sourceTurns.findIndex(t => t.id === options.fork!.turnId); if (forkIndex < 0) { // The fork point is unknown, so a fork is indistinguishable from a // fresh chat. Drop the fork to avoid the provider inheriting the - // whole backend conversation while the UI is seeded with no turns. + // whole backend chat while the UI is seeded with no turns. createOptions = { ...options, fork: undefined }; } else { const slice = sourceTurns.slice(0, forkIndex + 1); @@ -756,26 +928,52 @@ export class AgentService extends Disposable implements IAgentService { } forkedTurns = slice.map(t => ({ ...t, id: turnIdMapping.get(t.id) ?? generateUuid() })); + // Carry forked host-injected local turns (`/rename`, `!command`) + // into the new chat so they survive reload and anchor future + // fork/truncate. + this._persistForkedLocalTurns(sessionKey, sourceChatUri, chat.toString(), slice, forkedTurns, turnIdMapping); + const forkedTitlePrefix = localize('agentHost.forkedTitlePrefix', "Forked: "); forkedSourceTitle = sourceState?.title || this._stateManager.getSessionState(sessionKey)?.title; forkedTitle = forkedSourceTitle ? (forkedSourceTitle.startsWith(forkedTitlePrefix) ? forkedSourceTitle : `${forkedTitlePrefix}${forkedSourceTitle}`) : localize('agentHost.forkedChatFallback', "Forked Chat"); - createOptions = { ...options, fork: { ...options.fork, turnIdMapping } }; + // The SDK fork boundary must be a concrete (SDK-backed) turn. When + // the client forked at a host-injected local turn, redirect the + // agent to the preceding concrete turn (the local turns are still + // seeded into the new chat's protocol state above). + const concreteForkTurnId = this._localTurns.resolveConcreteTurnId(sourceChatUri, options.fork.turnId); + createOptions = { ...options, fork: { ...options.fork, turnIdMapping, ...(concreteForkTurnId !== undefined ? { turnId: concreteForkTurnId } : {}) } }; } } - // Spin up the backing conversation in the harness first, then register + // Spin up the backing chat in the harness first, then register // the chat in the catalog so a `session/chatAdded` only reaches - // subscribers once the chat can actually receive messages. - await provider.createChat(session, chat, createOptions); + // subscribers once the chat can actually receive messages. The agent + // returns the opaque `providerData` blob the orchestrator persists for + // restore (it never parses it); single-chat-only agents return `void`. + const createResult = await this._createChat(provider, chat, createOptions); + const providerData = createResult?.providerData; this._stateManager.addChat(sessionKey, chat.toString(), { ...(forkedTitle !== undefined ? { title: forkedTitle } : options?.title !== undefined ? { title: options.title } : {}), ...(forkedTurns !== undefined ? { turns: forkedTurns } : {}), + ...(providerData !== undefined ? { providerData } : {}), }); + // Persist the new peer chat into the orchestrator-owned catalog so it is + // re-enumerated and re-materialized on the next restore without asking + // the agent. + void this._persistPeerChat(session, chat, providerData); + + // When the agent backs this peer chat with its own separately-enumerable + // SDK session (e.g. Claude), mark that session so it is filtered out of + // the top-level session list instead of leaking as a standalone session. + if (createResult?.backingSession) { + this._markPeerChatBacking(createResult.backingSession, chat); + } + // Refine the forked chat's placeholder `Forked: …` title into one - // derived from the inherited conversation. Forks seed pre-existing + // derived from the inherited chat. Forks seed pre-existing // turns, so the normal first-message/first-turn title generation never // fires for them — this is the fork-time equivalent. if (forkedTurns && forkedTurns.length > 0 && forkedTitle !== undefined) { @@ -787,9 +985,143 @@ export class AgentService extends Disposable implements IAgentService { const sessionKey = session.toString(); const provider = this._findProviderForSession(session); this._stateManager.removeChat(sessionKey, chat.toString()); - await provider?.disposeChat?.(session, chat); + // Drop the chat from the orchestrator-owned catalog so it isn't + // re-materialized on the next restore. + void this._removePersistedPeerChat(session, chat); + if (provider) { + await this._disposeChat(provider, chat); + } + } + + // ---- Chat dispatch adapter --------------------------------------------- + // + // The orchestrator owns the feature-level `(session, chat)` → + // `(agent, session, chat)` mapping. It dispatches against an agent's + // chat-addressed surface ({@link IAgent.chats}) and session lifecycle + // ({@link IAgent.createSession}/{@link IAgent.disposeSession}). + + /** Whether `provider` can host additional (peer) chats. */ + private _supportsChats(provider: IAgent): boolean { + return !!provider.chats; + } + + private _createSession(provider: IAgent, config: IAgentCreateSessionConfig | undefined): Promise { + return provider.createSession(config); } + private async _disposeSession(provider: IAgent, session: URI): Promise { + await provider.disposeSession(session); + } + + /** + * Reconstruct the turns for a chat. `chat` is the concrete chat channel URI, + * except for legacy restore paths that still address subagent sessions. + */ + private _getChatMessages(provider: IAgent, chat: URI): Promise { + return provider.chats.getMessages(chat); + } + + /** + * Merges persisted host-injected local turns (`/rename`, `!command`) for + * `chatUri` back into that chat's SDK-derived `turns`, positioned after + * their anchor turn (the concrete turn they were recorded after). Locals + * anchored before any real turn are prepended; locals whose anchor is absent + * from the SDK turns (e.g. truncated away) are dropped. Also seeds the + * in-memory local-turn index so fork/truncate resolve correctly before the + * next reload. + */ + private async _interleaveLocalTurns(sessionStr: string, chatUri: string, turns: readonly Turn[]): Promise { + const records = await this._localTurns.loadForChat(sessionStr, chatUri); + if (records.length === 0) { + return [...turns]; + } + const knownIds = new Set(turns.map(t => t.id)); + const byAnchor = new Map(); + const head: Turn[] = []; + for (const record of records) { + let turn: Turn; + try { + turn = JSON.parse(record.payload) as Turn; + } catch { + continue; + } + if (record.anchorTurnId === undefined) { + head.push(turn); + } else if (knownIds.has(record.anchorTurnId)) { + const list = byAnchor.get(record.anchorTurnId) ?? []; + list.push(turn); + byAnchor.set(record.anchorTurnId, list); + } + // else: orphaned (anchor truncated away) → drop. + } + const merged: Turn[] = [...head]; + for (const turn of turns) { + merged.push(turn); + const locals = byAnchor.get(turn.id); + if (locals) { + merged.push(...locals); + } + } + return merged; + } + + /** + * Re-persists forked host-injected local turns (`/rename`, `!command`) into + * a newly forked chat so they survive reload and anchor future + * fork/truncate. `originalSlice[i]` and `forkedTurns[i]` are the source turn + * and its remapped copy (same length, 1:1); `mapping` is the old→new turn id + * map used to remap each local turn's anchor. `persistSession` owns the + * destination database; `sourceChatUri` / `newChatUri` key the source and + * destination local-turn indexes. + * + * Shared by the {@link createSession} (default-chat) and {@link createChat} + * (peer-chat) fork paths. + */ + private _persistForkedLocalTurns(persistSession: string, sourceChatUri: string, newChatUri: string, originalSlice: readonly Turn[], forkedTurns: readonly Turn[], mapping: ReadonlyMap): void { + for (let i = 0; i < originalSlice.length; i++) { + const original = originalSlice[i]; + if (!this._localTurns.isLocal(sourceChatUri, original.id)) { + continue; + } + const originalAnchor = this._localTurns.resolveConcreteTurnId(sourceChatUri, original.id); + const newAnchor = originalAnchor !== undefined ? mapping.get(originalAnchor) : undefined; + this._localTurns.record(persistSession, newChatUri, forkedTurns[i], newAnchor); + } + } + + /** + * Create (or fork) the peer chat `chat` within `session`. `chat` is + * always a peer URI here (the default chat is created implicitly with + * the session), so no default-chat resolution is needed. + */ + private _createChat(provider: IAgent, chat: URI, options: IAgentCreateChatOptions | undefined): Promise { + const convOptions: IAgentCreateChatOptions | undefined = options && (options.title !== undefined || options.model !== undefined) + ? { ...(options.title !== undefined ? { title: options.title } : {}), ...(options.model !== undefined ? { model: options.model } : {}) } + : undefined; + return options?.fork + ? provider.chats.fork(chat, options.fork, convOptions) + : provider.chats.createChat(chat, convOptions); + } + + private async _disposeChat(provider: IAgent, chat: URI): Promise { + await provider.chats.disposeChat(chat); + } + + /** + * Derives a title for an imported session from its first user turn (imports + * seed pre-existing turns, so the normal first-message title generation + * never fires). Falls back to a generic label for an empty import. + */ + private _buildImportedTitle(turns: readonly Turn[]): string { + const importedPrefix = localize('agentHost.importedTitlePrefix', "Imported: "); + const firstText = turns.find(t => t.message?.text?.trim())?.message.text.trim(); + if (!firstText) { + return localize('agentHost.importedSessionFallback', "Imported Session"); + } + const MAX = 60; + const clipped = firstText.length > MAX ? `${firstText.slice(0, MAX)}...` : firstText; + return `${importedPrefix}${clipped}`; + } private _buildInitialSummary(provider: IAgent, session: URI, config: IAgentCreateSessionConfig | undefined, created: { project?: { uri: URI; displayName: string }; workingDirectory?: URI }, title: string): SessionSummary { const now = new Date().toISOString(); @@ -802,6 +1134,10 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: now, ...(created.project ? { project: { uri: created.project.uri.toString(), displayName: created.project.displayName } } : {}), workingDirectory: (created.workingDirectory ?? config?.workingDirectory)?.toString(), + // Workspace-less is inferred at create from an absent input + // `workingDirectory` (the host assigns a scratch cwd, so it can't be + // re-inferred later) and tagged on the generic `_meta` bag. + ...(config && !config.fork && !config.workingDirectory ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), }; } @@ -819,6 +1155,9 @@ export class AgentService extends Disposable implements IAgentService { */ private _onDidMaterializeSession(e: IAgentMaterializeSessionEvent): void { const sessionKey = e.session.toString(); + // The session is now materialized — its SDK is resolved (any cold + // download already finished), so no further progress is expected for it. + this._clearDownloadProgressInterest(sessionKey); const state = this._stateManager.getSessionState(sessionKey); if (!state) { this._logService.warn(`[AgentService] onDidMaterializeSession for unknown session: ${sessionKey}`); @@ -839,6 +1178,9 @@ export class AgentService extends Disposable implements IAgentService { if (configValues && Object.keys(configValues).length > 0) { this._persistConfigValues(e.session, configValues); } + // Persist the AH-owned workspace-less marker now that the session has a + // real on-disk database (deferred from create for provisional sessions). + this._persistWorkspaceless(e.session, readSessionWorkspaceless(summary._meta)); // `markSessionPersisted` writes the summary into state and fires // the deferred `SessionAdded` notification atomically so subscribers // see consistent state through both paths. @@ -848,16 +1190,75 @@ export class AgentService extends Disposable implements IAgentService { // Attach git state for the working directory (if present) void this._gitStateService.refreshSessionGitState(e.session.toString(), e.workingDirectory); - // Initialize the session's changesets from the catalogue - const changesets = buildDefaultChangesetCatalogue(sessionKey); - this._stateManager.setSessionChangesets(sessionKey, changesets); - // If a client subscribed to this session's uncommitted changeset // before the working directory was known, the coordinator drains // the deferred refresh now that the working directory is set. this._changesetCoordinator.onSessionMaterialized(sessionKey); } + /** Drop a session's download-progress opt-in, if any. */ + private _clearDownloadProgressInterest(sessionKey: string): void { + for (const [provider, sessions] of this._downloadProgressInterest) { + if (sessions.delete(sessionKey) && sessions.size === 0) { + this._downloadProgressInterest.delete(provider); + } + } + } + + /** + * Surface a host-level SDK download as client progress. The downloader fires + * process-global frames keyed by package id (which equals the provider id); + * because the download is shared across every session of that provider, we + * emit a SINGLE `progress` stream keyed by that package id — not one per + * session — so the client shows exactly one indicator no matter how many + * sessions of the provider are awaiting it. Frames are only emitted while at + * least one session has opted in (supplied a + * {@link IAgentCreateSessionConfig.progressToken} on `createSession`). A + * terminal frame reports `total === progress` (using `receivedBytes` when the + * size was never known) so the client dismisses the indicator deterministically. + * + * `displayName` is the provider's brand noun (e.g. `Claude`). It is woven + * into the notification's localized, human-readable `message` (e.g. + * "Downloading Claude agent") so a generic client can render the indicator + * verbatim without knowing the resource is an agent SDK. No trailing + * ellipsis: clients render progress as ": <percent>", so an ellipsis + * would read as an unusual "…:" (see #324455). + */ + emitDownloadProgress(packageId: string, displayName: string, receivedBytes: number, totalBytes: number | undefined, terminal: boolean): void { + const sessions = this._downloadProgressInterest.get(packageId); + if (!sessions || sessions.size === 0) { + return; + } + // On a terminal frame force `progress === total` so clients treat the + // operation as complete (covers both the determinate case and the + // indeterminate one where `totalBytes` was never known, plus failures — + // the real error surfaces via the session-failure path). + const total = terminal ? receivedBytes : totalBytes; + const message = localize('agentHost.download.agentSdkTitle', "Downloading {0} agent", displayName); + // `progressToken` is the download's own stable identity (the package id), + // shared by every session of the provider, so the client coalesces all + // frames into one indicator and dismisses it on the terminal frame. + this._stateManager.emitProgress({ progressToken: packageId, progress: receivedBytes, total, message }); + if (terminal) { + this._downloadProgressInterest.delete(packageId); + } + } + + private _persistWorkspaceless(session: URI, workspaceless: boolean): void { + let ref; + try { + ref = this._sessionDataService.openDatabase(session); + } catch (err) { + this._logService.warn(`[AgentService] Failed to open session database to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); + return; + } + ref.object.setMetadata(AH_META_WORKSPACELESS_DB_KEY, workspaceless ? 'true' : 'false').catch(err => { + this._logService.warn(`[AgentService] Failed to persist workspaceless for ${session.toString()}: ${toErrorMessage(err)}`); + }).finally(() => { + ref.dispose(); + }); + } + private _persistConfigValues(session: URI, values: Record<string, unknown>): void { let ref; try { @@ -920,8 +1321,9 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); const provider = this._findProviderForSession(session); if (provider) { - await provider.disposeSession(session); + await this._disposeSession(provider, session); this._sessionToProvider.delete(session.toString()); + this._clearDownloadProgressInterest(session.toString()); } this._changesetCoordinator.onSessionDisposed(session.toString()); this._sideEffects.cancelSessionTitleGeneration(session.toString()); @@ -1143,14 +1545,23 @@ export class AgentService extends Disposable implements IAgentService { } /** - * If `resource` names an idle session and no client is still subscribed to - * it (or, for a subagent URI, no sibling subagent under the same parent is - * still subscribed), drop its cached state from the state manager. Subagent - * URIs evict the parent session entry; the parent owns the materialized - * turn tree that backs every subagent view. The next subscribe will - * rehydrate the session via {@link restoreSession}. + * Eviction is currently disabled: this is a no-op that keeps cached session + * state in memory. When re-enabled, it should drop cached state for an idle + * session that has no remaining subscribers (walking up subagent ancestry + * and evicting the root session entry), allowing the session to be + * rehydrated later via {@link restoreSession}. */ private _maybeEvictIdleSession(resource: URI): void { + // Idle-session eviction is disabled while we investigate issues where + // cached session state is dropped while clients still expect it to be + // observable. Keeping the cached state in memory prevents spurious + // re-restores and state loss. + // TODO: re-enable eviction or add an LRU cap to avoid unbounded memory + // growth in long-lived agent-host processes. + void resource; + return; + + /* const key = resource.toString(); if (this._resourceSubscribers.has(resource)) { return; @@ -1187,6 +1598,7 @@ export class AgentService extends Disposable implements IAgentService { this._stateManager.removeSession(cachedKey); } this._stateManager.removeSession(evictionTargetKey); + */ } // Returns true when a changeset is safe to drop from the in-memory cache. @@ -1247,7 +1659,7 @@ export class AgentService extends Disposable implements IAgentService { dispatchAction(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction, clientId: string, clientSeq: number): void { this._logService.trace(`[AgentService] dispatchAction: type=${action.type}, clientId=${clientId}, clientSeq=${clientSeq}`, action); - // Clients dispatch conversation (chat) actions against a chat channel + // Clients dispatch chat (chat) actions against a chat channel // URI. Keep that chat channel for the optimistic state apply and for // per-chat routing in side effects, while deriving the owning session // URI for all session-scoped work (attachment snapshotting, agent @@ -1282,7 +1694,7 @@ export class AgentService extends Disposable implements IAgentService { if (action.type === ActionType.RootConfigChanged) { this._configurationService.persistRootConfig(); } - this._sideEffects.handleAction(channel, action); + this._sideEffects.handleAction(channel, action, clientId); } private _needsAsyncRewrite(channel: string, action: SessionAction | ChatAction | TerminalAction | ClientAnnotationsAction | IRootConfigChangedAction): action is ChatTurnStartedAction | ChatPendingMessageSetAction { @@ -1498,9 +1910,10 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found on backend: ${sessionStr}`); } + const defaultChatUri = URI.parse(buildDefaultChatUri(sessionStr)); let turns: readonly Turn[]; try { - turns = await agent.getSessionMessages(session); + turns = await this._getChatMessages(agent, defaultChatUri); } catch (err) { if (err instanceof ProtocolError) { throw err; @@ -1530,6 +1943,7 @@ export class AgentService extends Disposable implements IAgentService { isArchived: true, isDone: true, configValues: true, + [AH_META_WORKSPACELESS_DB_KEY]: true, ...GIT_DB_METADATA_KEYS, ...CHANGESET_DB_METADATA_KEYS, }); @@ -1577,6 +1991,10 @@ export class AgentService extends Disposable implements IAgentService { } } + if (m[AH_META_WORKSPACELESS_DB_KEY] !== undefined) { + sessionMetadata = withSessionWorkspaceless(sessionMetadata, m[AH_META_WORKSPACELESS_DB_KEY] === 'true'); + } + if (m.configValues) { try { persistedConfigValues = JSON.parse(m.configValues); @@ -1612,11 +2030,15 @@ export class AgentService extends Disposable implements IAgentService { ...(meta.project ? { project: { uri: meta.project.uri.toString(), displayName: meta.project.displayName } } : {}), changes: meta.changes ?? changes, workingDirectory: meta.workingDirectory?.toString(), - _meta: sessionMetadata + _meta: (sessionMetadata || meta._meta) ? { ...(meta._meta ?? {}), ...(sessionMetadata ?? {}) } : undefined, }; - const defaultDraft = await this._getChatDraft(session, URI.parse(buildDefaultChatUri(sessionStr))); - this._stateManager.restoreSession(summary, [...turns], { draft: defaultDraft }); + const [defaultDraft, defaultChatTitle] = await Promise.all([ + this._getChatDraft(session, defaultChatUri), + this._readPersistedChatTitle(session, defaultChatUri), + ]); + const mergedTurns = await this._interleaveLocalTurns(sessionStr, defaultChatUri.toString(), turns); + this._stateManager.restoreSession(summary, mergedTurns, { draft: defaultDraft, defaultChatTitle }); const promises: Promise<unknown>[] = []; // Eagerly register subagent child sessions discovered in the event log @@ -1630,7 +2052,7 @@ export class AgentService extends Disposable implements IAgentService { try { const children = await agent.getSubagentSessions(session); for (const child of children) { - this._registerRestoredSubagent(child, summary); + this._registerRestoredSubagent(child, summary, sessionStr); } } catch (err) { this._logService.warn(`[AgentService] restoreSession failed to eagerly register subagents session=${sessionStr}`, err); @@ -1643,9 +2065,6 @@ export class AgentService extends Disposable implements IAgentService { // persisted title so they reappear after a process restart. promises.push(this._restorePeerChats(agent, session)); - const changesets = buildDefaultChangesetCatalogue(sessionStr); - this._stateManager.setSessionChangesets(sessionStr, changesets); - // Register the static changeset URIs and reseed them from any // persisted file lists in the batched metadata read. The catalogue // itself is seeded on `state.changesets` synchronously by the @@ -1702,30 +2121,94 @@ export class AgentService extends Disposable implements IAgentService { } /** - * Restores the additional (non-default) peer chats persisted for a session. - * For each chat returned by the provider, loads its history and persisted - * title and re-registers it in the state manager so it reappears in the - * session's chat catalog after a process restart. Best-effort: a chat whose - * history fails to load is restored with no turns rather than dropped. + * Restores the additional (non-default) peer chats for a session. + * + * Enumeration is driven by the orchestrator's OWN persisted catalog (the + * {@link PEER_CHATS_METADATA_KEY} blob). For each catalog entry the agent's + * in-memory backing is re-attached via + * {@link IAgent.materializeChat} (handing back the opaque + * `providerData` blob) BEFORE its history is read, then the chat is + * re-registered in the state manager with its persisted title and draft so + * it reappears after a process restart. Best-effort: a chat whose history + * fails to load is restored with no turns rather than dropped. + * + * When the orchestrator catalog is absent ({@link _readPersistedPeerChatCatalog} + * returns `undefined`) the session predates orchestrator-owned persistence: + * a one-time migration ({@link _migrateLegacyPeerChats}) drains the agent's + * legacy `*.chats` enumeration into the catalog so it is never consulted + * again. */ private async _restorePeerChats(agent: IAgent, session: URI): Promise<void> { - if (!agent.getChats) { - return; - } - let chats: readonly URI[]; - try { - chats = await agent.getChats(session); - } catch (err) { - this._logService.warn(`[AgentService] Failed to enumerate peer chats for ${session.toString()}: ${toErrorMessage(err)}`); + const persisted = await this._readPersistedPeerChatCatalog(session); + if (persisted !== undefined) { + // The orchestrator owns the catalog: enumerate from it. + await this._restorePeerChatsFromCatalog(agent, session, persisted); return; } - if (chats.length === 0) { + // No orchestrator catalog yet: one-time migration from legacy `*.chats`. + await this._migrateLegacyPeerChats(agent, session); + } + + /** + * One-time migration for sessions persisted before the orchestrator owned + * the peer-chat catalog: enumerate the agent's legacy `*.chats` + * ({@link IAgent.listLegacyChats}), restore them via the same path as the + * new catalog, then write the orchestrator {@link PEER_CHATS_METADATA_KEY} + * blob so subsequent restores read the new catalog and never consult the + * legacy read again. No-op when the agent has no legacy enumeration or none + * is persisted. + */ + private async _migrateLegacyPeerChats(agent: IAgent, session: URI): Promise<void> { + const legacy = await agent.listLegacyChats?.(session); + if (!legacy || legacy.length === 0) { + // Write an empty catalog sentinel so `_readPersistedPeerChatCatalog` + // returns `[]` on subsequent restores and this migration never re-runs. + await this._enqueuePeerChatCatalogWrite(session, () => []); return; } - await Promise.all(chats.map(async (chatUri) => { + const entries: IPersistedPeerChat[] = legacy.map(chat => ({ + uri: chat.uri.toString(), + ...(chat.providerData !== undefined ? { providerData: chat.providerData } : {}), + })); + await this._restorePeerChatsFromCatalog(agent, session, entries); + // Single atomic write: the key is absent before and complete after, so no + // partial catalog can survive a crash mid-migration (which would make + // `_readPersistedPeerChatCatalog` return a proper subset and permanently + // skip re-migration). The callback takes no parameter so `entries` here is + // the full migrated set, not the (absent) current catalog. + await this._enqueuePeerChatCatalogWrite(session, () => [...entries]); + } + + /** + * Restores a set of peer chats from an enumerated catalog. Loads each + * chat's history in parallel (after re-attaching its backing) but restores + * them in catalog order, so the catalog never reorders by which chat's + * history/title happened to resolve first. + */ + private async _restorePeerChatsFromCatalog(agent: IAgent, session: URI, entries: readonly IPersistedPeerChat[]): Promise<void> { + const restored = await Promise.all(entries.map(async (entry) => { + let chatUri: URI; + try { + chatUri = URI.parse(entry.uri); + } catch (err) { + this._logService.warn(`[AgentService] Skipping malformed persisted peer chat URI '${entry.uri}': ${toErrorMessage(err)}`); + return undefined; + } + // Re-attach the agent's in-memory backing for the chat BEFORE + // reading its history, so `getSessionMessages` can resolve the + // chat. Best-effort: a corrupt/unknown blob must not abort + // the restore — the chat is then surfaced with history but no live + // backing. + if (agent.materializeChat) { + try { + await agent.materializeChat(chatUri, entry.providerData); + } catch (err) { + this._logService.warn(`[AgentService] Failed to materialize peer chat ${entry.uri}: ${toErrorMessage(err)}`); + } + } let turns: readonly Turn[] = []; try { - turns = await agent.getSessionMessages(chatUri); + turns = await this._getChatMessages(agent, chatUri); } catch (err) { this._logService.warn(`[AgentService] Failed to load history for peer chat ${chatUri.toString()}: ${toErrorMessage(err)}`); } @@ -1733,11 +2216,203 @@ export class AgentService extends Disposable implements IAgentService { this._readPersistedChatTitle(session, chatUri), this._getChatDraft(session, chatUri), ]); - this._stateManager.restoreChat(session.toString(), chatUri.toString(), { title, turns: [...turns], draft }); + const mergedTurns = await this._interleaveLocalTurns(session.toString(), chatUri.toString(), turns); + return { chatUri, title, turns: mergedTurns, draft, providerData: entry.providerData }; + })); + for (const item of restored) { + if (!item) { + continue; + } + const { chatUri, title, turns, draft, providerData } = item; + this._stateManager.restoreChat(session.toString(), chatUri.toString(), { + title, + turns, + draft, + ...(providerData !== undefined ? { providerData } : {}), + }); + } + } + + /** + * Re-persists a peer chat's opaque `providerData` blob when the agent + * reports it changed (e.g. per-chat model switch, fork remap). The + * orchestrator never parses the blob; it stores whatever it is handed. + */ + private _onChatDataChanged(e: IAgentChatDataChange): void { + const sessionStr = parseDefaultChatUri(e.chat); + if (sessionStr === undefined) { + this._logService.warn(`[AgentService] onDidChangeChatData for malformed chat URI: ${e.chat.toString()}`); + return; + } + void this._persistPeerChat(URI.parse(sessionStr), e.chat, e.providerData); + } + + /** + * Deterministic membership sequencer for agent-spawned chats, + * driven off {@link IAgent.onDidSessionProgress}: a `subagent_started` adds + * the subagent chat to the catalog via the same spawn-channel handler + * ({@link _onChatSpawned}) used by {@link IAgent.onDidSpawnChat}. + * A completed subagent chat stays live and subscribable, so completion is + * not sequenced here; subagent chats are removed only on session teardown. + * Registered before {@link AgentSideEffects} so the subagent chat exists + * before its turn starts; addChat is idempotent so overlapping with the + * agent's own spawn bridge is safe. + */ + private _sequenceSpawnedChat(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onChatSpawned(spawn); + } + } + + /** + * Routes an agent-spawned chat (e.g. a sub-agent delegated by a tool + * call) straight into the chat catalog via {@link IAgentHostStateManager.addChat}, + * so harness-spawned chats and user-driven chats share ONE membership path. + * The {@link IAgentSpawnChatEvent.parent} spawn edge is recorded as + * the chat's {@link ChatOriginKind.Tool} origin. Spawned chats are + * not written to the orchestrator's persisted peer-chat catalog — they are + * transient children re-derived from the parent's event log on restore. + */ + private _onChatSpawned(e: IAgentSpawnChatEvent): void { + this._stateManager.addChat(e.session.toString(), e.chat.toString(), { + ...(e.title !== undefined ? { title: e.title } : {}), + ...(e.parent ? { + origin: { kind: ChatOriginKind.Tool, chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId }, + // Subagent worker chats are observable but not directly steerable: + // the user watches them and steers the lead chat. Mark read-only so + // the UI hides the composer and shows a lock (the agent-team pattern). + interactivity: ChatInteractivity.ReadOnly, + } : {}), + }); + } + + /** + * Reads the orchestrator's persisted peer-chat catalog for a session. + * Returns `undefined` when the session has no catalog yet (a legacy session + * predating orchestrator-owned persistence, or a corrupt blob); the caller + * then performs a one-time migration from the agent's legacy `*.chats` + * enumeration (see {@link _restorePeerChats} / {@link _migrateLegacyPeerChats}). + * An empty array means the session is known to have no peer chats, so + * migration is skipped. + */ + private async _readPersistedPeerChatCatalog(session: URI): Promise<IPersistedPeerChat[] | undefined> { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { + return undefined; + } + try { + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw === undefined) { + return undefined; + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + this._logService.warn(`[AgentService] Ignoring malformed peer-chat catalog for ${session.toString()}`); + return undefined; + } + return parsed + .filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string') + .map(entry => ({ uri: entry.uri, ...(typeof entry.providerData === 'string' ? { providerData: entry.providerData } : {}) })); + } catch (err) { + this._logService.warn(`[AgentService] Failed to read peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + return undefined; + } finally { + ref.dispose(); + } + } + + /** + * Marks a peer chat's backing SDK session (in that session's own DB) so + * {@link listSessions} filters it out of the top-level session list. The + * marker is persisted, so it survives a host restart. Best-effort: a failure + * only means the backing session may transiently reappear in the list. + */ + private _markPeerChatBacking(backingSession: URI, chat: URI): void { + let ref; + try { + ref = this._sessionDataService.openDatabase(backingSession); + } catch (err) { + this._logService.warn(`[AgentService] Failed to open backing session database to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`); + return; + } + ref.object.setMetadata(PEER_CHAT_BACKING_METADATA_KEY, chat.toString()).catch(err => { + this._logService.warn(`[AgentService] Failed to mark peer-chat backing for ${backingSession.toString()}: ${toErrorMessage(err)}`); + }).finally(() => { + ref.dispose(); + }); + } + + /** + * Inserts or updates a single peer chat in the orchestrator's persisted + * catalog, recording its opaque `providerData` verbatim (or clearing it when + * `undefined`). Serialized per session via {@link _enqueuePeerChatCatalogWrite}. + */ + private _persistPeerChat(session: URI, chat: URI, providerData: string | undefined): Promise<void> { + const chatUri = chat.toString(); + return this._enqueuePeerChatCatalogWrite(session, entries => { + const next = entries.filter(entry => entry.uri !== chatUri); + next.push({ uri: chatUri, ...(providerData !== undefined ? { providerData } : {}) }); + return next; + }); + } + + /** + * Removes a peer chat from the orchestrator's persisted catalog. Serialized + * per session via {@link _enqueuePeerChatCatalogWrite}. + */ + private _removePersistedPeerChat(session: URI, chat: URI): Promise<void> { + const chatUri = chat.toString(); + return this._enqueuePeerChatCatalogWrite(session, entries => entries.filter(entry => entry.uri !== chatUri)); + } + + /** + * Chains a read-modify-write of a session's persisted peer-chat catalog + * behind any in-flight write for the same session, so concurrent + * create/dispose/data-change updates can't clobber each other. + */ + private _enqueuePeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> { + const key = session.toString(); + const previous = this._peerChatCatalogWrites.get(key) ?? Promise.resolve(); + const next = previous + .catch(() => { /* a failed prior write must not block later ones */ }) + .then(() => this._applyPeerChatCatalogWrite(session, mutate)); + this._peerChatCatalogWrites.set(key, next.finally(() => { + if (this._peerChatCatalogWrites.get(key) === next) { + this._peerChatCatalogWrites.delete(key); + } })); + return next; } - /** Reads a peer chat's persisted custom title, if any. */ + private async _applyPeerChatCatalogWrite(session: URI, mutate: (entries: IPersistedPeerChat[]) => IPersistedPeerChat[]): Promise<void> { + const ref = await this._sessionDataService.tryOpenDatabase?.(session); + if (!ref) { + return; + } + try { + let current: IPersistedPeerChat[] = []; + try { + const raw = await ref.object.getMetadata(PEER_CHATS_METADATA_KEY); + if (raw !== undefined) { + const parsed = JSON.parse(raw); + if (Array.isArray(parsed)) { + current = parsed.filter((entry): entry is IPersistedPeerChat => typeof entry?.uri === 'string'); + } + } + } catch (err) { + this._logService.warn(`[AgentService] Replacing malformed peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + } + const updated = mutate(current); + await ref.object.setMetadata(PEER_CHATS_METADATA_KEY, JSON.stringify(updated)); + } catch (err) { + this._logService.warn(`[AgentService] Failed to persist peer-chat catalog for ${session.toString()}: ${toErrorMessage(err)}`); + } finally { + ref.dispose(); + } + } + + /** Reads a chat's persisted custom title (default or peer chat), if any. */ private async _readPersistedChatTitle(session: URI, chatUri: URI): Promise<string | undefined> { const ref = await this._sessionDataService.tryOpenDatabase?.(session); if (!ref) { @@ -2191,6 +2866,7 @@ export class AgentService extends Disposable implements IAgentService { } await Promise.all(promises); this._sessionToProvider.clear(); + this._downloadProgressInterest.clear(); } // ---- helpers ------------------------------------------------------------ @@ -2317,7 +2993,7 @@ export class AgentService extends Disposable implements IAgentService { const agent = this._findProviderForSession(parentSession); if (agent) { try { - childTurns = await agent.getSessionMessages(URI.parse(subagentUri)); + childTurns = await this._getChatMessages(agent, URI.parse(subagentUri)); } catch (err) { this._logService.warn(`[AgentService] Failed to load subagent turns for ${subagentUri}`, err); } @@ -2327,6 +3003,10 @@ export class AgentService extends Disposable implements IAgentService { const title = subagentContent?.title ?? 'Subagent'; const subagentNow = new Date().toISOString(); + // Local turns for a subagent chat are persisted in the parent session's + // database (its chat URI resolves to the parent session), keyed by the + // subagent chat URI. + const mergedChildTurns = await this._interleaveLocalTurns(parentSession.toString(), subagentUri, childTurns); this._stateManager.restoreSession( { resource: subagentUri, @@ -2337,7 +3017,7 @@ export class AgentService extends Disposable implements IAgentService { modifiedAt: subagentNow, ...(parentState?.project ? { project: parentState.project } : {}), }, - [...childTurns], + mergedChildTurns, ); this._logService.info(`[AgentService] Restored subagent session: ${subagentUri} with ${childTurns.length} turn(s)`); } @@ -2348,7 +3028,7 @@ export class AgentService extends Disposable implements IAgentService { * {@link _restoreSubagentSession} finds it present and returns early * instead of re-reading the parent event log. No-op if already registered. */ - private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary): void { + private _registerRestoredSubagent(child: IRestoredSubagentSession, parentSummary: SessionSummary, parentSessionStr: string): void { const resourceStr = child.resource.toString(); if (this._stateManager.getSessionState(resourceStr)) { return; @@ -2366,6 +3046,19 @@ export class AgentService extends Disposable implements IAgentService { }, [...child.turns], ); + + // Mirror the live `_handleSubagentStarted` flow on restore: surface the + // subagent as a read-only peer chat in the PARENT session's catalog so it + // reappears as a tab (and the inline "Open Agent" link can reveal it) + // after a restart. Uses the same `ahp-chat://subagent/...` chat URI form + // as the live path so the sessions provider parses and surfaces it. + const subagentChatUri = buildSubagentChatUri(parentSessionStr, child.toolCallId); + this._stateManager.addChat(parentSessionStr, subagentChatUri, { + title: child.title, + turns: [...child.turns], + origin: { kind: ChatOriginKind.Tool, chat: buildDefaultChatUri(parentSessionStr), toolCallId: child.toolCallId }, + interactivity: ChatInteractivity.ReadOnly, + }); } private _findProviderForSession(session: URI | string): IAgent | undefined { diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index 5d806634ce2fd4..781b71d5521270 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -10,7 +10,6 @@ import { autorun, IObservable, IReader } from '../../../base/common/observable.j import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; -import { localize } from '../../../nls.js'; import { IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILogService } from '../../log/common/log.js'; import { IAgentHostChangesetService } from '../common/agentHostChangesetService.js'; @@ -21,13 +20,14 @@ import { toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; -import { ChatOriginKind, ToolCallContributorKind, type AgentInfo } from '../common/state/protocol/state.js'; -import { ActionType, StateAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; +import { SessionInputRequestKind, ToolCallContributorKind, type AgentInfo, type SessionInputRequest } from '../common/state/protocol/state.js'; +import { ActionType, isChatAction, StateAction, type ChatAction, type ChatToolCallCompleteAction } from '../common/state/sessionActions.js'; import { buildSubagentChatUri, getToolFileEdits, isAhpChatChannel, isDefaultChatUri, + isSubagentChatUri, MessageKind, parseChatUri, parseRequiredSessionUriFromChatUri, @@ -41,18 +41,24 @@ import { type Message, type URI as ProtocolURI, type SessionState, + type ToolCallState, + type ToolCallResult, type ToolResultContent, type Turn } from '../common/state/sessionState.js'; -import { parseRenameCommand } from './agentHostRenameCommand.js'; +import { AgentHostLocalTurns } from './agentHostLocalTurns.js'; import { AgentHostSessionTitleController } from './agentHostSessionTitleController.js'; import { AgentHostStateManager } from './agentHostStateManager.js'; import { AgentHostTelemetryReporter } from './agentHostTelemetryReporter.js'; +import { AgentHostToolCallTracker } from './agentHostToolCallTracker.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostTurnTracker } from './agentHostTurnTracker.js'; +import { AgentHostLocalCommands } from './localCommands/localChatCommand.js'; +import './localCommands/localChatCommands.contribution.js'; import { SessionPermissionManager } from './sessionPermissions.js'; import type { ICopilotApiService } from './shared/copilotApiService.js'; import { stripProxyErrorMarker, toChatErrorMeta, tryParseForwardedChatError } from './shared/forwardedChatError.js'; +import { persistSessionMetadata } from './shared/persistSessionMetadata.js'; /** * Options for constructing an {@link AgentSideEffects} instance. @@ -64,6 +70,8 @@ export interface IAgentSideEffectsOptions { readonly agents: IObservable<readonly IAgent[]>; /** Session data service for cleaning up per-session data on disposal. */ readonly sessionDataService: ISessionDataService; + /** Registry that persists host-injected `/rename` and `!command` turns. */ + readonly localTurns: AgentHostLocalTurns; /** Get the GitHub token used for Copilot utility title generation. */ readonly getGitHubCopilotToken?: () => string | undefined; /** CAPI service used for Copilot utility title generation. */ @@ -107,6 +115,9 @@ export class AgentSideEffects extends Disposable { private readonly _permissionManager: SessionPermissionManager; + /** Registry-driven dispatcher for host-handled `/rename` / `!command` etc. */ + private readonly _localCommands: AgentHostLocalCommands; + private readonly _subagentChats = new NKeyMap<ISubagentSessionRef, [ProtocolURI, string]>(); /** @@ -122,6 +133,7 @@ export class AgentSideEffects extends Disposable { private readonly _pendingSubagentSignals = new NKeyMap<IPendingSubagentSignal[], [ProtocolURI, string]>(); private readonly _telemetryReporter: AgentHostTelemetryReporter; private readonly _turnTracker: AgentHostTurnTracker; + private readonly _toolCallTracker: AgentHostToolCallTracker; private readonly _titleController: AgentHostSessionTitleController; constructor( @@ -136,7 +148,17 @@ export class AgentSideEffects extends Disposable { super(); this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._turnTracker = new AgentHostTurnTracker(this._telemetryReporter); + this._toolCallTracker = this._register(new AgentHostToolCallTracker(this._telemetryReporter)); this._permissionManager = this._register(instantiationService.createInstance(SessionPermissionManager, this._stateManager)); + this._localCommands = this._register(instantiationService.createInstance( + AgentHostLocalCommands, + this._stateManager, + this._options.localTurns, + // Draining the queue re-enters agent lookup / telemetry / sendMessage, + // which is this class's responsibility, so the dispatcher hands the + // turn back here once it has completed a host-handled command. + (turnChannel: ProtocolURI) => this._tryConsumeNextQueuedMessage(turnChannel), + )); this._titleController = this._register(instantiationService.createInstance(AgentHostSessionTitleController, this._stateManager, { sessionDataService: this._options.sessionDataService, getGitHubCopilotToken: this._options.getGitHubCopilotToken, @@ -154,6 +176,9 @@ export class AgentSideEffects extends Disposable { // handleAction, so the agent's SDK deferred never resolves. // Listen for these envelopes and notify the agent directly. this._register(this._stateManager.onDidEmitEnvelope(envelope => { + if (isAhpChatChannel(envelope.channel) && isChatAction(envelope.action)) { + this._syncSessionInputNeededForChatAction(envelope.channel, envelope.action); + } if (!envelope.origin && envelope.action.type === ActionType.ChatToolCallComplete) { const action = envelope.action; // Chat-action envelopes are emitted on the chat channel URI; @@ -164,8 +189,7 @@ export class AgentSideEffects extends Disposable { return; // Not a chat channel; ignore (already logged elsewhere). } const sessionChannel = parseRequiredSessionUriFromChatUri(envelope.channel); - const agent = this._options.getAgent(sessionChannel); - agent?.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(envelope.channel), action.toolCallId, action.result); + this._notifyClientToolCallComplete(sessionChannel, envelope.channel, action.toolCallId, action.result, 'server-envelope'); } if (envelope.action.type === ActionType.ChatDraftChanged) { this._persistChatDraft(envelope.channel, envelope.action.draft); @@ -197,6 +221,7 @@ export class AgentSideEffects extends Disposable { })), customizations: customizations?.length ? [...customizations] : undefined, protectedResources: protectedResources.length > 0 ? protectedResources : undefined, + capabilities: d.capabilities ? { ...d.capabilities } : undefined, }; }); if (equals(this._lastAgentInfos, infos)) { @@ -241,6 +266,128 @@ export class AgentSideEffects extends Disposable { } } + // ---- Session input-needed aggregation ---------------------------------- + // + // Mirrors per-chat blockers (user-input elicitations, tool confirmations, + // and running client-tool executions) into the owning session's + // `inputNeeded` list so clients subscribed only to the session channel can + // discover and answer them without subscribing to each chat. This handler + // only produces the state; it does not consume it. + + private _syncSessionInputNeededForChatAction(chatUri: ProtocolURI, action: ChatAction): void { + switch (action.type) { + case ActionType.ChatInputRequested: + this._setSessionInputNeeded(chatUri, { + id: this._chatInputNeededId(chatUri, action.request.id), + kind: SessionInputRequestKind.ChatInput, + chat: chatUri, + request: action.request, + }); + break; + case ActionType.ChatInputCompleted: + this._removeSessionInputNeeded(chatUri, this._chatInputNeededId(chatUri, action.requestId)); + break; + case ActionType.ChatToolCallStart: + case ActionType.ChatToolCallReady: + case ActionType.ChatToolCallConfirmed: + case ActionType.ChatToolCallComplete: + case ActionType.ChatToolCallResultConfirmed: + this._syncToolInputNeeded(chatUri, action.turnId, action.toolCallId); + break; + case ActionType.ChatTurnComplete: + case ActionType.ChatTurnCancelled: + case ActionType.ChatError: + case ActionType.ChatTruncated: + this._removeSessionInputNeededForChat(chatUri); + break; + } + } + + private _syncToolInputNeeded(chatUri: ProtocolURI, turnId: string, toolCallId: string): void { + const confirmationId = this._toolConfirmationNeededId(chatUri, turnId, toolCallId); + const clientExecutionId = this._toolClientExecutionNeededId(chatUri, turnId, toolCallId); + const toolCall = this._findToolCall(chatUri, turnId, toolCallId); + + const needsConfirmation = toolCall?.status === ToolCallStatus.PendingConfirmation || toolCall?.status === ToolCallStatus.PendingResultConfirmation; + if (needsConfirmation && toolCall) { + this._setSessionInputNeeded(chatUri, { + id: confirmationId, + kind: SessionInputRequestKind.ToolConfirmation, + chat: chatUri, + turnId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, confirmationId); + } + + const contributor = toolCall?.contributor; + if (toolCall?.status === ToolCallStatus.Running && contributor?.kind === ToolCallContributorKind.Client) { + this._setSessionInputNeeded(chatUri, { + id: clientExecutionId, + kind: SessionInputRequestKind.ToolClientExecution, + chat: chatUri, + turnId, + clientId: contributor.clientId, + toolCall, + }); + } else { + this._removeSessionInputNeeded(chatUri, clientExecutionId); + } + } + + private _findToolCall(chatUri: ProtocolURI, turnId: string, toolCallId: string): ToolCallState | undefined { + const state = this._stateManager.getSessionState(chatUri); + const turn = state?.activeTurn?.id === turnId ? state.activeTurn : state?.turns.find(t => t.id === turnId); + const part = turn?.responseParts.find(p => p.kind === ResponsePartKind.ToolCall && p.toolCall.toolCallId === toolCallId); + return part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + } + + private _setSessionInputNeeded(chatUri: ProtocolURI, request: SessionInputRequest): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + const existing = this._stateManager.getSessionState(sessionUri)?.inputNeeded?.find(r => r.id === request.id); + if (existing && equals(existing, request)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededSet, request }); + if (request.kind !== SessionInputRequestKind.ChatInput) { + const agent = this._options.getAgent(sessionUri); + if (agent) { + this._toolCallTracker.toolCallBlocked(agent.id, chatUri, request); + } + } + } + + private _removeSessionInputNeeded(chatUri: ProtocolURI, id: string): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + this._toolCallTracker.toolCallUnblocked(chatUri, id); + if (!this._stateManager.getSessionState(sessionUri)?.inputNeeded?.some(r => r.id === id)) { + return; + } + this._stateManager.dispatchServerAction(sessionUri, { type: ActionType.SessionInputNeededRemoved, id }); + } + + private _removeSessionInputNeededForChat(chatUri: ProtocolURI): void { + const sessionUri = parseRequiredSessionUriFromChatUri(chatUri); + for (const request of this._stateManager.getSessionState(sessionUri)?.inputNeeded ?? []) { + if (request.chat === chatUri) { + this._removeSessionInputNeeded(chatUri, request.id); + } + } + } + + private _chatInputNeededId(chatUri: ProtocolURI, requestId: string): string { + return `chatInput:${chatUri}:${requestId}`; + } + + private _toolConfirmationNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolConfirmation:${chatUri}:${turnId}:${toolCallId}`; + } + + private _toolClientExecutionNeededId(chatUri: ProtocolURI, turnId: string, toolCallId: string): string { + return `toolClientExecution:${chatUri}:${turnId}:${toolCallId}`; + } + // ---- Initialization ---------------------------------------------------- /** @@ -270,6 +417,9 @@ export class AgentSideEffects extends Disposable { this._publishSessionCustomizationsForAgent(agent); })); } + if (agent.onDidRequireAuth) { + disposables.add(agent.onDidRequireAuth(e => this._stateManager.emitAuthRequired(e))); + } return disposables; } @@ -284,7 +434,7 @@ export class AgentSideEffects extends Disposable { */ private _handleAgentSignal(agent: IAgent, signal: AgentSignal): void { if (signal.kind === 'subagent_started') { - this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription); + this._handleSubagentStarted(signal.chat.toString(), signal.toolCallId, signal.agentName, signal.agentDisplayName, signal.agentDescription, signal.parentToolCallId); this._drainPendingSubagentSignals(signal.chat.toString(), signal.toolCallId); return; } @@ -412,9 +562,14 @@ export class AgentSideEffects extends Disposable { if (action.type === ActionType.ChatToolCallStart && agent) { this._toolCallAgents.set(`${sessionKey}:${action.toolCallId}`, agent.id); + // Stamp the tool call start for `languageModelToolInvoked` telemetry. + // Only the start action carries the tool name and contributor, so the + // source kind must be captured here rather than on completion. The + // provider comes from the agent that emitted the signal. + this._toolCallTracker.toolCallStarted(agent.id, sessionKey, action.toolCallId, action.toolName, action.contributor); } - const sessionScope = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; + const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; // When a parent tool call has an associated subagent session, // preserve the subagent content metadata in the completion result. @@ -447,6 +602,11 @@ export class AgentSideEffects extends Disposable { } if (action.type === ActionType.ChatToolCallComplete) { + // Emit `languageModelToolInvoked` telemetry for the completed tool + // call. `action.result` carries `success`/`error.code` even after the + // subagent-content merge above (which only touches `result.content`). + this._toolCallTracker.toolCallCompleted(sessionKey, action.toolCallId, action.result); + // Drop any events that were buffered for a subagent whose // `subagent_started` never arrived (e.g. the parent tool failed // before the subagent was created). The actual subagent session @@ -455,21 +615,24 @@ export class AgentSideEffects extends Disposable { // after the parent tool call returns. this._pendingSubagentSignals.delete(sessionKey, action.toolCallId); if (getToolFileEdits(action.result).length > 0) { - this._changesets.onToolCallEditsApplied(sessionScope, turnId); + this._changesets.onToolCallEditsApplied(sessionUri, turnId); } } if (action.type === ActionType.ChatTurnComplete) { this._turnTracker.turnCompleted(sessionKey, turnId, 'success'); + this._toolCallTracker.clearSession(sessionKey); this._runTurnCompleteSideEffects(sessionKey, turnId); } if (action.type === ActionType.ChatTurnCancelled) { this._turnTracker.turnCompleted(sessionKey, turnId, 'cancelled'); + this._toolCallTracker.clearSession(sessionKey); } if (action.type === ActionType.ChatError) { this._turnTracker.turnCompleted(sessionKey, turnId, 'error'); + this._toolCallTracker.clearSession(sessionKey); } } @@ -486,7 +649,7 @@ export class AgentSideEffects extends Disposable { // message consumption (queues live on the chat state). For the // default chat / single-chat case `sessionKey` is already the // session URI, so this is a no-op. - const sessionScope = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; + const sessionUri = isAhpChatChannel(sessionKey) ? parseRequiredSessionUriFromChatUri(sessionKey) : sessionKey; // Capture the end-of-turn git checkpoint BEFORE notifying the // changeset service so the per-turn changeset recompute can take // the authoritative git-diff fast path (which includes terminal-tool @@ -497,17 +660,17 @@ export class AgentSideEffects extends Disposable { // completion since those have always been fire-and-forget; the // ordering guarantee we care about is checkpoint-then-changeset. if (turnId !== undefined) { - this._checkpointService.captureTurnCheckpoint(URI.parse(sessionScope), turnId).then(() => { - this._changesets.onTurnComplete(sessionScope, turnId); + this._checkpointService.captureTurnCheckpoint(URI.parse(sessionUri), turnId).then(() => { + this._changesets.onTurnComplete(sessionUri, turnId); }, err => { - this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionScope}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); - this._changesets.onTurnComplete(sessionScope, turnId); + this._logService.warn(`[AgentSideEffects] Turn checkpoint capture failed for ${sessionUri}/${turnId}: ${err instanceof Error ? err.message : String(err)}`); + this._changesets.onTurnComplete(sessionUri, turnId); }); } else { - this._changesets.onTurnComplete(sessionScope, turnId); + this._changesets.onTurnComplete(sessionUri, turnId); } this._tryConsumeNextQueuedMessage(sessionKey); - this._options.onTurnComplete(sessionScope); + this._options.onTurnComplete(sessionUri); // After the first turn completes, refine the auto-generated title using // the full first-turn context (request + response). No-op for later @@ -515,7 +678,7 @@ export class AgentSideEffects extends Disposable { // additional chat channel; route it as `chatChannel` so the refinement // targets that chat's title, mirroring `seedTitleFromFirstMessage`. const titleChatChannel = isAhpChatChannel(sessionKey) && !isDefaultChatUri(sessionKey) ? sessionKey : undefined; - this._titleController.refineTitleFromFirstTurn(sessionScope, titleChatChannel); + this._titleController.refineTitleFromFirstTurn(sessionUri, titleChatChannel); } private _describeSignal(signal: AgentSignal): string { @@ -542,9 +705,21 @@ export class AgentSideEffects extends Disposable { // ---- Subagent session management ---------------------------------------- /** - * Creates a subagent session in response to a `subagent_started` event. - * The subagent session is created silently (no `sessionAdded` notification) - * and immediately transitioned to ready with an active turn. + * Starts the subagent turn in response to a `subagent_started` event and + * wires the parent tool call to the subagent chat. The subagent chat's + * catalog membership is owned by the spawn channel + * ({@link AgentService._onChatSpawned}), which the orchestrator applies + * before this runs, so this only drives the turn/tracking/parent content + * — it does not add the chat. + * + * `chatURI` is always the agent's top-level chat: the subagent is + * registered (and inner events routed) under it because inner-tool + * signals carry the top-level chat as their resource. `spawningToolParentId`, + * when set, is the tool call one level up from the spawning `toolCallId` + * — the tool call in whose (subagent) chat the spawning tool lives — and + * is used to route the discovery content block to that immediate parent + * chat. Since subagent chats are flat (keyed off the root session), this + * one-hop reference resolves the parent chat at any nesting depth. */ private _handleSubagentStarted( chatURI: ProtocolURI, @@ -552,6 +727,7 @@ export class AgentSideEffects extends Disposable { agentName: string, agentDisplayName: string, agentDescription?: string, + spawningToolParentId?: string, ): void { const parentSessionUri = parseRequiredSessionUriFromChatUri(chatURI); const subagentChatUri = buildSubagentChatUri(parentSessionUri, toolCallId); @@ -561,11 +737,7 @@ export class AgentSideEffects extends Disposable { return; } - this._logService.info(`[AgentSideEffects] Creating subagent chat: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`); - this._stateManager.addChat(parentSessionUri, subagentChatUri, { - title: agentDisplayName, - origin: { kind: ChatOriginKind.Tool, chat: chatURI, toolCallId }, - }); + this._logService.info(`[AgentSideEffects] Starting subagent turn: ${subagentChatUri} (parent=${chatURI}, toolCallId=${toolCallId})`); // Start a turn on the subagent session const turnId = generateUuid(); @@ -577,13 +749,22 @@ export class AgentSideEffects extends Disposable { this._subagentChats.set({ parentChatUri: chatURI, toolCallId, sessionUri: parentSessionUri, chatUri: subagentChatUri }, chatURI, toolCallId); - // Dispatch content on the parent tool call so clients discover the subagent. - // Merge with any existing content to avoid dropping prior content blocks. - const parentTurnId = this._stateManager.getActiveTurnId(chatURI); + // Dispatch content on the spawning tool call so clients discover the + // subagent. The tool call lives in the immediate parent chat, which is + // the top-level chat for a first-level subagent or the immediate + // parent subagent chat when nested (at any depth) — resolve it via + // `spawningToolParentId` so the block lands where the tool call is + // (dispatching on the top-level chat would be a no-op, leaving nested + // subagents undiscoverable). Merge with any existing content to avoid + // dropping prior content blocks. + const contentChatUri = spawningToolParentId + ? this._subagentChats.get(chatURI, spawningToolParentId)?.chatUri ?? chatURI + : chatURI; + const parentTurnId = this._stateManager.getActiveTurnId(contentChatUri); if (parentTurnId) { - const parentState = this._stateManager.getSessionState(chatURI); + const parentState = this._stateManager.getSessionState(contentChatUri); const existingContent = this._getRunningToolCallContent(parentState, parentTurnId, toolCallId); - this._stateManager.dispatchServerAction(chatURI, { + this._stateManager.dispatchServerAction(contentChatUri, { type: ActionType.ChatToolCallContentChanged, turnId: parentTurnId, toolCallId, @@ -633,6 +814,7 @@ export class AgentSideEffects extends Disposable { }); this._turnTracker.turnCompleted(subagent.chatUri, turnId, 'cancelled'); } + this._toolCallTracker.clearSession(subagent.chatUri); } this._subagentChats.deleteAll(parentChatURI); // Drop any buffered events targeted at subagents that never started. @@ -676,6 +858,7 @@ export class AgentSideEffects extends Disposable { for (const subagent of this._subagentChats.values()) { if (subagent.sessionUri === parentSession) { this._stateManager.removeChat(subagent.sessionUri, subagent.chatUri); + this._toolCallTracker.clearSession(subagent.chatUri); parentChatURIs.add(subagent.parentChatUri); } } @@ -700,6 +883,32 @@ export class AgentSideEffects extends Disposable { return undefined; } + private _toolCallCompletionChat(chatChannel: ProtocolURI): ProtocolURI { + if (!isSubagentChatUri(chatChannel)) { + return chatChannel; + } + + for (const subagent of this._subagentChats.values()) { + if (subagent.chatUri === chatChannel) { + return this._toolCallCompletionChat(subagent.parentChatUri); + } + } + + this._logService.warn(`[AgentSideEffects] Missing parent chat for subagent tool completion: chat=${chatChannel}`); + return chatChannel; + } + + private _notifyClientToolCallComplete(sessionChannel: ProtocolURI, chatChannel: ProtocolURI, toolCallId: string, result: ToolCallResult, source: 'client-dispatch' | 'server-envelope'): void { + const completionChat = this._toolCallCompletionChat(chatChannel); + const agent = this._options.getAgent(sessionChannel); + if (!agent) { + this._logService.warn(`[AgentSideEffects] No agent for client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}`); + return; + } + this._logService.info(`[AgentSideEffects] Forwarding client tool completion: source=${source}, session=${sessionChannel}, chat=${chatChannel}, completionChat=${completionChat}, toolCallId=${toolCallId}, success=${result.success}`); + agent.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(completionChat), toolCallId, result); + } + // ---- Side-effect handlers -------------------------------------------------- /** @@ -744,7 +953,7 @@ export class AgentSideEffects extends Disposable { ); } - handleAction(channel: ProtocolURI, action: StateAction): void { + handleAction(channel: ProtocolURI, action: StateAction, clientId?: string): void { const chatChannel = isAhpChatChannel(channel) ? channel : undefined; const sessionChannel = chatChannel ? parseRequiredSessionUriFromChatUri(chatChannel) : channel; switch (action.type) { @@ -755,12 +964,10 @@ export class AgentSideEffects extends Disposable { // Per-turn streaming part tracking is owned by the agent // (e.g. CopilotAgentSession) and reset on its `send()` call. - // `/rename [title]` is a generic, agent-agnostic slash command: - // it is intercepted here and redirected to a title change rather - // than forwarded to the agent SDK. Mirrors the per-agent text-side - // dispatch (`parseLeadingSlashCommand` in CopilotAgentSession), but - // applies to every session type. - if (this._tryHandleRenameCommand(channel, action.turnId, action.message.text)) { + // Generic, agent-agnostic host commands (`/rename`, `!command`, + // …) are intercepted here and handled by the local-command + // dispatcher rather than forwarded to the agent SDK. + if (this._localCommands.tryHandle({ turnChannel: channel, turnId: action.turnId, text: action.message.text })) { break; } @@ -790,6 +997,7 @@ export class AgentSideEffects extends Disposable { chat: channel, message: action.message, turnId: action.turnId, + senderClientId: clientId, }); break; } @@ -827,12 +1035,16 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatTurnCancelled must be handled on an AHP chat channel: ${channel}`); } this._turnTracker.turnCompleted(channel, action.turnId, 'cancelled'); + this._toolCallTracker.clearSession(channel); // Cancel all subagent sessions for this parent this.cancelSubagentSessions(channel); const agent = this._options.getAgent(sessionChannel); - agent?.abortSession(URI.parse(sessionChannel), isDefaultChatUri(channel) ? undefined : URI.parse(channel)).catch(err => { - this._logService.error('[AgentSideEffects] abortSession failed', err); - }); + if (agent) { + const chat = URI.parse(channel); + agent.chats.abort(chat).catch(err => { + this._logService.error('[AgentSideEffects] abort failed', err); + }); + } // Intentionally do NOT drain queued messages here: cancelling means // "stop", so messages queued behind the turn stay queued for the // user to dequeue/run manually. (A message the user sends *after* @@ -866,9 +1078,23 @@ export class AgentSideEffects extends Disposable { throw new Error(`ChatTruncated must be handled on an AHP chat channel: ${channel}`); } const agent = this._options.getAgent(sessionChannel); - agent?.truncateSession?.(URI.parse(sessionChannel), action.turnId).catch(err => { + // When the truncation boundary is a host-injected local turn + // (`/rename` / `!command`), redirect the SDK truncation to the + // preceding concrete turn so the agent keeps everything up to + // the real message before it. + const sdkTurnId = action.turnId !== undefined + ? this._options.localTurns.resolveConcreteTurnId(chatChannel, action.turnId) + : action.turnId; + // Route to the chat being truncated: the default chat (addressed + // by the session) or a peer chat with its own backing. + agent?.truncateSession?.(URI.parse(sessionChannel), sdkTurnId, URI.parse(chatChannel)).catch(err => { this._logService.error('[AgentSideEffects] truncateSession failed', err); }); + // Drop persisted local turns that no longer survive in the + // (already-truncated) chat state. + const survivingIds = new Set((this._stateManager.getChatState(chatChannel)?.turns ?? []).map(t => t.id)); + const removed = this._options.localTurns.getLocalTurnIds(chatChannel).filter(id => !survivingIds.has(id)); + this._options.localTurns.deleteLocals(sessionChannel, removed); this._changesets.onSessionTruncated(sessionChannel); break; } @@ -906,6 +1132,20 @@ export class AgentSideEffects extends Disposable { agent?.setCustomizationEnabled?.(action.id, action.enabled); break; } + case ActionType.SessionMcpServerStartRequested: { + const agent = this._options.getAgent(sessionChannel); + agent?.startMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => { + this._logService.warn(`[AgentSideEffects] startMcpServer failed for ${sessionChannel}`, err); + }); + break; + } + case ActionType.SessionMcpServerStopRequested: { + const agent = this._options.getAgent(sessionChannel); + agent?.stopMcpServer?.(URI.parse(sessionChannel), action.id).catch(err => { + this._logService.warn(`[AgentSideEffects] stopMcpServer failed for ${sessionChannel}`, err); + }); + break; + } case ActionType.SessionIsReadChanged: { this._persistSessionFlag(channel, 'isRead', action.isRead ? 'true' : ''); break; @@ -932,8 +1172,7 @@ export class AgentSideEffects extends Disposable { if (!chatChannel) { break; // Not a chat channel; ignore. } - const agent = this._options.getAgent(sessionChannel); - agent?.onClientToolCallComplete(URI.parse(sessionChannel), URI.parse(chatChannel), action.toolCallId, action.result); + this._notifyClientToolCallComplete(sessionChannel, chatChannel, action.toolCallId, action.result, 'client-dispatch'); break; } } @@ -945,92 +1184,20 @@ export class AgentSideEffects extends Disposable { /** * Generates a content-derived title for a freshly forked session - * (`chatChannel` undefined) or peer chat from its inherited conversation + * (`chatChannel` undefined) or peer chat from its inherited chat * turns, replacing the placeholder `Forked: …` title once ready. */ generateForkedTitle(channel: ProtocolURI, chatChannel: ProtocolURI | undefined, turns: readonly Turn[], fallbackTitle: string, sourceTitle?: string): void { this._titleController.generateForkedTitle(channel, chatChannel, turns, fallbackTitle, sourceTitle); } - /** - * Handles the generic `/rename [title]` slash command. When `text` is a - * rename command it is redirected to a {@link ActionType.SessionTitleChanged} - * action (when a non-empty title is supplied) and the just-started turn is - * immediately completed, so the command is never forwarded to the agent SDK. - * - * @returns `true` when the message was a rename command and was handled here - * (the caller MUST NOT forward it to the agent), `false` otherwise. - */ - private _tryHandleRenameCommand(channel: ProtocolURI, turnId: string, text: string): boolean { - const title = parseRenameCommand(text); - if (title === undefined) { - return false; - } - const isAdditional = (uri: ProtocolURI | undefined): uri is ProtocolURI => - !!uri && isAhpChatChannel(uri) && !isDefaultChatUri(uri); - const chatTarget = isAdditional(channel) ? channel : undefined; - const sessionChannel = chatTarget ? parseRequiredSessionUriFromChatUri(chatTarget) : (isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel); - // The just-opened turn lives wherever the message was dispatched. - const turnTarget = chatTarget ?? channel; - if (title.length > 0) { - if (chatTarget) { - // Rename only this chat, independently of the session title. - this._stateManager.updateChatTitle(sessionChannel, chatTarget, title); - this._persistSessionFlag(sessionChannel, `customChatTitle:${chatTarget}`, title); - } else { - this._stateManager.dispatchServerAction(sessionChannel, { - type: ActionType.SessionTitleChanged, - title, - }); - // Server-dispatched actions bypass `handleAction`, so persist the - // new title here directly (the client-dispatched rename path relies - // on the `SessionTitleChanged` case in `handleAction` instead). - this._persistSessionFlag(sessionChannel, 'customTitle', title); - } - // Acknowledge the rename with a brief response so the turn has - // visible content in the transcript. - this._stateManager.dispatchServerAction(turnTarget, { - type: ActionType.ChatResponsePart, - turnId, - part: { - kind: ResponsePartKind.Markdown, - id: generateUuid(), - content: localize('agentHostRename.renamed', "Renamed: {0}", title), - }, - }); - } - // Close out the turn that the reducer opened for this message so the - // session returns to idle instead of waiting on an agent response. - this._stateManager.dispatchServerAction(turnTarget, { - type: ActionType.ChatTurnComplete, - turnId, - }); - // This turn was completed via a direct server dispatch rather than - // `_runTurnCompleteSideEffects`, so drain any messages queued behind - // the rename ourselves; otherwise they would stall until the next - // unrelated state change re-triggers consumption. - this._tryConsumeNextQueuedMessage(turnTarget); - return true; - } - /** * Persists a session metadata key/value pair to the session database. * Used for fields the host needs to remember across restarts (custom * title, isRead/isArchived flags, merged config values). - * - * Counterpart in `agentHostChangesetService.ts` (`AgentHostChangesetService._persistSessionFlag`): - * keep both copies in sync if the signature changes. Duplicated rather - * than lifted because the two consumers persist disjoint metadata - * (changeset diffs there vs. customTitle / isRead / isArchived / - * configValues here) and a shared util would only have two callers. */ private _persistSessionFlag(session: ProtocolURI, key: string, value: string): void { - const ref = this._options.sessionDataService.openDatabase(URI.parse(session)); - ref.object.setMetadata(key, value).catch(err => { - this._logService.warn(`[AgentSideEffects] Failed to persist ${key}`, err); - }).finally(() => { - ref.dispose(); - }); + persistSessionMetadata(this._options.sessionDataService, this._logService, session, key, value); } private _persistChatDraft(channel: ProtocolURI, draft: Message | undefined): void { @@ -1068,6 +1235,7 @@ export class AgentSideEffects extends Disposable { URI.parse(sessionChannel), state.steeringMessage, [], + isDefaultChatUri(chatChannel) ? undefined : URI.parse(chatChannel), ); // Steering message removal is now dispatched by the agent @@ -1110,14 +1278,15 @@ export class AgentSideEffects extends Disposable { queuedMessageId: msg.id, }); - // `/rename` is intercepted generically (see the ChatTurnStarted - // handler) and must not reach the agent SDK even when queued. - if (this._tryHandleRenameCommand(session, turnId, msg.message.text)) { + // Generic host commands (`/rename`, `!command`, …) are intercepted by + // the local-command dispatcher (see the ChatTurnStarted handler) and + // must not reach the agent SDK even when queued. + if (this._localCommands.tryHandle({ turnChannel: session, turnId, text: msg.message.text })) { return; } // Send the message to the agent backend. When `session` is an - // additional chat channel, the SDK conversation is owned by the + // additional chat channel, the SDK chat is owned by the // parent session: look up the provider by the parent session URI and // pass the chat channel so the harness routes to the right peer chat. const agent = this._options.getAgent(sessionChannel); @@ -1142,6 +1311,7 @@ export class AgentSideEffects extends Disposable { chat: session, message: msg.message, turnId, + senderClientId: undefined, }); } @@ -1161,7 +1331,7 @@ export class AgentSideEffects extends Disposable { */ private async _sendTurnMessage(options: { agent: IAgent; - /** The agent/session URI the conversation lives on (the send target). */ + /** The agent/session URI the chat lives on (the send target). */ sessionChannel: ProtocolURI; /** The channel the turn runs on — where `ChatError` / turn completion are reported. */ turnChannel: ProtocolURI; @@ -1169,30 +1339,25 @@ export class AgentSideEffects extends Disposable { chat: ProtocolURI; message: Message; turnId: string; + senderClientId: string | undefined; }): Promise<void> { - const { agent, sessionChannel, turnChannel, chat, message, turnId } = options; + const { agent, turnChannel, chat, message, turnId, senderClientId } = options; - const sessionUri = URI.parse(sessionChannel); const chatUri = URI.parse(chat); + const selectionUpdates: Promise<void>[] = []; if (message.model) { - const changeModel = agent.changeModel?.(sessionUri, message.model, chatUri); - if (changeModel) { - selectionUpdates.push(changeModel.catch(err => { - this._logService.error('[AgentSideEffects] changeModel failed', err); - })); - } - } - const changeAgent = agent.changeAgent?.(sessionUri, message.agent, chatUri); - if (changeAgent) { - selectionUpdates.push(changeAgent.catch(err => { - this._logService.error('[AgentSideEffects] changeAgent failed', err); + selectionUpdates.push(agent.chats.changeModel(chatUri, message.model).catch(err => { + this._logService.error('[AgentSideEffects] changeModel failed', err); })); } + selectionUpdates.push(agent.chats.changeAgent(chatUri, message.agent).catch(err => { + this._logService.error('[AgentSideEffects] changeAgent failed', err); + })); await Promise.all(selectionUpdates); - await agent.sendMessage(URI.parse(sessionChannel), chatUri, message.text, message.attachments, turnId).catch(err => { + await agent.chats.sendMessage(chatUri, message.text, message.attachments, turnId, senderClientId).catch(err => { const errCode = (err as { code?: number })?.code; this._logService.error(`[AgentSideEffects] sendMessage failed for session=${turnChannel}: code=${errCode}, message=${err instanceof Error ? err.message : String(err)}, type=${err?.constructor?.name}`, err); this._stateManager.dispatchServerAction(turnChannel, { @@ -1201,12 +1366,14 @@ export class AgentSideEffects extends Disposable { error: buildSendFailedError(err), }); this._turnTracker.turnCompleted(turnChannel, turnId, 'error'); + this._toolCallTracker.clearSession(turnChannel); }); } override dispose(): void { this._toolCallAgents.clear(); + this._toolCallTracker.clear(); super.dispose(); } } diff --git a/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts new file mode 100644 index 00000000000000..c06917c841c996 --- /dev/null +++ b/src/vs/platform/agentHost/node/byokLmBridgeRegistry.ts @@ -0,0 +1,242 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 { createDecorator } from '../../instantiation/common/instantiation.js'; +import { IByokLmBridgeConnection, IByokLmModelInfo } from '../common/agentHostByokLm.js'; + +export const IByokLmBridgeRegistry = createDecorator<IByokLmBridgeRegistry>('byokLmBridgeRegistry'); + +/** + * Node-side registry of renderer {@link IByokLmBridgeConnection}s keyed by + * client id. Populated by the agent host's connection lifecycle (one entry per + * connected renderer) and consumed by {@link IByokLmProxyService} (inference + * routing) and {@link CopilotAgent} (model catalogue). + * + * **Single serving window, multiple connections.** BYOK is serviced by the + * renderer LM API, whose BYOK models are a property of the user's installed + * extensions, not of a particular window — so every window that registers the + * handler exposes the same set. Both the main workbench and the dedicated Agents + * app register it (each runs a full extension host whose LM API holds the same + * BYOK models), so either can serve. A connection that connects without binding + * the handler registers a bridge whose `listModels()` rejects and is treated as + * non-serving. The registry therefore does NOT aggregate per-window model sets; + * it surfaces the models from any one *serving* window (preferring one that + * actually has models) and routes inference there, automatically excluding + * non-serving windows. + * + * A connection becomes "serving" once its `listModels()` resolves (even to an + * empty list). On registration (and whenever a connection reports + * {@link IByokLmBridgeConnection.onDidChangeModels}) the registry enumerates it + * and fires {@link onDidChangeModels} when the serving model set changes. + */ +export interface IByokLmBridgeRegistry { + readonly _serviceBrand: undefined; + + /** Register a renderer connection. Disposing the result removes it. */ + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable; + + /** + * Re-enumerate the connected renderers, refresh the cache, and return the + * serving window's BYOK models. Use this when freshness matters (e.g. + * synthesizing a session's provider config at create time). + */ + listModels(): Promise<IByokLmModelInfo[]>; + + /** + * The serving window's BYOK models, read synchronously from the cache (no + * enumeration). Use this for fast reads driven by {@link onDidChangeModels}. + */ + getModels(): readonly IByokLmModelInfo[]; + + /** + * A connection that can serve BYOK inference (one whose enumeration has + * resolved), or `undefined` when no connected window can. All serving + * windows expose the same models, so any one of them is a valid target. + */ + getServingConnection(): IByokLmBridgeConnection | undefined; + + /** + * Subscribe to changes in the set of registered connections (a renderer + * connecting or disconnecting) or in the serving window's models, so + * consumers can re-read {@link getModels}. Disposing the result removes the + * listener. + */ + onDidChangeModels(listener: () => void): IDisposable; +} + +/** + * Per-connection registry entry. `models` is `undefined` until the connection's + * first successful enumeration; a connection with defined `models` is "serving" + * (it answered, even if with an empty list). Non-serving windows (those that did + * not register the BYOK handler, whose `listModels()` rejects) keep + * `models === undefined`. + */ +interface IConnectionEntry { + readonly connection: IByokLmBridgeConnection; + models: readonly IByokLmModelInfo[] | undefined; + readonly store: DisposableStore; +} + +export class ByokLmBridgeRegistry implements IByokLmBridgeRegistry { + + declare readonly _serviceBrand: undefined; + + private readonly _entries = new Map<string, IConnectionEntry>(); + private readonly _changeListeners = new Set<() => void>(); + + onDidChangeModels(listener: () => void): IDisposable { + this._changeListeners.add(listener); + return toDisposable(() => { + this._changeListeners.delete(listener); + }); + } + + private _notifyChanged(): void { + // Snapshot first: a listener may unsubscribe (mutating the set) while it + // is being notified. + for (const listener of [...this._changeListeners]) { + listener(); + } + } + + register(clientId: string, connection: IByokLmBridgeConnection): IDisposable { + // Replace any prior entry for the same client id (e.g. a reconnect). + this._entries.get(clientId)?.store.dispose(); + + const store = new DisposableStore(); + const entry: IConnectionEntry = { connection, models: undefined, store }; + this._entries.set(clientId, entry); + + // Re-enumerate whenever the renderer reports its BYOK models changed. + if (connection.onDidChangeModels) { + store.add(connection.onDidChangeModels(() => { + void this._refreshConnection(clientId); + })); + } + + // The connection set changed; enumerate the new connection's models. + this._notifyChanged(); + void this._refreshConnection(clientId); + + return toDisposable(() => { + if (this._entries.get(clientId) === entry) { + this._entries.delete(clientId); + entry.store.dispose(); + this._notifyChanged(); + } + }); + } + + async listModels(): Promise<IByokLmModelInfo[]> { + // Actively re-enumerate every connection so callers that need freshness + // (e.g. session create) don't race a cold cache. + await Promise.all([...this._entries.keys()].map(clientId => this._refreshConnection(clientId))); + return [...this.getModels()]; + } + + getModels(): readonly IByokLmModelInfo[] { + return this._servingEntry()?.models ?? []; + } + + getServingConnection(): IByokLmBridgeConnection | undefined { + return this._servingEntry()?.connection; + } + + /** + * A connection that has answered an enumeration (`models` defined), preferring + * one whose model set is non-empty. All serving windows expose the same models, + * so any populated one is an equivalent source/target; the preference matters + * when a window that is still starting up (e.g. the Agents app before its BYOK + * extension has registered models) answers with an empty list first — it must + * not shadow a peer that already has them, transiently or permanently. Falls + * back to a serving-but-empty window when none have models yet; non-serving + * windows (those that didn't register the BYOK handler) are skipped. + */ + private _servingEntry(): IConnectionEntry | undefined { + let emptyFallback: IConnectionEntry | undefined; + for (const entry of this._entries.values()) { + if (entry.models === undefined) { + continue; + } + if (entry.models.length > 0) { + return entry; + } + emptyFallback ??= entry; + } + return emptyFallback; + } + + /** + * Enumerate a single connection's models into its cache and notify listeners + * when the result changes. A connection whose `listModels()` rejects (e.g. a + * window that did not register the BYOK handler) is left non-serving. + */ + private async _refreshConnection(clientId: string): Promise<void> { + const entry = this._entries.get(clientId); + if (!entry) { + return; + } + let models: readonly IByokLmModelInfo[]; + try { + models = await entry.connection.listModels(); + } catch { + // The connection didn't answer (e.g. no BYOK handler registered); + // leave it non-serving. + return; + } + // Drop the result if the entry was removed/replaced while in flight. + if (this._entries.get(clientId) !== entry) { + return; + } + // The connection answered, so this entry is serving. Notify only when the + // serving model set actually changed. + if (entry.models === undefined || !modelsEqual(entry.models, models)) { + entry.models = models; + this._notifyChanged(); + } + } +} + +/** Shallow structural comparison of two model lists (order-sensitive). */ +function modelsEqual(a: readonly IByokLmModelInfo[], b: readonly IByokLmModelInfo[]): boolean { + if (a.length !== b.length) { + return false; + } + return a.every((m, i) => { + const n = b[i]; + return m.vendor === n.vendor && m.id === n.id && m.name === n.name && m.maxContextWindowTokens === n.maxContextWindowTokens && m.supportsVision === n.supportsVision; + }); +} + +/** + * No-op {@link IByokLmBridgeRegistry} for agent host entrypoints that do not + * support BYOK — e.g. the remote agent host, where no extension host runs + * alongside the agent host to serve the renderer LM API. + */ +export class NullByokLmBridgeRegistry implements IByokLmBridgeRegistry { + + declare readonly _serviceBrand: undefined; + + register(): IDisposable { + return Disposable.None; + } + + async listModels(): Promise<IByokLmModelInfo[]> { + return []; + } + + getModels(): readonly IByokLmModelInfo[] { + return []; + } + + getServingConnection(): IByokLmBridgeConnection | undefined { + return undefined; + } + + onDidChangeModels(): IDisposable { + return Disposable.None; + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeAgent.ts b/src/vs/platform/agentHost/node/claude/claudeAgent.ts index a4a344525bc3e8..7baf144d22187c 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgent.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgent.ts @@ -9,28 +9,32 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { SequencerByKey } from '../../../../base/common/async.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../base/common/errors.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { Disposable, DisposableMap, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { IObservable, observableValue } 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 { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; import { createSchema, platformSessionSchema, schemaProperty } from '../../common/agentHostSchema.js'; import { ClaudePermissionMode, ClaudeSessionConfigKey, narrowClaudePermissionMode } from '../../common/claudeSessionConfigKeys.js'; import { createClaudeThinkingLevelSchema, isClaudeEffortLevel } from '../../common/claudeModelConfig.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; -import { AgentProvider, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo } from '../../common/agentService.js'; -import { ActionType } from '../../common/state/sessionActions.js'; +import { AgentProvider, AgentSession, AgentSignal, CLAUDE_AGENT_PROVIDER_ID, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, SubagentChatSignal } from '../../common/agentService.js'; +import { ensureWorkspacelessScratchDir } from '../workspacelessScratchDir.js'; +import { ActionType, AuthRequiredReason, type AuthRequiredParams } from '../../common/state/sessionActions.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { PolicyState, ProtectedResourceMetadata, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { isSubagentSession, parseSubagentSessionUri, ChatInputResponseKind, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { isSubagentSession, parseSubagentSessionUri, buildDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, isDefaultChatUri, ChatInputResponseKind, type ClientPluginCustomization, type Customization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { projectFromCopilotContext } from '../copilot/copilotGitProject.js'; @@ -42,7 +46,7 @@ import { getSubagentTranscript } from './claudeSubagentResolver.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { handleCanUseTool } from './claudeCanUseTool.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { createPricingMetaFromBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { tryParseClaudeModelId } from './claudeModelId.js'; import { resolvePromptToContentBlocks } from './claudePromptResolver.js'; import { IClaudeProxyHandle, IClaudeProxyService, type ClaudeTransport } from './claudeProxyService.js'; @@ -96,10 +100,10 @@ function toAgentModelInfo(m: CCAModel, provider: AgentProvider): IAgentModelInfo const supportedEfforts = ((supports as IClaudeModelSupports | undefined)?.reasoning_effort ?? []).filter(isClaudeEffortLevel); const configSchema = createClaudeThinkingLevelSchema(supportedEfforts); const policyState = m.policy?.state as PolicyState | undefined; - const billing = m.billing as ICAPIModelBilling | undefined; + const billing = normalizeCAPIBilling(m.billing); // priceCategory may appear as a top-level model field depending on the CAPI version. - const priceCategory = typeof (m as { modelPickerPriceCategory?: string }).modelPickerPriceCategory === 'string' - ? (m as { modelPickerPriceCategory?: string }).modelPickerPriceCategory + const priceCategory = typeof m.model_picker_price_category === 'string' + ? m.model_picker_price_category : undefined; return { provider, @@ -208,7 +212,7 @@ class ClaudeActiveClientHandle implements IActiveClient { * of any single review stays small. */ export class ClaudeAgent extends Disposable implements IAgent { - readonly id: AgentProvider = 'claude'; + readonly id: AgentProvider = CLAUDE_AGENT_PROVIDER_ID; private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>()); readonly onDidSessionProgress = this._onDidSessionProgress.event; @@ -216,6 +220,9 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _onDidCustomizationsChange = this._register(new Emitter<void>()); readonly onDidCustomizationsChange = this._onDidCustomizationsChange.event; + private readonly _onDidRequireAuth = this._register(new Emitter<Omit<AuthRequiredParams, 'channel'>>()); + readonly onDidRequireAuth = this._onDidRequireAuth.event; + private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []); readonly models: IObservable<readonly IAgentModelInfo[]> = this._models; @@ -255,6 +262,34 @@ export class ClaudeAgent extends Disposable implements IAgent { */ private readonly _sessions = this._register(new DisposableMap<string, ClaudeSessionEntry>()); + /** + * Live, in-memory peer-chat backings keyed by the chat's `ahp-chat` channel + * URI string. Populated by {@link createChat} on creation and by + * {@link materializeChat} on session restore (decoding the opaque + * `providerData` the orchestrator persisted). This is the live source of the + * `chatUri → sdkSessionId` mapping. + */ + private readonly _chatBackings = new Map<string, IPersistedChat>(); + + /** + * Fires when a peer chat's opaque `providerData` blob changes after creation + * (e.g. a per-chat model switch) so the orchestrator can re-persist the + * refreshed token. See {@link IAgent.onDidChangeChatData}. + */ + private readonly _onDidChangeChatData = this._register(new Emitter<IAgentChatDataChange>()); + readonly onDidChangeChatData: Event<IAgentChatDataChange> = this._onDidChangeChatData.event; + + /** + * Membership channel for chats the agent spawns itself — today the + * sub-agent chats delegated by a `Task`/`Agent` tool call (and, when the + * harness gains them, Claude Teams teammates). Derived from the + * `subagent_started` / `subagent_completed` signals that already flow on + * {@link onDidSessionProgress}, so the orchestrator records the spawn edge + * on the unified chat catalog. See {@link IAgent.onDidSpawnChat}. + */ + private readonly _onDidSpawnChat = this._register(new Emitter<IAgentSpawnChatEvent>()); + readonly onDidSpawnChat: Event<IAgentSpawnChatEvent> = this._onDidSpawnChat.event; + /** Stable active-client handles, keyed by `${sessionId}\0${clientId}`. */ private readonly _activeClientHandles = new Map<string, ClaudeActiveClientHandle>(); @@ -296,12 +331,103 @@ export class ClaudeAgent extends Disposable implements IAgent { private readonly _metadataStore: ClaudeSessionMetadataStore; /** - * Unified per-session lookup. Returns the session whether it is - * still provisional or already materialized; callers branch on + * Unified per-session lookup. Returns the session's default chat whether it + * is still provisional or already materialized; callers branch on * {@link ClaudeAgentSession.isPipelineReady} when behavior differs. */ private _findAnySession(sessionId: string): ClaudeAgentSession | undefined { - return this._sessions.get(sessionId)?.session; + return this._sessions.get(sessionId)?.defaultChat; + } + + /** + * Resolve the live {@link ClaudeAgentSession} for a chat — the session's + * default (main) chat, or an additional peer chat addressed by its + * `ahp-chat` channel URI — via a single uniform lookup in the owning + * session's chat map. Returns `undefined` when the session (or the chat) is + * not in memory. + */ + private _findChat(session: URI, chat: URI | undefined): ClaudeAgentSession | undefined { + const entry = this._sessions.get(AgentSession.id(session)); + if (!entry) { + return undefined; + } + return entry.getChat((chat ?? URI.parse(buildDefaultChatUri(session))).toString()); + } + + private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: ClaudeAgentSession | undefined; isPeerChat: boolean } { + // Accept either a chat channel URI or a bare session URI: per the AHP + // convention the default chat's URI equals the session URI, so callers + // that address the default chat by the session URI resolve here in one + // place rather than each operational method re-deriving it. + const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession)); + const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); + const sessionId = AgentSession.id(session); + const chatKey = chat.toString(); + const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey); + return { + session, + sessionId, + chatKey, + target: resolved?.chatSession, + isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session), + }; + } + + /** + * Resolve a live {@link ClaudeAgentSession} by its SDK chat id, + * searching every session entry's default chat and its peer chats. Used by + * SDK-id-addressed callbacks — proxy credit reports and the `canUseTool` + * permission bridge — which carry the SDK session id, not the chat URI. + */ + private _findSessionBySdkId(sdkSessionId: string): ClaudeAgentSession | undefined { + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.sessionId === sdkSessionId) { + return chat; + } + } + } + return undefined; + } + + /** Wrap a {@link ClaudeAgentSession} in a chat-leaf entry and forward its events. */ + private _wireEntry(session: ClaudeAgentSession): ClaudeSessionEntry { + const entry = new ClaudeSessionEntry(session); + entry.addDisposable(session.onDidSessionProgress(signal => { + this._onDidSessionProgress.fire(signal); + this._emitSpawnedChatEvents(signal); + })); + entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); + return entry; + } + + /** + * Create a session container seeding its default (main) chat as the first + * entry in the uniform chat map, keyed by the session's default-chat URI. + */ + private _seedSessionEntry(sessionId: string, session: URI, mainSession: ClaudeAgentSession): ClaudeSessionEntry { + const container = new ClaudeSessionEntry(); + container.setDefaultChat(buildDefaultChatUri(session), this._wireEntry(mainSession)); + this._sessions.set(sessionId, container); + return container; + } + + /** + * Bridges the agent's `subagent_started` signal onto the + * {@link onDidSpawnChat} membership channel. The signals are still forwarded + * verbatim on {@link onDidSessionProgress} (the orchestrator's + * `AgentSideEffects` keeps driving the sub-agent turn + parent tool-call + * content); this event only mirrors the spawn into the unified chat catalog. + * A completed subagent chat stays live and subscribable (it is removed only + * on session teardown), so there is no corresponding end event. The catalog + * add is idempotent so the overlap with the orchestrator's own membership + * sequencing is safe. + */ + private _emitSpawnedChatEvents(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onDidSpawnChat.fire(spawn); + } } constructor( @@ -311,9 +437,11 @@ export class ClaudeAgent extends Disposable implements IAgent { @IClaudeAgentSdkService private readonly _sdkService: IClaudeAgentSdkService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IAgentPluginManager private readonly _pluginManager: IAgentPluginManager, @IProductService private readonly _productService: IProductService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); this._metadataStore = _instantiationService.createInstance(ClaudeSessionMetadataStore, this.id); @@ -322,7 +450,7 @@ export class ClaudeAgent extends Disposable implements IAgent { // the originating session by the session id the proxy decoded from // the Bearer token, so the session can surface real per-turn credits. this._register(this._claudeProxyService.onDidReportCredits(e => { - this._findAnySession(e.sessionId)?.recordTurnCredits(e.totalNanoAiu); + this._findSessionBySdkId(e.sessionId)?.recordTurnCredits(e.totalNanoAiu); })); // Phase 19: resolve the transport mode now and re-resolve reactively. @@ -336,6 +464,18 @@ export class ClaudeAgent extends Disposable implements IAgent { if (next !== this._transportMode) { this._transportMode = next; void this._refreshModels(); + // Flipping into proxy makes GitHub Copilot auth newly required. + // If no proxy handle was ever established, proactively ask the + // client to authenticate rather than waiting for the next command + // to fail with `AHP_AUTH_REQUIRED`. A handle persists across a + // proxy→native→proxy round-trip (cleared only on dispose), so this + // fires only when a credential is genuinely missing. + if (next === 'proxy' && !this._proxyHandle) { + this._onDidRequireAuth.fire({ + resource: this._gitHubEndpointService.getCopilotResource().resource, + reason: AuthRequiredReason.Required, + }); + } } })); if (this._transportMode === 'native') { @@ -364,6 +504,7 @@ export class ClaudeAgent extends Disposable implements IAgent { provider: this.id, displayName: localize('claudeAgent.displayName', "Claude"), description: localize('claudeAgent.description', "Claude agent backed by the Anthropic Claude Agent SDK"), + capabilities: { multipleChats: { fork: true } }, }; } @@ -372,11 +513,11 @@ export class ClaudeAgent extends Disposable implements IAgent { // the Anthropic credential — so the required Copilot resource is dropped. // The optional repo resource is kept for git operations either way. if (this._transportMode !== 'proxy') { - return [GITHUB_REPO_PROTECTED_RESOURCE]; + return [this._gitHubEndpointService.getRepoResource()]; } return [ - GITHUB_COPILOT_PROTECTED_RESOURCE, - GITHUB_REPO_PROTECTED_RESOURCE, + this._gitHubEndpointService.getCopilotResource(), + this._gitHubEndpointService.getRepoResource(), ]; } @@ -401,10 +542,10 @@ export class ClaudeAgent extends Disposable implements IAgent { } async authenticate(resource: string, token: string): Promise<boolean> { - if (resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { + if (resource === this._gitHubEndpointService.getRepoResource().resource) { return true; } - if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { return false; } // Native (BYO-Anthropic) mode needs no proxy and no GitHub token. Record @@ -415,7 +556,7 @@ export class ClaudeAgent extends Disposable implements IAgent { return true; } const tokenChanged = this._githubToken !== token; - if (!tokenChanged) { + if (!tokenChanged && this._proxyHandle) { this._logService.info('[Claude] Auth token unchanged'); return true; } @@ -546,6 +687,14 @@ export class ClaudeAgent extends Disposable implements IAgent { return { session: sessionUri, workingDirectory: config.workingDirectory }; } + // A workspace-less session (no `workingDirectory` supplied, and not a + // fork) runs in a stable per-session scratch dir shared with the Copilot + // agent; without a cwd Claude throws at materialize. The workspace-less + // marker itself is owned/persisted centrally by the AH service. + const workingDirectory = config.workingDirectory ?? await ensureWorkspacelessScratchDir(this._environmentService.userHome, sessionId); + + // Only probe for a project when the caller supplied a real folder; a + // scratch dir is never a code project. const project = config.workingDirectory ? await projectFromCopilotContext({ cwd: config.workingDirectory.fsPath }, this._gitService) : undefined; @@ -555,7 +704,8 @@ export class ClaudeAgent extends Disposable implements IAgent { const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, - config.workingDirectory, + URI.parse(buildDefaultChatUri(sessionUri)), + workingDirectory, project, config.model, config.agent, @@ -565,14 +715,11 @@ export class ClaudeAgent extends Disposable implements IAgent { this._metadataStore, this._instantiationService, ); - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); - this._sessions.set(sessionId, entry); + this._seedSessionEntry(sessionId, sessionUri, session); return { session: sessionUri, - workingDirectory: config.workingDirectory, + workingDirectory, provisional: true, ...(project ? { project } : {}), }; @@ -668,9 +815,58 @@ export class ClaudeAgent extends Disposable implements IAgent { this._logService.info(`[Claude:${sessionId}] truncateSession removed all turns (deleteSession + fresh same-id)`); } + // ---- Chat surface ------------------------------------------------------ + // + // `chats` exposes the per-chat operations addressed by a single, + // concrete chat channel URI (the default chat channel or a peer/subagent + // URI). The default chat's SDK id is still the owning session id, derived + // inside the harness from the chat URI. + + /** + * The chat-addressed operation surface + * ({@link IAgentChats}). Every method addresses a chat by a single, + * already-resolved chat URI; this maps to the `(session, chat)` pair + * the agent's internal SDK storage is keyed by (via + * {@link _resolveChatTarget}). + */ + readonly chats: IAgentChats = { + createChat: (chat, options) => this._createChat(chat, options), + fork: (chat, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions) => + this._createChat(chat, { ...options, fork: source }), + disposeChat: chatUri => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this._disposeChat(session, chat); + }, + sendMessage: (chatUri, prompt, attachments, turnId, senderClientId) => { + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId); + }, + abort: chatUri => { + return this._abortSession(chatUri); + }, + changeModel: (chatUri, model) => { + return this._changeModel(chatUri, model); + }, + changeAgent: (chatUri, agent) => { + return this._changeAgent(chatUri, agent); + }, + getMessages: chat => this.getSessionMessages(chat), + }; + + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the agent's + * internal SDK storage is keyed by. A peer (or subagent) chat is addressed by + * its own `ahp-chat` channel URI, from which the owning session is recovered. + * The default chat is addressed by its deterministic chat channel URI. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Claude chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat }; + } + /** - * Fork an existing session at a protocol `turnId` (keep `[0..N]` - * INCLUSIVE) into a new, non-provisional session. The SDK `Query` is * NOT started here (CONTEXT M9): `forkSession` writes the transcript to * disk and we return; the `Query` materializes lazily on the first * {@link sendMessage} via {@link _resumeSession}. `turnId` is translated @@ -738,6 +934,19 @@ export class ClaudeAgent extends Disposable implements IAgent { }); } + /** + * Builds the SDK `canUseTool` permission bridge for a session/chat. The + * resolver searches both default chats and peer chats by SDK id so a peer + * chat's tool-permission requests reach its own pending-permission registry. + */ + private _makeCanUseTool(sdkSessionId: string): NonNullable<Options['canUseTool']> { + return (toolName, input, options) => + handleCanUseTool( + { getSession: id => this._findSessionBySdkId(id), configurationService: this._configurationService }, + sdkSessionId, toolName, input, options, + ); + } + /** * Promote a provisional {@link ClaudeAgentSession} into a live one. * Called from {@link sendMessage} inside the {@link _sessionSequencer.queue} @@ -763,11 +972,7 @@ export class ClaudeAgent extends Disposable implements IAgent { } const transport = this._ensureAuthenticated(); - const canUseTool: NonNullable<Options['canUseTool']> = (toolName, input, options) => - handleCanUseTool( - { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, - sessionId, toolName, input, options, - ); + const canUseTool = this._makeCanUseTool(sessionId); try { await session.materialize({ transport, canUseTool, isResume: false, serverToolHost: this._serverToolHost }); @@ -829,6 +1034,7 @@ export class ClaudeAgent extends Disposable implements IAgent { const session = ClaudeAgentSession.createProvisional( sessionId, sessionUri, + URI.parse(buildDefaultChatUri(sessionUri)), workingDirectory, project, overlay.model, @@ -839,16 +1045,9 @@ export class ClaudeAgent extends Disposable implements IAgent { this._metadataStore, this._instantiationService, ); - const entry = new ClaudeSessionEntry(session); - entry.addDisposable(session.onDidSessionProgress(signal => this._onDidSessionProgress.fire(signal))); - entry.addDisposable(session.onDidCustomizationsChange(() => this._onDidCustomizationsChange.fire())); - this._sessions.set(sessionId, entry); + this._seedSessionEntry(sessionId, sessionUri, session); - const canUseTool: NonNullable<Options['canUseTool']> = (toolName, input, options) => - handleCanUseTool( - { getSession: id => this._findAnySession(id), configurationService: this._configurationService }, - sessionId, toolName, input, options, - ); + const canUseTool = this._makeCanUseTool(sessionId); try { await session.materialize({ transport, canUseTool, isResume: true, serverToolHost: this._serverToolHost }); @@ -880,22 +1079,387 @@ export class ClaudeAgent extends Disposable implements IAgent { disposeSession(session: URI): Promise<void> { // Routed through {@link _disposeSequencer} so a concurrent // {@link shutdown} already serializing teardown for this same - // session id awaits this work first (and vice versa). Phase 6 - // adds a provisional branch: when the session has not yet been - // materialized, abort the controller (unblocks any racing - // `await sdk.startup()`) and drop the record. No SDK contact, + // session id awaits this work first (and vice versa). When the session + // has not yet been materialized, abort the controller (unblocks any + // racing `await sdk.startup()`) and drop the record. No SDK contact, // no DB write — symmetric with `createSession`. const sessionId = AgentSession.id(session); return this._disposeSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); - if (sess && !sess.isPipelineReady) { - sess.abortController.abort(); - } - this._sessions.deleteAndDispose(sessionId); + await this._teardownEntry(sessionId); this._pruneActiveClientHandles(sessionId); }); } + /** + * Abort and dispose a session entry — its default chat and every peer chat. + * Each peer teardown serializes on the peer's own {@link _sessionSequencer} + * key so it waits for any in-flight materialize/send rather than disposing + * the chat under it. + */ + private async _teardownEntry(sessionId: string): Promise<void> { + const entry = this._sessions.get(sessionId); + if (!entry) { + return; + } + const defaultChat = entry.defaultChat; + if (defaultChat && !defaultChat.isPipelineReady) { + defaultChat.abortController.abort(); + } + await Promise.all(entry.peerChatKeys().map(chatKey => + this._sessionSequencer.queue(chatKey, async () => { + const peer = entry.getPeerChat(chatKey); + if (peer) { + if (!peer.isPipelineReady) { + peer.abortController.abort(); + } else { + peer.abort(); + } + } + entry.disposePeerChat(chatKey); + }) + )); + this._sessions.deleteAndDispose(sessionId); + // Drop the live backings for this session's peer chats. The chat URI + // encodes its parent session, so we recover it via `parseChatUri`. + for (const chatKey of [...this._chatBackings.keys()]) { + const parsed = parseChatUri(URI.parse(chatKey)); + if (parsed && AgentSession.id(URI.parse(parsed.session)) === sessionId) { + this._chatBackings.delete(chatKey); + } + } + } + + // #region Multi-chat — additional (non-default) peer chats + + /** + * Create an additional peer chat within an existing session. The new chat + * is backed by its own SDK chat (a fresh one, or a fork of the + * source chat at a turn) that shares the parent session's working directory + * and inherited model / agent / permission-mode parentSession. The backing is + * recorded in the live {@link _chatBackings} map and returned as an opaque + * `providerData` blob for the orchestrator to persist; the chat's metadata + * overlay is seeded so a later lazy resume inherits the parent parentSession. The + * live {@link ClaudeAgentSession} is built lazily on the chat's first send + * (mirroring how default sessions materialize lazily). + */ + private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> { + this._ensureAuthenticated(); + if (isDefaultChatUri(chat)) { + return; + } + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`[Claude] createChat: malformed chat URI ${chat.toString()}`); + } + const session = URI.parse(parsed.session); + const chatKey = chat.toString(); + const parentSessionId = AgentSession.id(session); + let result: IAgentCreateChatResult | undefined; + await this._sessionSequencer.queue(parentSessionId, async () => { + const existing = this._chatBackings.get(chatKey); + if (existing) { + // Idempotent re-create: hand back the existing backing so the + // orchestrator re-persists a consistent blob. + result = { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) }; + return; + } + const parentSession = await this._resolveParentSession(session, parentSessionId); + const model = options?.model ?? parentSession.model; + + let sdkSessionId: string | undefined; + if (options?.fork) { + // If the fork point can't be resolved, fall through to a fresh + // chat rather than inheriting the whole source backend. + sdkSessionId = await this._forkChat(session, options.fork); + } + sdkSessionId ??= generateUuid(); + + // Record the live backing and hand the opaque blob back to the + // orchestrator to persist. + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + this._chatBackings.set(chatKey, backing); + result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; + + // Seed the chat's own metadata overlay so a later lazy resume (this + // process or a restart) inherits the parent's parentSession. + await this._metadataStore.write(chat, { + ...(model ? { model } : {}), + ...(parentSession.agent ? { agent: parentSession.agent } : {}), + ...(parentSession.permissionMode ? { permissionMode: parentSession.permissionMode } : {}), + }); + this._logService.info(`[Claude] Created additional chat ${chat.toString()} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); + }); + return result; + } + + /** + * Dispose an additional peer chat, tearing down its live chat (if + * any) and dropping its live backing. The default chat cannot be disposed in + * isolation — it lives and dies with the session. + * + * Routed through {@link _sessionSequencer} (keyed on the chat URI) so it + * waits for any in-flight {@link _materializeChatLocked} or + * {@link sendMessage} to finish before tearing down — prevents + * use-after-dispose if a send is concurrently in progress. The durable + * peer-chat catalog is owned by the orchestrator now, so this only drops the + * live backing and chat. + */ + private async _disposeChat(session: URI, chat: URI): Promise<void> { + if (isDefaultChatUri(chat)) { + return; + } + const chatKey = chat.toString(); + const parentSessionId = AgentSession.id(session); + await this._sessionSequencer.queue(chatKey, async () => { + const entry = this._sessions.get(parentSessionId); + const peer = entry?.getPeerChat(chatKey); + if (peer) { + if (!peer.isPipelineReady) { + peer.abortController.abort(); + } else { + peer.abort(); + } + entry!.disposePeerChat(chatKey); + } + this._chatBackings.delete(chatKey); + }); + // The Claude SDK exposes no delete-chat RPC, so the forked / + // fresh transcript is left on disk; without a catalog entry it is never + // resumed again. + } + + /** + /** + * Resolve the inherited session settings (working directory, project, model, agent, + * permission mode) a new or resumed peer chat copies from its parent + * session. Prefers the live in-memory parent; falls back to the SDK's + * on-disk session record + metadata overlay for an unloaded parent. + */ + private async _resolveParentSession(session: URI, parentSessionId: string): Promise<{ workingDirectory: URI; project: IAgentSessionProjectInfo | undefined; model: ModelSelection | undefined; agent: AgentSelection | undefined; permissionMode: ClaudePermissionMode }> { + const parent = this._findAnySession(parentSessionId); + let workingDirectory = parent?.workingDirectory; + let project = parent?.project; + if (!workingDirectory) { + const sdkInfo = await this._sdkService.getSessionInfo(parentSessionId); + workingDirectory = sdkInfo?.cwd ? URI.file(sdkInfo.cwd) : undefined; + } + if (!workingDirectory) { + throw new Error(`[Claude] createChat: cannot resolve working directory for parent session ${session.toString()}`); + } + if (!project) { + try { + project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + } catch (err) { + this._logService.warn(`[Claude] createChat: project resolution failed for ${session.toString()}; continuing without project`, err); + } + } + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(session); + } catch (err) { + this._logService.warn(`[Claude] createChat: parent overlay read failed for ${session.toString()}; continuing with defaults`, err); + } + const permissionMode = readClaudePermissionMode(this._configurationService, session) ?? overlay.permissionMode ?? 'default'; + return { workingDirectory, project, model: overlay.model, agent: overlay.agent, permissionMode }; + } + + /** + * Fork the source chat's SDK chat at the requested turn into a new + * chat and return its SDK session id. Returns `undefined` (so the + * caller creates a fresh chat instead) when the source chat or the + * fork anchor cannot be resolved. + */ + private async _forkChat(session: URI, fork: IAgentCreateChatOptions['fork'] & {}): Promise<string | undefined> { + const sourceSdkId = await this._resolveChatSdkId(session, fork.source); + if (!sourceSdkId) { + this._logService.warn(`[Claude] createChat fork: source ${fork.source.toString()} has no SDK chat; creating fresh chat`); + return undefined; + } + const messages = await this._sdkService.getSessionMessages(sourceSdkId, { includeSystemMessages: true }); + const upToMessageId = resolveForkAnchorUuid(messages, fork.turnId); + if (upToMessageId === undefined) { + this._logService.warn(`[Claude] createChat fork: turn ${fork.turnId} not found in source ${sourceSdkId}; creating fresh chat`); + return undefined; + } + const { sessionId } = await this._sdkService.forkSession(sourceSdkId, { upToMessageId }); + return sessionId; + } + + /** + * Resolve the SDK chat id backing a chat URI — the session's + * default chat (the parent session's own id) or an additional peer chat + * (from the in-memory entry, else the live/legacy backing). + */ + private async _resolveChatSdkId(session: URI, chatUri: URI): Promise<string | undefined> { + if (isDefaultChatUri(chatUri) || chatUri.toString() === session.toString()) { + return AgentSession.id(session); + } + const inMemory = this._findChat(session, chatUri)?.sessionId; + if (inMemory) { + return inMemory; + } + return this._resolveChatBacking(chatUri)?.sdkSessionId; + } + + /** + * Resolves the live backing for a peer chat from the in-memory + * {@link _chatBackings} map. Returns `undefined` for a chat that has not been + * materialized via {@link materializeChat}. + */ + private _resolveChatBacking(chat: URI): IPersistedChat | undefined { + return this._chatBackings.get(chat.toString()); + } + + /** + * Return the in-memory entry for a session, creating a provisional (not yet + * materialized) default chat to host its peer chats if none exists — e.g. a + * peer chat is sent to after a restart before the default chat is touched. + * Serialized on the session id so concurrent peer sends share one entry. + */ + private _ensureSessionEntry(session: URI): Promise<ClaudeSessionEntry> { + const sessionId = AgentSession.id(session); + return this._sessionSequencer.queue(sessionId, async () => { + const existing = this._sessions.get(sessionId); + if (existing) { + return existing; + } + const parentSession = await this._resolveParentSession(session, sessionId); + const mainSession = ClaudeAgentSession.createProvisional( + sessionId, + session, + URI.parse(buildDefaultChatUri(session)), + parentSession.workingDirectory, + parentSession.project, + parentSession.model, + parentSession.agent, + undefined, + new PendingRequestRegistry<CallToolResult>(), + parentSession.permissionMode, + this._metadataStore, + this._instantiationService, + ); + return this._seedSessionEntry(sessionId, session, mainSession); + }); + } + + /** + * Build + materialize the peer chat's live {@link ClaudeAgentSession}, + * resuming its persisted SDK chat when one already exists on disk + * (forked or restored chats) or starting fresh otherwise. The caller MUST + * hold the per-chat (`chat.toString()`) {@link _sessionSequencer} lock so + * concurrent first sends collapse into one materialize and teardown can't + * race the build. + */ + private async _materializeChatLocked(session: URI, chat: URI): Promise<ClaudeAgentSession> { + const chatKey = chat.toString(); + const entry = await this._ensureSessionEntry(session); + const existing = entry.getPeerChat(chatKey); + if (existing?.isPipelineReady) { + return existing; + } + const chatSession = existing ?? await this._buildProvisionalChat(session, chat, entry); + // Resume when the SDK already has a transcript for this chat + // (forked or restored); otherwise materialize a fresh one. + const sdkInfo = await this._sdkService.getSessionInfo(chatSession.sessionId); + const transport = this._ensureAuthenticated(); + const canUseTool = this._makeCanUseTool(chatSession.sessionId); + try { + await chatSession.materialize({ transport, canUseTool, isResume: !!sdkInfo, serverToolHost: this._serverToolHost }); + } catch (err) { + entry.disposePeerChat(chatKey); + throw err; + } + return chatSession; + } + + /** + * Build a provisional peer-chat {@link ClaudeAgentSession} from its live (or + * legacy) backing + overlay: its `sessionUri` is the real parent session URI + * and its `chatChannelUri` is the chat's own channel (never overloaded), + * backed by the resolved SDK chat id. Registers it on the owning + * {@link ClaudeSessionEntry}; the caller materializes it. + */ + private async _buildProvisionalChat(session: URI, chat: URI, entry: ClaudeSessionEntry): Promise<ClaudeAgentSession> { + const info = this._resolveChatBacking(chat); + if (!info) { + throw new Error(`[Claude] no backing chat for chat ${chat.toString()}`); + } + const parentSession = await this._resolveParentSession(session, AgentSession.id(session)); + let overlay: IClaudeSessionOverlay = {}; + try { + overlay = await this._metadataStore.read(chat); + } catch (err) { + this._logService.warn(`[Claude] chat overlay read failed for ${chat.toString()}; continuing with defaults`, err); + } + const permissionMode = readClaudePermissionMode(this._configurationService, chat) ?? overlay.permissionMode ?? parentSession.permissionMode; + // Overlay takes precedence over the backing: `changeModel` always writes + // the overlay first (via `setModel` or `_metadataStore.write`) and then + // the backing. If the backing update is lost, the overlay already holds + // the newest model; preferring it here ensures a model change is never + // silently reverted after a restart. + const model = overlay.model ?? info.model; + const chatSession = ClaudeAgentSession.createProvisional( + info.sdkSessionId, + session, + chat, + parentSession.workingDirectory, + parentSession.project, + model, + overlay.agent ?? parentSession.agent, + undefined, + new PendingRequestRegistry<CallToolResult>(), + permissionMode, + this._metadataStore, + this._instantiationService, + ); + entry.registerPeerChat(chat.toString(), this._wireEntry(chatSession)); + return chatSession; + } + + /** + * Update a peer chat's live backing model and push the refreshed opaque + * `providerData` blob to the orchestrator (via + * {@link onDidChangeChatData}) so the durable catalog stays in sync. + */ + private async _updateChatBackingModel(chat: URI, model: ModelSelection): Promise<void> { + const backing = this._resolveChatBacking(chat); + if (!backing) { + return; + } + const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + this._chatBackings.set(chat.toString(), updated); + this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); + } + + /** + * Re-attach the in-memory backing for a peer chat on session restore, + * decoding the opaque `providerData` the orchestrator persisted at creation + * (or the latest {@link onDidChangeChatData}). After this resolves the + * chat's backing SDK chat can be resumed lazily on its first send. + * Best-effort — a corrupt/unknown blob is logged and dropped rather than + * thrown. + */ + async materializeChat(chat: URI, providerData: string | undefined): Promise<void> { + if (isDefaultChatUri(chat)) { + return; + } + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return; + } + if (providerData === undefined) { + return; + } + const backing = decodeProviderData(providerData); + if (!backing) { + this._logService.warn(`[Claude] materializeChat: dropping corrupt providerData for ${chat.toString()}`); + return; + } + this._chatBackings.set(chat.toString(), backing); + } + + // #endregion + /** * Test-only accessor for the materialized {@link ClaudeAgentSession}. * Phase 6 section 5.1 Test 10 needs to inspect `_isResumed` directly because @@ -905,7 +1469,7 @@ export class ClaudeAgent extends Disposable implements IAgent { * existence; the protocol surface (`IAgent`) does not include it. */ getSessionForTesting(session: URI): ClaudeAgentSession | undefined { - const sess = this._sessions.get(AgentSession.id(session))?.session; + const sess = this._sessions.get(AgentSession.id(session))?.defaultChat; return sess?.isPipelineReady ? sess : undefined; } @@ -918,14 +1482,22 @@ export class ClaudeAgent extends Disposable implements IAgent { * and returns `[]` rather than propagating — mirrors `listSessions`. */ async getSessionMessages(session: URI): Promise<readonly Turn[]> { - const sessionId = AgentSession.id(session); - const sess = this._findAnySession(sessionId); - if (sess && !sess.isPipelineReady) { + // Don't trigger a cold SDK download just to reconstruct a transcript + // during restore (the renderer subscribes to the last-active session + // on startup). Mirrors `listSessions` / `getSessionMetadata`: when the + // SDK isn't local yet, defer with an empty transcript. The download + // fires (with host-level progress) once the user sends the first + // message, after which the transcript re-hydrates on the next restore. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session messages until a session triggers the download'); return []; } + // Additional peer chat: reconstruct its own SDK chat (resolved + // from the catalog/in-memory), routed to the chat channel URI. Shares + // the same fetch+map path as the default chat via `_reconstructTurns`. if (isSubagentSession(session)) { const parsed = parseSubagentSessionUri(session); - const parentSession = parsed ? this._sessions.get(AgentSession.id(parsed.parentSession))?.session : undefined; + const parentSession = parsed ? this._sessions.get(AgentSession.id(parsed.parentSession))?.defaultChat : undefined; if (!parentSession) { // Parent session is gone (disposed or never materialized). // The registry that holds the agentId cache lives on the @@ -940,32 +1512,62 @@ export class ClaudeAgent extends Disposable implements IAgent { return []; } } - const parentSession = this._sessions.get(sessionId)?.session; + + const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return []; + } + const parentSessionUri = URI.parse(chatInfo.session); + const sessionId = AgentSession.id(parentSessionUri); + const context = this._getChatContext(chat); + if (context.isPeerChat) { + const sdkId = await this._resolveChatSdkId(parentSessionUri, chat); + if (!sdkId) { + return []; + } + return this._reconstructTurns(sdkId, chat, context.target); + } + + const sess = context.target; + if (sess && !sess.isPipelineReady) { + return []; + } + // Default chat: its SDK chat id is the session id. + return this._reconstructTurns(sessionId, parentSessionUri, sess); + } + + /** + * Fetch a chat's SDK transcript ({@link sdkSessionId}) and map it to + * protocol {@link Turn}s routed to {@link routingUri} (the session or chat + * channel URI). When {@link primeOn} is supplied (the materialized owning + * session), its subagent registry is primed from the agentId suffixes the + * SDK encoded in Task tool_result blocks. Resilient: any failure warn-logs + * and returns `[]` rather than propagating. + */ + private async _reconstructTurns(sdkSessionId: string, routingUri: URI, primeOn: ClaudeAgentSession | undefined): Promise<readonly Turn[]> { let messages; try { - messages = await this._sdkService.getSessionMessages(sessionId, { includeSystemMessages: true }); + messages = await this._sdkService.getSessionMessages(sdkSessionId, { includeSystemMessages: true }); } catch (err) { - this._logService.warn(`[Claude] getSessionMessages SDK fetch failed for ${sessionId}`, err); + this._logService.warn(`[Claude] getSessionMessages SDK fetch failed for ${sdkSessionId}`, err); return []; } let turns: readonly Turn[]; try { - turns = mapSessionMessagesToTurns(messages, session, this._logService); + turns = mapSessionMessagesToTurns(messages, routingUri, this._logService); } catch (err) { // Defensive boundary: a single malformed SDK message must not // blow up the entire transcript read. - this._logService.warn(`[Claude] replay mapper threw for ${sessionId}`, err); + this._logService.warn(`[Claude] replay mapper threw for ${sdkSessionId}`, err); return []; } - // If the parent session is materialized, prime its registry from - // any agentId suffixes the SDK encoded in Task tool_result text - // blocks so subsequent subagent transcript reads can short-circuit - // the strategy chain. A bug in `primeFromTranscript` MUST NOT - // break an otherwise-successful parent transcript read. + // A bug in `primeFromTranscript` MUST NOT break an otherwise-successful + // transcript read. try { - parentSession?.subagents.primeFromTranscript(turns); + primeOn?.subagents.primeFromTranscript(turns); } catch (err) { - this._logService.warn(`[Claude] primeFromTranscript threw for ${sessionId}`, err); + this._logService.warn(`[Claude] primeFromTranscript threw for ${sdkSessionId}`, err); } return turns; } @@ -990,6 +1592,15 @@ export class ClaudeAgent extends Disposable implements IAgent { // sibling Copilot provider gets nuked too. Catch and log instead. let sdkEntries: readonly SDKSessionInfo[]; try { + // Don't trigger a cold SDK download just to populate the session + // list at startup. When the SDK isn't local yet, surface an empty + // list; the download fires (with host-level progress) once the user + // starts a session, and the next `listSessions` — driven by the + // renderer's post-turn refresh — returns the full list. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session list until a session triggers the download'); + return []; + } sdkEntries = await this._sdkService.listSessions(); } catch (err) { this._logService.warn('[Claude] SDK listSessions failed; surfacing empty list', err); @@ -1022,6 +1633,16 @@ export class ClaudeAgent extends Disposable implements IAgent { * fetch and should learn that the SDK module is broken). */ async getSessionMetadata(session: URI): Promise<IAgentSessionMetadata | undefined> { + // Don't trigger a cold SDK download just to hydrate session metadata + // during restore (the renderer subscribes to the last-active session + // on startup). Mirrors `listSessions` / `getSessionMessages`: when the + // SDK isn't local yet, defer. The download fires (with host-level + // progress) once the user sends the first message, after which the + // session re-hydrates on the next restore. + if (!(await this._sdkService.canLoadWithoutDownload())) { + this._logService.info('[Claude] SDK not downloaded yet; deferring session metadata until a session triggers the download'); + return undefined; + } const sessionId = AgentSession.id(session); const sdkInfo = await this._sdkService.getSessionInfo(sessionId); if (!sdkInfo) { @@ -1114,98 +1735,129 @@ export class ClaudeAgent extends Disposable implements IAgent { // not a fresh outer-async wrapper around it. return this._shutdownPromise ??= (async () => { for (const entry of this._sessions.values()) { - if (!entry.session.isPipelineReady) { - entry.session.abortController.abort(); + // Provisional chats (a default or peer whose first send's + // materialize is in-flight) race on their own abort controller — + // abort them up front so a queued `sdk.startup()` unwinds + // promptly rather than running past shutdown until its teardown + // task dequeues. + for (const chat of entry.allChatSessions()) { + if (!chat.isPipelineReady) { + chat.abortController.abort(); + } } } const sessionIds = [...this._sessions.keys()]; await Promise.all(sessionIds.map(sessionId => this._disposeSequencer.queue(sessionId, async () => { - this._sessions.deleteAndDispose(sessionId); + await this._teardownEntry(sessionId); this._pruneActiveClientHandles(sessionId); }) )); })(); } - async sendMessage(sessionUri: URI, _chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise<void> { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise<void> { + // `IAgent.sendMessage` declares `turnId?` but every production caller in + // `AgentSideEffects` supplies one. Generate a fallback so the + // session-side `QueuedRequest.turnId: string` invariant holds even if a + // hypothetical caller forgets it. + const effectiveTurnId = turnId ?? generateUuid(); + const context = this._getChatContext(chat); + + // Additional peer chat: route to its own chat. Its SDK + // `session_id` is the chat's chat id, NOT the parent session's. + // Hold the per-chat lock across BOTH materialize and send (mirroring the + // default-chat path below) so concurrent sends to the same peer chat + // serialize and a racing disposeChat/disposeSession (which queue on the + // same chat key) waits for the in-flight turn instead of disposing the + // session under it. + if (context.isPeerChat) { + return this._sessionSequencer.queue(context.chatKey, async () => { + const chatSession = await this._materializeChatLocked(context.session, chat); + await chatSession.send(this._buildSdkPrompt(chatSession.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId); + }); + } + // Plan section 3.8. The sequencer scope holds across BOTH materialize // and `session.send` so two concurrent first-message calls on the // same session collapse into one materialize plus two ordered // sends. A `disposeSession` racing a first send reaches its own // dispose-sequencer eventually but the in-flight materialize // completes first. - const sessionId = AgentSession.id(sessionUri); - // `IAgent.sendMessage` declares `turnId?` (agentService.ts:424) but - // every production caller in `AgentSideEffects` supplies one. Generate - // a fallback so the session-side `QueuedRequest.turnId: string` - // invariant holds even if a hypothetical caller forgets it. - const effectiveTurnId = turnId ?? generateUuid(); - return this._sessionSequencer.queue(sessionId, async () => { - const existing = this._findAnySession(sessionId); + return this._sessionSequencer.queue(context.sessionId, async () => { + const existing = this._getChatContext(chat).target; let session: ClaudeAgentSession; if (existing?.isPipelineReady) { session = existing; } else if (existing) { - session = await this._materializeProvisional(sessionId); + session = await this._materializeProvisional(context.sessionId); } else { - session = await this._resumeSession(sessionId, sessionUri); + session = await this._resumeSession(context.sessionId, context.session); } - const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); - const sdkPrompt: SDKUserMessage = { - type: 'user', - message: { role: 'user', content: contentBlocks }, - session_id: sessionId, - parent_tool_use_id: null, - // M1 / Glossary: `Turn.id ↔ SDKUserMessage.uuid`. The SDK - // types this as a branded `${string}-…` template-literal - // alias of Node's `crypto.UUID`; cast at the boundary - // rather than threading the brand up to every caller. - // Mirrors the reference extension at - // `extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts:585`. - uuid: effectiveTurnId as `${string}-${string}-${string}-${string}-${string}`, - }; - - await session.send(sdkPrompt, effectiveTurnId); + await session.send(this._buildSdkPrompt(context.sessionId, prompt, attachments, effectiveTurnId), effectiveTurnId); }); } + /** Builds the SDK user message for a send, addressed to `sdkSessionId`. */ + private _buildSdkPrompt(sdkSessionId: string, prompt: string, attachments: readonly MessageAttachment[] | undefined, turnId: string): SDKUserMessage { + const contentBlocks = resolvePromptToContentBlocks(prompt, attachments); + return { + type: 'user', + message: { role: 'user', content: contentBlocks }, + session_id: sdkSessionId, + parent_tool_use_id: null, + // M1 / Glossary: `Turn.id ↔ SDKUserMessage.uuid`. The SDK types this + // as a branded `${string}-…` template-literal alias of Node's + // `crypto.UUID`; cast at the boundary rather than threading the brand + // up to every caller. + uuid: turnId as `${string}-${string}-${string}-${string}-${string}`, + }; + } + respondToPermissionRequest(requestId: string, approved: boolean): void { // `requestId` is the SDK's `tool_use_id` — globally unique, so a - // single matching session is all we need. Silent on miss - // (workbench may have raced a session dispose). - for (const entry of this._sessions.values()) { - if (entry.session.respondToPermissionRequest(requestId, approved)) { + // single matching chat is all we need. Silent on miss (workbench may + // have raced a session dispose). + for (const sess of this._allLiveSessions()) { + if (sess.respondToPermissionRequest(requestId, approved)) { return; } } } respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record<string, ChatInputAnswer>): void { - // `requestId` is the SDK's `tool_use_id` (interactive tools - // reuse it as the {@link ChatInputRequest.id}); globally - // unique, so a single matching session is all we need. Silent - // on miss for the same reasons as `respondToPermissionRequest`. - for (const entry of this._sessions.values()) { - if (entry.session.respondToUserInputRequest(requestId, response, answers)) { + // `requestId` is the SDK's `tool_use_id` (interactive tools reuse it as + // the {@link ChatInputRequest.id}); globally unique, so a single + // matching chat is all we need. Silent on miss for the same reasons as + // {@link respondToPermissionRequest}. + for (const sess of this._allLiveSessions()) { + if (sess.respondToUserInputRequest(requestId, response, answers)) { return; } } } - async abortSession(session: URI): Promise<void> { + /** Every live chat — each session's default chat and its peers. */ + private _allLiveSessions(): ClaudeAgentSession[] { + const all: ClaudeAgentSession[] = []; + for (const entry of this._sessions.values()) { + all.push(...entry.allChatSessions()); + } + return all; + } + + private async _abortSession(chat: URI): Promise<void> { // Phase 9 D1: cancel via the abort controller, NOT `Query.interrupt()`. // Abort is a control-plane operation — it must NOT serialize // through `_sessionSequencer` because an in-flight `sendMessage` // task is parked on its turn deferred and would deadlock the abort // behind the very turn it's trying to cancel. Calling - // `entry.session.abort()` directly rejects the in-flight deferred, + // `chat.abort()` directly rejects the in-flight deferred, // which lets the queued sendMessage task complete and frees the // sequencer for the next caller. - const sessionId = AgentSession.id(session); - const sess = this._findAnySession(sessionId); + const sess = this._getChatContext(chat).target; if (!sess) { return; } @@ -1216,34 +1868,41 @@ export class ClaudeAgent extends Disposable implements IAgent { sess.abort(); } - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[], chat?: URI): void { // Phase 9 D5: queued messages are intentionally a no-op. CONTEXT.md // M10 + AgentSideEffects confirm queued messages are consumed // server-side; the agent boundary always receives an empty queue. - const sessionId = AgentSession.id(session); - this._logService.info(`[Claude:${sessionId}] setPendingMessages called: steering=${steeringMessage?.id ?? 'none'} queued=${_queuedMessages.length}`); - const entry = this._sessions.get(sessionId); - if (!entry) { - this._logService.warn(`[Claude:${sessionId}] setPendingMessages: session not found`); + // + // Steering targets the chat that owns the in-flight turn: an additional + // peer chat is addressed by its `chat` channel URI, the default chat by + // the session URI. + const isPeerChat = !!chat && !isDefaultChatUri(chat); + const target = this._findChat(session, chat); + this._logService.info(`[Claude] setPendingMessages for ${(chat ?? session).toString()}: steering=${steeringMessage?.id ?? 'none'} queued=${_queuedMessages.length}`); + if (!target) { + this._logService.warn(`[Claude] setPendingMessages: ${isPeerChat ? 'chat' : 'session'} not found for ${(chat ?? session).toString()}`); return; } if (steeringMessage) { - entry.session.injectSteering(steeringMessage); + target.injectSteering(steeringMessage); } } - async changeModel(session: URI, model: ModelSelection): Promise<void> { - // Session owns its own provisional/runtime branching and metadata - // write (see {@link ClaudeAgentSession.setModel}). The agent only - // covers the "external-only session" case where there is no - // in-memory record to delegate to. - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); + private async _changeModel(chat: URI, model: ModelSelection): Promise<void> { + const context = this._getChatContext(chat); + const queueKey = context.isPeerChat ? context.chatKey : context.sessionId; + await this._sessionSequencer.queue(queueKey, async () => { + const current = this._getChatContext(chat); + const sess = current.target; if (sess) { await sess.setModel(model); + } else if (current.isPeerChat) { + await this._metadataStore.write(chat, { model }); } else { - await this._metadataStore.write(session, { model }); + await this._metadataStore.write(current.session, { model }); + } + if (current.isPeerChat) { + await this._updateChatBackingModel(chat, model); } }); } @@ -1254,16 +1913,19 @@ export class ClaudeAgent extends Disposable implements IAgent { * provisional/runtime branching and metadata write * (see {@link ClaudeAgentSession.setAgent}). For external-only * sessions (no in-memory record), the agent is persisted directly to - * the overlay so a later resume picks it up. + * the overlay so a later resume picks it up. When `chat` is an additional + * peer chat, the change targets that chat's chat. */ - async changeAgent(session: URI, agent: AgentSelection | undefined): Promise<void> { - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const sess = this._findAnySession(sessionId); + private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise<void> { + const context = this._getChatContext(chat); + const queueKey = context.isPeerChat ? context.chatKey : context.sessionId; + await this._sessionSequencer.queue(queueKey, async () => { + const current = this._getChatContext(chat); + const sess = current.target; if (sess) { await sess.setAgent(agent); } else { - await this._metadataStore.write(session, { agent: agent ?? null }); + await this._metadataStore.write(current.isPeerChat ? chat : current.session, { agent: agent ?? null }); } }); } @@ -1325,7 +1987,7 @@ export class ClaudeAgent extends Disposable implements IAgent { const entry = this._sessions.get(sessionId); // `AgentSideEffects` forwards every `ChatToolCallComplete` envelope // (including SDK-owned tools); silent on miss is the expected path. - entry?.session.completeClientToolCall(toolCallId, result); + entry?.defaultChat?.completeClientToolCall(toolCallId, result); } async syncClientCustomizations(session: URI, clientId: string, customizations: ClientPluginCustomization[]): Promise<ISyncedCustomization[]> { @@ -1370,7 +2032,7 @@ export class ClaudeAgent extends Disposable implements IAgent { setCustomizationEnabled(id: string, enabled: boolean): void { for (const entry of this._sessions.values()) { - entry.session.setClientCustomizationEnabled(id, enabled); + entry.defaultChat.setClientCustomizationEnabled(id, enabled); } } @@ -1394,6 +2056,16 @@ export class ClaudeAgent extends Disposable implements IAgent { return sess ? await sess.getSessionCustomizations() : []; } + async startMcpServer(session: URI, id: string): Promise<void> { + const sess = this._findAnySession(AgentSession.id(session)); + await sess?.startMcpServer(id); + } + + async stopMcpServer(session: URI, id: string): Promise<void> { + const sess = this._findAnySession(AgentSession.id(session)); + await sess?.stopMcpServer(id); + } + // #endregion override dispose(): void { @@ -1423,8 +2095,10 @@ export class ClaudeAgent extends Disposable implements IAgent { // wrapper-before-proxy ordering invariant. This is locked by // test "dispose disposes the proxy handle and is idempotent". for (const entry of this._sessions.values()) { - if (!entry.session.isPipelineReady) { - entry.session.abortController.abort(); + for (const chat of entry.allChatSessions()) { + if (!chat.isPipelineReady) { + chat.abortController.abort(); + } } } super.dispose(); @@ -1436,25 +2110,19 @@ export class ClaudeAgent extends Disposable implements IAgent { } /** - * Bundle of a {@link ClaudeAgentSession} and any per-session disposables - * registered against it (e.g. the agent's forward subscription to the - * session's `onDidSessionProgress` event). One entry per materialized - * session in {@link ClaudeAgent._sessions}; disposing the entry disposes - * the session AND every extra registered via {@link addDisposable}. - * - * Lets new per-session lifecycle bindings (future config listeners, - * abort wirings, etc.) attach to the session's lifetime without growing - * a new parallel `DisposableMap` on the agent. + * Per-session container. Owns the session's default (main) chat and any + * additional peer chats — each a {@link ClaudeAgentSession} plus the + * event-forwarding subscriptions registered against it (e.g. the agent's + * forward subscription to the session's `onDidSessionProgress` event). A single + * {@link ClaudeAgent._sessions} map of these entries keeps all chats of a + * session together (no parallel maps), so dispatch resolves a chat by looking + * up its owning session and then the chat within it. Disposing the entry + * disposes the session AND every extra registered via + * {@link AgentSessionEntry.addDisposable}. */ -class ClaudeSessionEntry extends Disposable { - readonly session: ClaudeAgentSession; - - constructor(session: ClaudeAgentSession) { - super(); - this.session = this._register(session); - } - - addDisposable(disposable: IDisposable): void { - this._register(disposable); +class ClaudeSessionEntry extends AgentSessionEntry<ClaudeAgentSession> { + /** Claude sessions always have a materialized default chat once seeded. */ + override get defaultChat(): ClaudeAgentSession { + return super.defaultChat!; } } diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts index 8d72ff49ecb0f6..15651d2284039a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSdkService.ts @@ -22,6 +22,7 @@ import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; */ export const ClaudeSdkPackage: IAgentSdkPackage = { id: 'claude', + displayName: 'Claude', devOverrideEnvVar: AgentHostClaudeSdkRootEnvVar, hasSeparateMuslLinuxPackage: true, }; @@ -54,6 +55,16 @@ export interface IClaudeAgentSdkService { getSessionMessages(sessionId: string, options?: GetSessionMessagesOptions): Promise<readonly SessionMessage[]>; listSubagents(sessionId: string, options?: ListSubagentsOptions): Promise<readonly string[]>; getSubagentMessages(sessionId: string, agentId: string, options?: GetSubagentMessagesOptions): Promise<readonly SessionMessage[]>; + + /** + * True iff the SDK can be loaded WITHOUT a network download — a dev + * override or dev bare-import is available, or a previously-downloaded SDK + * is cached on disk. Eager / background callers (e.g. `listSessions` at + * startup) gate on this so listing sessions never kicks off a multi-second + * cold download before the user has started a session. + */ + canLoadWithoutDownload(): Promise<boolean>; + forkSession(sessionId: string, options?: ForkSessionOptions): Promise<ForkSessionResult>; deleteSession(sessionId: string, options?: SessionMutationOptions): Promise<void>; createSdkMcpServer(options: { @@ -134,6 +145,17 @@ export class ClaudeAgentSdkService implements IClaudeAgentSdkService { return sdk.listSessions(undefined); } + async canLoadWithoutDownload(): Promise<boolean> { + // A dev override (explicit SDK root) is always local. So is the dev + // bare-import path, which is taken when there is no product config — + // `isAvailable` is false exactly in that case. Otherwise the SDK comes + // from the downloader, which is only local once it has been cached. + if (process.env[AgentHostClaudeSdkRootEnvVar] || !this._downloader.isAvailable(ClaudeSdkPackage)) { + return true; + } + return this._downloader.isSdkResolvableWithoutDownload(ClaudeSdkPackage); + } + async getSessionInfo(sessionId: string): Promise<SDKSessionInfo | undefined> { const sdk = await this._getSdk(); return sdk.getSessionInfo(sessionId); diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index 4abf2858a456a6..0c4a65fb15490a 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -22,8 +22,8 @@ import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; -import { buildDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; +import { PendingMessage, ChatInputAnswer, ChatInputRequest, ChatInputResponseKind, ToolCallContributorKind, ToolCallPendingConfirmationState, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { isDefaultChatUri, type Customization, type ToolCallResult } from '../../common/state/sessionState.js'; import { IClaudeAgentSdkService } from './claudeAgentSdkService.js'; import { buildClientMcpServers, buildOptions } from './claudeSdkOptions.js'; import { toSdkModelId } from './claudeModelId.js'; @@ -34,6 +34,7 @@ import { readClaudePermissionMode } from './claudeSessionPermissionMode.js'; import { SessionClientToolsDiff } from './clientTools/claudeSessionClientToolsModel.js'; import { SessionClientCustomizationsDiff } from './customizations/claudeSessionClientCustomizationsModel.js'; import { ClaudeCustomizationWatcher, buildDiscoveredCustomizations, resolveClaudeAgentName } from './customizations/claudeSessionCustomizationDiscovery.js'; +import { findMcpChildId, findMcpServerName } from '../shared/mcpCustomizationController.js'; import { scanClaudeDiskCustomizations } from './customizations/scan/claudeAgentSkillScan.js'; import { scanClaudeHooks } from './customizations/scan/claudeHookScan.js'; import { scanClaudeMcpServers } from './customizations/scan/claudeMcpScan.js'; @@ -90,6 +91,18 @@ export class ClaudeAgentSession extends Disposable { private _pipeline: ClaudeSdkPipeline | undefined; private readonly _chatChannelUri: URI; + /** + * URI under which this chat's per-chat resources (its session database, + * metadata overlay, config scope and server-tool advertisement) are keyed. + * The default chat uses the real session URI; an additional peer chat uses + * its own `ahp-chat` channel URI so its chat state stays isolated + * from the default chat's. `sessionUri` always remains the real session URI + * and `chatChannelUri` always the chat channel — they are never overloaded. + */ + private get _storageUri(): URI { + return isDefaultChatUri(this._chatChannelUri) ? this.sessionUri : this._chatChannelUri; + } + /** Pre-materialize model selection. Mutable; flows into `Options.model` on first installPipeline. */ private _provisionalModel: ModelSelection | undefined; /** @@ -117,6 +130,7 @@ export class ClaudeAgentSession extends Disposable { static createProvisional( sessionId: string, sessionUri: URI, + chatChannelUri: URI, workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, @@ -131,6 +145,7 @@ export class ClaudeAgentSession extends Disposable { ClaudeAgentSession, sessionId, sessionUri, + chatChannelUri, workingDirectory, project, model, @@ -253,9 +268,33 @@ export class ClaudeAgentSession extends Disposable { }; } + /** + * Stamps the MCP {@link ToolCallContributor} onto a `ChatToolCallStart` for + * an external `mcp__<server>__<tool>` call, resolved from this session's + * cached customization snapshot. Owned here because the session owns the + * customization data; the stream mapper stays free of it. (The in-process + * `mcp__client__` server already carries a Client contributor from the mapper.) + */ + private _enrichSignalWithMcpContributor(signal: AgentSignal): AgentSignal { + if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatToolCallStart || signal.action.contributor !== undefined) { + return signal; + } + const toolName = signal.action.toolName; + if (!toolName.startsWith('mcp__')) { + return signal; + } + const serverName = toolName.split('__')[1]; + const customizationId = serverName ? findMcpChildId(this._lastCustomizations, serverName) : undefined; + if (customizationId === undefined) { + return signal; + } + return { ...signal, action: { ...signal.action, contributor: { kind: ToolCallContributorKind.MCP, customizationId } } }; + } + constructor( readonly sessionId: string, readonly sessionUri: URI, + readonly chatChannelUri: URI, readonly workingDirectory: URI | undefined, project: IAgentSessionProjectInfo | undefined, model: ModelSelection | undefined, @@ -275,7 +314,7 @@ export class ClaudeAgentSession extends Disposable { @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, ) { super(); - this._chatChannelUri = URI.parse(buildDefaultChatUri(sessionUri)); + this._chatChannelUri = chatChannelUri; this.project = project; this._provisionalModel = model; this._provisionalAgent = agent; @@ -336,7 +375,7 @@ export class ClaudeAgentSession extends Disposable { * ref-count keeps the shared DB alive; disposing only decrements). */ private async _withDatabase(fn: (db: ISessionDatabase) => Promise<void>): Promise<void> { - const ref = this._sessionDataService.openDatabase(this.sessionUri); + const ref = this._sessionDataService.openDatabase(this._storageUri); try { await fn(ref.object); } finally { @@ -366,7 +405,7 @@ export class ClaudeAgentSession extends Disposable { } this._transportKind = ctx.transport.kind; - const permissionMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + const permissionMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback; const { mcpServers, allowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); const agentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); @@ -399,7 +438,7 @@ export class ClaudeAgentSession extends Disposable { throw new CancellationError(); } - const dbRef = this._sessionDataService.openDatabase(this.sessionUri); + const dbRef = this._sessionDataService.openDatabase(this._storageUri); let pipeline: ClaudeSdkPipeline; try { pipeline = this._register(this._instantiationService.createInstance( @@ -418,7 +457,7 @@ export class ClaudeAgentSession extends Disposable { await warm[Symbol.asyncDispose](); throw err; } - this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithCredits(s)))); + this._register(pipeline.onDidProduceSignal(s => this._onDidSessionProgress.fire(this._enrichSignalWithMcpContributor(this._enrichSignalWithCredits(s))))); this._pipeline = pipeline; // The materialize succeeded with the staged anchor applied to `Options` // — clear it now so it isn't re-applied. A throw before this point (e.g. @@ -440,7 +479,7 @@ export class ClaudeAgentSession extends Disposable { // upstream and would otherwise overwrite their source. if (!ctx.isResume) { try { - await this._metadataStore.write(this.sessionUri, { + await this._metadataStore.write(this._storageUri, { customizationDirectory: this.workingDirectory, model: this._provisionalModel, permissionMode, @@ -462,7 +501,7 @@ export class ClaudeAgentSession extends Disposable { } pipeline.attachRematerializer(async (_reason) => { - const liveMode = readClaudePermissionMode(this._configurationService, this.sessionUri) ?? this._permissionModeFallback; + const liveMode = readClaudePermissionMode(this._configurationService, this._storageUri) ?? this._permissionModeFallback; try { const { mcpServers: rebuildMcp, allowedTools: rebuildAllowedTools } = await this._buildStartupToolWiring(ctx.serverToolHost); const rebuildAgentName = await resolveClaudeAgentName(this._provisionalAgent, this._fileService, this._logService, this.sessionId); @@ -504,7 +543,7 @@ export class ClaudeAgentSession extends Disposable { // Advertise the agent host's server tools on this session so the client // sees them as server-provided. Execution happens in-process via the // server-tool MCP server built in `_buildStartupToolWiring`. - ctx.serverToolHost?.advertise(this.sessionUri.toString()); + ctx.serverToolHost?.advertise(this._storageUri.toString()); // Surface the SDK-resolved customization tier to the workbench. // Pre-materialize, getSessionCustomizations returns only the @@ -534,7 +573,7 @@ export class ClaudeAgentSession extends Disposable { ): Promise<{ mcpServers: Record<string, McpSdkServerConfigWithInstance> | undefined; allowedTools: readonly string[] | undefined }> { const clientServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); const serverToolServer = serverToolHost - ? await buildServerToolMcpServer(serverToolHost, this.sessionUri.toString(), this._sdkService) + ? await buildServerToolMcpServer(serverToolHost, this._storageUri.toString(), this._sdkService) : undefined; const mcpServers = (!clientServers && !serverToolServer) ? undefined @@ -620,7 +659,7 @@ export class ClaudeAgentSession extends Disposable { if (this.toolDiff.hasDifference || this.clientCustomizationsDiff.hasDifference || this._pendingResumeSessionAt !== undefined) { await this._rebindForSyncedState(); } else { - await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this.sessionUri, this._permissionModeFallback)); + await pipeline.setPermissionMode(resolveCurrentPermissionMode(this._configurationService, this._storageUri, this._permissionModeFallback)); } return pipeline.send(prompt, turnId); } @@ -688,7 +727,7 @@ export class ClaudeAgentSession extends Disposable { // (`output_config.effort ... does not support reasoning effort`). await this._pipeline.setEffort(runtimeEffort); } - await this._metadataStore.write(this.sessionUri, { model }); + await this._metadataStore.write(this._storageUri, { model }); } /** @@ -716,7 +755,7 @@ export class ClaudeAgentSession extends Disposable { // runtime hook to swap the agent in place. this.clientCustomizationsDiff.markDirty(); } - await this._metadataStore.write(this.sessionUri, { agent: agent ?? null }); + await this._metadataStore.write(this._storageUri, { agent: agent ?? null }); } /** @@ -917,6 +956,9 @@ export class ClaudeAgentSession extends Disposable { return this.clientCustomizationsDiff.model.state.get().synced; } + /** Snapshot of the last {@link getSessionCustomizations} result, read by {@link _enrichSignalWithMcpContributor}. */ + private _lastCustomizations: readonly Customization[] = []; + /** * Project the union of (a) **client-pushed** customizations and * (b) the **server-side** (SDK-discovered) view (commands / agents @@ -968,9 +1010,43 @@ export class ClaudeAgentSession extends Disposable { enabled: enablement.get(item.customization.id) ?? item.customization.enabled, })); result.push(...discoveredCustomizations); + // Cache for the MCP-contributor signal enrichment (see + // {@link _enrichSignalWithMcpContributor}). + this._lastCustomizations = result; return result; } + async startMcpServer(id: string): Promise<void> { + const serverName = await this._resolveMcpServerName(id); + if (!serverName) { + this._logService.warn(`[Claude:${this.sessionId}] Cannot start unknown MCP server customization ${id}`); + return; + } + const handled = await this._requirePipeline().startMcpServer(serverName); + if (!handled) { + await this._rebindForSyncedState(); + } + this._onDidCustomizationsChange.fire(); + } + + async stopMcpServer(id: string): Promise<void> { + const serverName = await this._resolveMcpServerName(id); + if (!serverName) { + this._logService.warn(`[Claude:${this.sessionId}] Cannot stop unknown MCP server customization ${id}`); + return; + } + const handled = await this._requirePipeline().stopMcpServer(serverName); + if (!handled) { + this._logService.warn(`[Claude:${this.sessionId}] MCP server stop is not supported by the current SDK`); + return; + } + this._onDidCustomizationsChange.fire(); + } + + private async _resolveMcpServerName(id: string): Promise<string | undefined> { + return findMcpServerName(this._lastCustomizations, id) ?? findMcpServerName(await this.getSessionCustomizations(), id); + } + // #endregion override dispose(): void { diff --git a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts index 2802007a05a6eb..50a94e21e4a8be 100644 --- a/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts +++ b/src/vs/platform/agentHost/node/claude/claudeCanUseTool.ts @@ -9,6 +9,7 @@ import { ChatInputResponseKind, ToolCallPendingConfirmationState, ToolCallStatus import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { ClaudeAgentSession } from './claudeAgentSession.js'; import { buildAskUserSessionInputQuestions, buildExitPlanModeConfirmationState, flattenAskUserAnswers, parseAskUserQuestionInput } from './claudeInteractiveTools.js'; +import { CLAUDE_PLAN_DECLINED_MESSAGE, CLAUDE_QUESTION_CANCELLED_MESSAGE, CLAUDE_USER_DECLINED_MESSAGE } from './claudeToolDenial.js'; import { getClaudeConfirmationTitle, getClaudeInvocationMessage, getClaudePermissionKind, getClaudeToolDisplayName, getClaudeToolInputString, getClaudeToolPath, INTERACTIVE_CLAUDE_TOOLS, buildClaudeToolMeta } from './claudeToolDisplay.js'; /** @@ -147,7 +148,7 @@ async function dispatchCanUseTool( }); return approved ? { behavior: 'allow', updatedInput: input } - : { behavior: 'deny', message: 'User declined' }; + : { behavior: 'deny', message: CLAUDE_USER_DECLINED_MESSAGE }; } /** @@ -238,7 +239,7 @@ async function handleExitPlanMode( }); return { behavior: 'allow', updatedInput: input }; } - return { behavior: 'deny', message: 'The user declined the plan, maybe ask why?' }; + return { behavior: 'deny', message: CLAUDE_PLAN_DECLINED_MESSAGE }; } /** @@ -265,12 +266,12 @@ async function handleAskUserQuestion( questions: buildAskUserSessionInputQuestions(askInput), }, parentToolCallId); if (answer.response !== ChatInputResponseKind.Accept || !answer.answers) { - return { behavior: 'deny', message: 'The user cancelled the question' }; + return { behavior: 'deny', message: CLAUDE_QUESTION_CANCELLED_MESSAGE }; } const answers = flattenAskUserAnswers(askInput, answer.answers); if (Object.keys(answers).length === 0) { - return { behavior: 'deny', message: 'The user cancelled the question' }; + return { behavior: 'deny', message: CLAUDE_QUESTION_CANCELLED_MESSAGE }; } return { behavior: 'allow', updatedInput: { ...input, answers } }; } diff --git a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts index 82c103f8d6595b..8a239922f293a0 100644 --- a/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/claude/claudeMapSessionEvents.ts @@ -14,6 +14,7 @@ import { buildTopLevelSubagentReadyAction, emitInnerAssistantSignals, mapSubagen import type { SubagentRegistry } from './claudeSubagentRegistry.js'; import { stripClientToolNamePrefix, hasClientToolNamePrefix } from './clientTools/claudeClientToolMcpServer.js'; import { buildClaudeToolMeta, getClaudePastTenseMessage, getClaudeToolDisplayName } from './claudeToolDisplay.js'; +import { claudeToolDenialCode } from './claudeToolDenial.js'; import { ClaudeToolCallRegistry } from './claudeToolCallRegistry.js'; import { ToolCallConfirmationReason, ToolCallContributorKind, type StringOrMarkdown } from '../../common/state/protocol/state.js'; @@ -350,6 +351,10 @@ function mapUserMessage( const pastTenseMessage: StringOrMarkdown = info ? getClaudePastTenseMessage(info.toolName, info.displayName, info.parsedInput, !isError, resultText) : `${getClaudeToolDisplayName(tracked.toolName)} finished`; + // A denied/cancelled tool surfaces as an `is_error` result whose content + // is the deny `message` we returned from `canUseTool`; classify it so the + // telemetry reports `userCancelled` rather than a generic error. + const denialCode = isError ? claudeToolDenialCode(resultText) : undefined; signals.push({ kind: 'action', resource: chat, @@ -361,6 +366,7 @@ function mapUserMessage( success: !isError, pastTenseMessage, content: content.length > 0 ? content : undefined, + ...(denialCode ? { error: { message: resultText, code: denialCode } } : {}), }, }, }); diff --git a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts index 5a21d6e492e0d5..4a4b96a8f950bf 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSdkPipeline.ts @@ -115,6 +115,27 @@ export class ClaudeSdkPipeline extends Disposable { return { commands, agents, mcpServers, plugins: this._initPlugins }; } + async startMcpServer(serverName: string): Promise<boolean> { + const query = await this._ensureQueryBound(); + const lifecycle = query; + if (lifecycle.toggleMcpServer && lifecycle.reconnectMcpServer) { + await lifecycle.toggleMcpServer(serverName, true); + await lifecycle.reconnectMcpServer(serverName); + return true; + } + return false; + } + + async stopMcpServer(serverName: string): Promise<boolean> { + const query = await this._ensureQueryBound(); + const lifecycle = query; + if (!lifecycle.toggleMcpServer) { + return false; + } + await lifecycle.toggleMcpServer(serverName, false); + return true; + } + /** * Bind the SDK Query if needed, recovering a dead one first. Mirrors the * gate in {@link send}: if the pipeline is marked for rebind (after an diff --git a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts index d9cfba9af33eeb..0f4316d6228fc8 100644 --- a/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts +++ b/src/vs/platform/agentHost/node/claude/claudeSubagentSignals.ts @@ -66,6 +66,15 @@ export function tagWithParent( agentName: spawn.subagentType ?? 'subagent', agentDisplayName: spawn.subagentType ?? 'Subagent', agentDescription: spawn.description, + // The Task tool's short `description` input doubles as the concise + // per-task tab title for the subagent's read-only peer chat. + taskDescription: spawn.description, + // When the spawning Task tool is itself an inner tool of another + // subagent, its parent Task (one level up) is the tool call in + // whose chat this spawning tool lives. The host uses it to route + // the discovery content block to that immediate parent chat, at + // any nesting depth. + parentToolCallId: registry.getParentSpawn(parentToolUseId)?.toolUseId, }; return [started, ...tagged]; } diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts b/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts new file mode 100644 index 00000000000000..a2b4ddad3bc8fb --- /dev/null +++ b/src/vs/platform/agentHost/node/claude/claudeToolDenial.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Deny `message` returned to the SDK when the user declines a tool + * permission. The SDK surfaces it as the `is_error` tool_result content. + */ +export const CLAUDE_USER_DECLINED_MESSAGE = 'User declined'; +/** Deny `message` when the user declines an `ExitPlanMode` plan. */ +export const CLAUDE_PLAN_DECLINED_MESSAGE = 'The user declined the plan, maybe ask why?'; +/** Deny `message` when the user cancels an `AskUserQuestion` prompt. */ +export const CLAUDE_QUESTION_CANCELLED_MESSAGE = 'The user cancelled the question'; + +/** + * Classifies a failed tool's result message into a `languageModelToolInvoked` + * cancellation code (`denied`/`cancelled`), or `undefined` for a genuine tool + * error. The input is the `message` a denied `canUseTool` returns, which the + * SDK echoes back as the `is_error` tool_result content — so matching the + * known deny strings distinguishes a user cancellation from a tool failure. + */ +export function claudeToolDenialCode(message: string): 'denied' | 'cancelled' | undefined { + switch (message) { + case CLAUDE_USER_DECLINED_MESSAGE: + case CLAUDE_PLAN_DECLINED_MESSAGE: + return 'denied'; + case CLAUDE_QUESTION_CANCELLED_MESSAGE: + return 'cancelled'; + default: + return undefined; + } +} diff --git a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts index 6bd29af90d6644..30767b1270afa0 100644 --- a/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts +++ b/src/vs/platform/agentHost/node/claude/claudeToolDisplay.ts @@ -504,7 +504,7 @@ export function getClaudePastTenseMessage( case 'Agent': return localize('claude.toolComplete.task', "Ran subagent"); default: - return localize('claude.toolComplete.generic', "Used \"{0}\"", displayName); + return displayName; } } diff --git a/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts b/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts index de679d950e3229..df21a7efaa01cd 100644 --- a/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts +++ b/src/vs/platform/agentHost/node/claude/clientTools/claudeSessionClientToolsModel.ts @@ -52,9 +52,9 @@ export class SessionClientToolsModel { } } - /** The `clientId` that owns the merged tool named `toolName`, or `undefined`. */ - ownerOf(toolName: string): string | undefined { - return this._toolSet.ownerOf(toolName); + /** The `clientId` that owns the tool named `toolName`, or `undefined`. */ + ownerOf(toolName: string, preferredClientId?: string): string | undefined { + return this._toolSet.ownerOf(toolName, preferredClientId); } } diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 1ca2521e4a8288..7390d982744638 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -5,6 +5,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'; import * as fs from 'fs'; +import * as os from 'os'; import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; @@ -17,22 +18,23 @@ import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { createSchema, platformSessionSchema, schemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; -import { createPricingMetaFromBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; +import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; -import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; +import { AgentHostCodexAgentBinaryArgsEnvVar, AgentHostCodexAgentCodexHomeEnvVar, AgentHostCodexAgentSdkRootEnvVar, AgentSession, AgentSignal, IActiveClient, IAgent, IAgentChats, IAgentCreateChatForkSource, IAgentCreateChatResult, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IMcpNotification, type AgentProvider } from '../../common/agentService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ActionType, isChatAction, type SessionAction, type ChatAction } from '../../common/state/sessionActions.js'; -import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition } from '../../common/state/protocol/state.js'; +import type { ConfigSchema, ModelSelection, ProtectedResourceMetadata, ToolDefinition, AgentSelection } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; -import { buildDefaultChatUri, type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; +import { buildDefaultChatUri, parseChatUri, type ClientPluginCustomization, type MessageAttachment, type PendingMessage, type ChatInputAnswer, ChatInputResponseKind, type PolicyState, type ToolCallResult, ToolResultContentType, type Turn } from '../../common/state/sessionState.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; import { buildCodexMcpReadResult, codexMcpListToInventory, codexMcpToolsChanged, inventoryToSdkServers, translateCodexMcpStartupState, type ICodexMcpServerEntry } from './codexMcpServers.js'; import { buildElicitationRequest, cancelledElicitationResponse, declinedElicitationResponse, elicitationResponseFromAnswers } from './codexElicitationMapper.js'; -import type { AhpMcpUiHostCapabilities, Customization } from '../../common/state/protocol/channels-session/state.js'; +import { McpServerStatus, type AhpMcpUiHostCapabilities, type Customization } from '../../common/state/protocol/channels-session/state.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; import { ICopilotApiService } from '../shared/copilotApiService.js'; import { extractForwardedErrorInfo } from '../shared/forwardedChatError.js'; import { IAgentSdkDownloader, IAgentSdkPackage } from '../agentSdkDownloader.js'; @@ -323,7 +325,21 @@ interface ICodexSession { */ threadId: string | undefined; readonly sessionUri: URI; - readonly workingDirectory: URI | undefined; + /** + * The directory the codex thread runs in. Usually supplied by the client + * on `createSession`, but Codex requires a cwd, so when none is provided + * (e.g. an editor window with no workspace folder open) one is lazily + * created as a managed temp folder at materialize time (tracked by + * {@link managedWorkingDirectory} for cleanup). Mutable so that lazy + * assignment can happen after the provisional `createSession`. + */ + workingDirectory: URI | undefined; + /** + * Set to the temp folder created for this session when no working + * directory was supplied, so {@link CodexAgent.disposeSession} can remove + * it. `undefined` when the client supplied a working directory. + */ + managedWorkingDirectory: URI | undefined; readonly mapState: ICodexSessionMapState; /** * Phase 4: parked deferreds for `item/commandExecution/requestApproval`, @@ -449,6 +465,7 @@ interface IConnectionReady { */ export const CodexSdkPackage: IAgentSdkPackage = { id: 'codex', + displayName: 'Codex', devOverrideEnvVar: AgentHostCodexAgentSdkRootEnvVar, hasSeparateMuslLinuxPackage: false, }; @@ -583,6 +600,7 @@ export class CodexAgent extends Disposable implements IAgent { @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, @ICodexProxyService private readonly _codexProxyService: ICodexProxyService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentSdkDownloader private readonly _agentSdkDownloader: IAgentSdkDownloader, @IProductService private readonly _productService: IProductService, @IInstantiationService instantiationService: IInstantiationService, @@ -595,16 +613,16 @@ export class CodexAgent extends Disposable implements IAgent { getProtectedResources(): ProtectedResourceMetadata[] { return [ - GITHUB_COPILOT_PROTECTED_RESOURCE, - GITHUB_REPO_PROTECTED_RESOURCE + this._gitHubEndpointService.getCopilotResource(), + this._gitHubEndpointService.getRepoResource() ]; } async authenticate(resource: string, token: string): Promise<boolean> { - if (resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { + if (resource === this._gitHubEndpointService.getRepoResource().resource) { return true; } - if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { return false; } const changed = this._githubToken !== token; @@ -788,9 +806,9 @@ export class CodexAgent extends Disposable implements IAgent { configSchema, policyState: m.policy?.state as PolicyState | undefined, _meta: createPricingMetaFromBilling( - m.billing as ICAPIModelBilling | undefined, - typeof (m as { modelPickerPriceCategory?: string }).modelPickerPriceCategory === 'string' - ? (m as { modelPickerPriceCategory?: string }).modelPickerPriceCategory + normalizeCAPIBilling(m.billing), + typeof m.model_picker_price_category === 'string' + ? m.model_picker_price_category : undefined, ), })); @@ -923,6 +941,8 @@ export class CodexAgent extends Disposable implements IAgent { // elicitation handler is reserved for genuine server-to-user // elicitations. `features.tool_call_mcp_elicitation=false`, + // CAPI rejects the hosted `image_generation` tool; disable it so codex does not emit it. + `features.image_generation=false`, ]; // Extra args forwarded as JSON from the workbench setting. @@ -1580,15 +1600,71 @@ export class CodexAgent extends Disposable implements IAgent { }; } + private _sessionUriFromChat(chat: URI): URI { + const parsed = parseChatUri(chat); + return parsed ? URI.parse(parsed.session) : chat; + } + + // ---- Chat surface ------------------------------------------------------ + // + // Chat-addressed adoption of the {@link IAgent} surface introduced + // in gate G-C1. Codex is a SINGLE-CHAT harness: a session owns exactly one + // (default) chat addressed by its default chat channel URI, so the + // chat methods simply route to the existing session-addressed + // implementations. The legacy `(session, chat?)` methods below are kept as a + // compat shim (removed centrally in gate G-C2) and both surfaces coexist. + + /** + * The chat-addressed operation surface for the chats within a session. + * Codex is single-chat: peer-chat operations + * ({@link IAgentChats.createChat}/{@link IAgentChats.fork}) + * are unsupported and throw, mirroring today's behavior where Codex omits + * `createChat` (the orchestrator rejected multi-chat for Codex). The + * remaining methods address the session's single default chat, whose + * URI is the deterministic default chat channel URI. + */ + readonly chats: IAgentChats = { + createChat: (_chat: URI, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + throw new Error('Codex agent does not support multiple chats'); + }, + fork: (_chat: URI, _source: IAgentCreateChatForkSource, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + throw new Error('Codex agent does not support chat forking'); + }, + disposeChat: (_chat: URI): Promise<void> => { + // Codex has no additional (peer) chats to dispose; the + // default chat lives and dies with its session. + return Promise.resolve(); + }, + sendMessage: (chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise<void> => { + return this._sendMessage(chat, prompt, attachments, turnId); + }, + abort: (chat: URI): Promise<void> => { + return this._abort(chat); + }, + changeModel: (chat: URI, model: ModelSelection): Promise<void> => { + return this._changeModel(chat, model); + }, + changeAgent: (_chat: URI, _agent: AgentSelection | undefined): Promise<void> => { + // Codex does not support selecting a custom agent. + return Promise.resolve(); + }, + getMessages: (chat: URI): Promise<readonly Turn[]> => { + return this.getSessionMessages(chat); + }, + }; + async createSession(config: IAgentCreateSessionConfig = {}): Promise<IAgentCreateSessionResult> { this._logService.info(`[Codex DEBUG] createSession session=${config.session?.toString() ?? '(none)'} model=${config.model?.id ?? '(none)'} cwd=${config.workingDirectory?.toString() ?? '(none)'}`); this._ensureAuthenticated(); if (config.fork) { throw new Error('Codex agent does not support session forking'); } - if (!config.workingDirectory) { - throw new Error('Codex requires a working directory; pass `workingDirectory` to createSession'); - } + // Codex requires a working directory to start a thread, but the client + // may not have one to give (e.g. an editor window with no workspace + // folder open). Rather than reject session creation — which would break + // both the session and the first-use SDK download progress notification + // that keys off a successful `createSession` — defer: a managed temp + // folder is created lazily at materialize time (see `_materialize`). // Provisional / lazy materialize. We DON'T call `thread/start` here // because the workbench may rebind this URI to a fresh one when the @@ -1618,6 +1694,7 @@ export class CodexAgent extends Disposable implements IAgent { threadId: undefined, sessionUri, workingDirectory: config.workingDirectory, + managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry<CommandExecutionApprovalDecision>(), acceptedForSession: new Set<string>(), @@ -1687,7 +1764,14 @@ export class CodexAgent extends Disposable implements IAgent { return; } if (!session.workingDirectory) { - throw new Error(`Cannot materialize codex session ${session.sessionId}: no working directory`); + // No working directory was supplied (e.g. an editor window with no + // workspace folder open). Codex requires one, so create a managed + // per-session temp folder and remember it for cleanup on dispose. + const dir = join(os.tmpdir(), 'vscode-agent-codex', session.sessionId); + await fs.promises.mkdir(dir, { recursive: true }); + session.workingDirectory = URI.file(dir); + session.managedWorkingDirectory = session.workingDirectory; + this._logService.info(`[Codex] no working directory supplied for session=${session.sessionUri.toString()}; using managed temp folder ${dir}`); } const conn = await this._ensureConnection(); const config = this._readSessionConfig(session); @@ -1766,7 +1850,16 @@ export class CodexAgent extends Disposable implements IAgent { if (!session.workingDirectory) { return; } - void this._materializeIfNeeded(session, false).then(() => { + void (async () => { + // Prewarm is a background latency optimization, not a user action, + // so it must NOT trigger a cold SDK download. When the SDK isn't + // local yet, skip prewarm; the first `sendMessage` materializes the + // thread and fires the (host-level progress-reported) download then. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info(`[Codex] SDK not downloaded yet; skipping prewarm for session=${session.sessionUri.toString()} until a message triggers the download`); + return; + } + await this._materializeIfNeeded(session, false); if (session.prewarmClaimed || session.threadId === undefined) { return; } @@ -1775,7 +1868,7 @@ export class CodexAgent extends Disposable implements IAgent { void this._expirePrewarm(session); }, CodexPrewarmTtlMs); session.prewarmTimer = prewarmTimer; - }).catch(err => { + })().catch(err => { this._logService.warn(`[Codex] prewarm failed session=${session.sessionUri.toString()}: ${err instanceof Error ? err.message : String(err)}`); }); } @@ -1817,7 +1910,8 @@ export class CodexAgent extends Disposable implements IAgent { } } - async sendMessage(sessionUri: URI, _chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise<void> { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise<void> { + const sessionUri = this._sessionUriFromChat(chat); this._logService.info(`[Codex DEBUG] sendMessage session=${sessionUri.toString()} prompt=${JSON.stringify(prompt).slice(0, 60)}`); const sessionId = AgentSession.id(sessionUri); const session = this._sessions.get(sessionId); @@ -1982,7 +2076,8 @@ export class CodexAgent extends Disposable implements IAgent { }); } - async abortSession(sessionUri: URI): Promise<void> { + private async _abort(chat: URI): Promise<void> { + const sessionUri = this._sessionUriFromChat(chat); const sessionId = AgentSession.id(sessionUri); const session = this._sessions.get(sessionId); if (!session) { @@ -2020,6 +2115,15 @@ export class CodexAgent extends Disposable implements IAgent { this._claimPrewarm(session); this._sessions.delete(sessionId); session.mcpController?.dispose(); + // Remove the managed temp folder created for a session that had no + // client-supplied working directory. Best-effort; the OS temp dir is + // reclaimed anyway, but clean up proactively so it doesn't accumulate. + if (session.managedWorkingDirectory) { + const dir = session.managedWorkingDirectory.fsPath; + fs.promises.rm(dir, { recursive: true, force: true }).catch(err => { + this._logService.info(`[Codex] failed to remove managed temp folder ${dir}: ${err instanceof Error ? err.message : String(err)}`); + }); + } if (session.threadId !== undefined) { this._sessionIdByThreadId.delete(session.threadId); } @@ -2045,7 +2149,8 @@ export class CodexAgent extends Disposable implements IAgent { } } - async changeModel(sessionUri: URI, model: ModelSelection): Promise<void> { + private async _changeModel(chat: URI, model: ModelSelection): Promise<void> { + const sessionUri = this._sessionUriFromChat(chat); const session = this._sessions.get(AgentSession.id(sessionUri)); if (session) { const supported = this._supportedModelOrUndefined(model); @@ -2130,6 +2235,11 @@ export class CodexAgent extends Disposable implements IAgent { // resolve the first match. Mirrors the Claude/Copilot agents. for (const session of this._sessions.values()) { if (session.pendingCommandApprovals.respond(requestId, approved ? 'accept' : 'decline')) { + if (!approved) { + // Remember the decline so the tool's `item/completed` (which + // codex reports as a generic failure) maps to `userCancelled`. + session.mapState.declinedToolCalls.add(requestId); + } return; } } @@ -2147,8 +2257,8 @@ export class CodexAgent extends Disposable implements IAgent { this._logService.info(`[Codex] respondToUserInputRequest: unknown requestId=${requestId}`); } - getSessionMessages(session: URI): Promise<readonly Turn[]> { - return this._readSession(session).then(read => read ? replayThreadToTurns(read.thread) : []); + getSessionMessages(chat: URI): Promise<readonly Turn[]> { + return this._readSession(this._sessionUriFromChat(chat)).then(read => read ? replayThreadToTurns(read.thread) : []); } async getSessionMetadata(session: URI): Promise<IAgentSessionMetadata | undefined> { @@ -2170,6 +2280,7 @@ export class CodexAgent extends Disposable implements IAgent { threadId, sessionUri: session, workingDirectory, + managedWorkingDirectory: undefined, mapState: createCodexSessionMapState(new Set(this._serverToolHost?.toolNames ?? []), clientToolSet), pendingCommandApprovals: new PendingRequestRegistry<CommandExecutionApprovalDecision>(), acceptedForSession: new Set<string>(), @@ -2243,6 +2354,15 @@ export class CodexAgent extends Disposable implements IAgent { if (!this._githubToken) { return []; } + // Don't connect (and trigger a cold SDK download) just to list threads + // at startup. When the SDK isn't local yet, surface an empty list; the + // download fires (with host-level progress) once the user starts a + // session, and the next `listSessions` — driven by the renderer's + // post-turn refresh — returns the full list. + if (!(await this._agentSdkDownloader.isSdkResolvableWithoutDownload(CodexSdkPackage))) { + this._logService.info('[Codex] SDK not downloaded yet; deferring thread/list until a session triggers the download'); + return []; + } try { const conn = await this._ensureConnection(); const response = await conn.client.request<'thread/list', ThreadListResponse>('thread/list', { @@ -2331,6 +2451,7 @@ export class CodexAgent extends Disposable implements IAgent { } const controller = this._getOrCreateMcpController(session); controller.applyAll(inventoryToSdkServers(this._mcpInventory)); + this._refreshMcpCustomizationIds(session, controller); return controller.topLevelCustomizations(); } @@ -2390,6 +2511,35 @@ export class CodexAgent extends Disposable implements IAgent { } } + async startMcpServer(sessionUri: URI, id: string): Promise<void> { + const session = this._sessions.get(AgentSession.id(sessionUri)); + const serverName = session ? this._resolveMcpServerName(session, id) : undefined; + if (!session || !serverName) { + this._logService.warn(`[Codex] Cannot start unknown MCP server customization ${id}`); + return; + } + const conn = await this._ensureConnection(); + await conn.client.request<'config/mcpServer/reload'>('config/mcpServer/reload', undefined); + await this._refreshMcpInventory(conn.client); + } + + async stopMcpServer(sessionUri: URI, id: string): Promise<void> { + const session = this._sessions.get(AgentSession.id(sessionUri)); + const serverName = session ? this._resolveMcpServerName(session, id) : undefined; + if (!session || !serverName) { + this._logService.warn(`[Codex] Cannot stop unknown MCP server customization ${id}`); + return; + } + // TODO: Wire this when Codex exposes a typed MCP server stop request. + } + + private _resolveMcpServerName(session: ICodexSession, id: string): string | undefined { + const controller = this._getOrCreateMcpController(session); + controller.applyAll(inventoryToSdkServers(this._mcpInventory)); + this._refreshMcpCustomizationIds(session, controller); + return controller.serverNameForCustomizationId(id); + } + /** * Lazily create the per-session {@link McpCustomizationController}. Not * registered on the agent (sessions come and go) — disposed explicitly @@ -2415,7 +2565,27 @@ export class CodexAgent extends Disposable implements IAgent { if (session.disposed) { continue; } - this._getOrCreateMcpController(session).applyAll(servers); + const controller = this._getOrCreateMcpController(session); + controller.applyAll(servers); + this._refreshMcpCustomizationIds(session, controller); + } + } + + /** + * Refreshes the session's mapper snapshot of server name → customization id + * (read when stamping the MCP contributor on tool calls). Plain data, owned + * here — the mapper never reaches back into the controller. Must run on every + * inventory change because MCP servers are discovered asynchronously, after a + * session (and possibly its first tool call) already exists. + */ + private _refreshMcpCustomizationIds(session: ICodexSession, controller: McpCustomizationController): void { + const ids = session.mapState.mcpCustomizationIds; + ids.clear(); + for (const serverName of this._mcpInventory.keys()) { + const id = controller.customizationIdForServer(serverName); + if (id !== undefined) { + ids.set(serverName, id); + } } } @@ -2449,6 +2619,11 @@ export class CodexAgent extends Disposable implements IAgent { toolsChanged.push(name); } } + for (const [name, entry] of this._mcpInventory) { + if (!next.has(name) && entry.state.kind !== McpServerStatus.Ready) { + next.set(name, entry); + } + } this._mcpInventory.clear(); for (const [name, entry] of next) { this._mcpInventory.set(name, entry); @@ -2473,17 +2648,13 @@ export class CodexAgent extends Disposable implements IAgent { void this._refreshMcpInventory(client); return; } - if (status === 'cancelled') { - this._mcpInventory.delete(name); - } else { - const prev = this._mcpInventory.get(name); - this._mcpInventory.set(name, { - state: translateCodexMcpStartupState(status, error), - tools: prev?.tools ?? [], - resources: prev?.resources ?? [], - resourceTemplates: prev?.resourceTemplates ?? [], - }); - } + const prev = this._mcpInventory.get(name); + this._mcpInventory.set(name, { + state: translateCodexMcpStartupState(status, error), + tools: prev?.tools ?? [], + resources: prev?.resources ?? [], + resourceTemplates: prev?.resourceTemplates ?? [], + }); this._applyMcpInventoryToSessions(); } diff --git a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts index dc33d717191557..d7ced094e748ba 100644 --- a/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts +++ b/src/vs/platform/agentHost/node/codex/codexMapAppServerEvents.ts @@ -65,6 +65,21 @@ export interface ICodexSessionMapState { * answers the `item/tool/call` directly. */ serverToolNames: ReadonlySet<string>; + /** + * Server name → customization id for the session's MCP servers, used to + * stamp the {@link ToolCallContributorKind.MCP} contributor on `mcpToolCall` + * starts so clients can correlate the call with its originating server + * customization. Owned and populated by the agent (mirrors + * {@link clientToolSet}); empty until the agent first applies the inventory. + */ + readonly mcpCustomizationIds: Map<string, string>; + /** + * Tool call ids the host declined at the approval prompt. Codex reports the + * resulting `item/completed` as a generic failure, so the completion handler + * consults this set to emit a `userCancelled` (`error.code = 'denied'`) + * result instead. Drained on completion and cleared per turn. + */ + readonly declinedToolCalls: Set<string>; } export interface ICodexToolCallEntry { @@ -82,6 +97,8 @@ export function createCodexSessionMapState(serverToolNames: ReadonlySet<string> currentTurnId: undefined, clientToolSet, serverToolNames, + mcpCustomizationIds: new Map(), + declinedToolCalls: new Set(), }; } @@ -96,6 +113,7 @@ export function resetCodexTurnMapState(state: ICodexSessionMapState): void { state.itemToPartId.clear(); state.itemToToolCall.clear(); state.itemToReasoningPartId.clear(); + state.declinedToolCalls.clear(); } /** @@ -417,6 +435,7 @@ export function mapItemStarted( const toolCallId = generateUuid(); const toolName = `${params.item.server}.${params.item.tool}`; const toolInput = toolInputText(params.item.arguments); + const customizationId = state.mcpCustomizationIds.get(params.item.server); state.itemToToolCall.set(params.item.id, { toolCallId, turnId: params.turnId, @@ -430,6 +449,7 @@ export function mapItemStarted( toolCallId, toolName, displayName: params.item.tool, + ...(customizationId ? { contributor: { kind: ToolCallContributorKind.MCP, customizationId } } : {}), }, { type: ActionType.ChatToolCallDelta, @@ -611,12 +631,17 @@ export function mapItemCompleted( clearReasoningForItem(state, params.item.id); return []; } + // Every remaining item type is a tool call. Resolve the tracked entry and + // drain the host-decline flag here, once, so all completion paths treat a + // declined tool uniformly (reported as `userCancelled` via + // `error.code = 'denied'`) instead of depending on which tool type completed. + const entry = state.itemToToolCall.get(params.item.id); + if (!entry) { + return []; + } + state.itemToToolCall.delete(params.item.id); + const declined = state.declinedToolCalls.delete(entry.toolCallId); if (params.item.type === 'commandExecution') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.status === 'completed' && (params.item.exitCode === 0 || params.item.exitCode === null); const output = params.item.aggregatedOutput ?? entry.output; const command = params.item.command ?? ''; @@ -639,17 +664,13 @@ export function mapItemCompleted( : undefined, error: success ? undefined : { message: exit !== null ? `Exit code ${exit}` : 'Command failed', + ...(declined ? { code: 'denied' } : {}), }, }, }, ]; } if (params.item.type === 'webSearch') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const query = describeWebSearch(params.item.query, params.item.action); return [{ type: ActionType.ChatToolCallComplete, @@ -662,11 +683,6 @@ export function mapItemCompleted( }]; } if (params.item.type === 'fileChange') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const output = fileChangeOutput(params.item.changes) || entry.output; const success = params.item.status === 'completed'; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; @@ -674,7 +690,7 @@ export function mapItemCompleted( success, pastTenseMessage: success ? 'Applied file changes' : 'Failed to apply file changes', content, - ...(success ? {} : { error: { message: `Patch ${params.item.status}` } }), + ...(success ? {} : { error: { message: `Patch ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }; return [{ type: ActionType.ChatToolCallComplete, @@ -684,11 +700,6 @@ export function mapItemCompleted( }]; } if (params.item.type === 'mcpToolCall') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.status === 'completed' && !params.item.error; const output = mcpToolOutput(params.item.result, params.item.error?.message) || entry.output; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; @@ -700,16 +711,11 @@ export function mapItemCompleted( success, pastTenseMessage: success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`, content, - ...(success ? {} : { error: { message: params.item.error?.message ?? `MCP tool ${params.item.status}` } }), + ...(success ? {} : { error: { message: params.item.error?.message ?? `MCP tool ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }, }]; } if (params.item.type === 'dynamicToolCall') { - const entry = state.itemToToolCall.get(params.item.id); - if (!entry) { - return []; - } - state.itemToToolCall.delete(params.item.id); const success = params.item.success === true || params.item.status === 'completed'; const output = dynamicToolOutput(params.item.contentItems) || entry.output; const content = output ? [{ type: ToolResultContentType.Text as const, text: output }] : undefined; @@ -722,7 +728,7 @@ export function mapItemCompleted( success, pastTenseMessage: serverPastTense ?? (success ? `Called ${entry.toolName}` : `Failed to call ${entry.toolName}`), content, - ...(success ? {} : { error: { message: `Dynamic tool ${params.item.status}` } }), + ...(success ? {} : { error: { message: `Dynamic tool ${params.item.status}`, ...(declined ? { code: 'denied' } : {}) } }), }, }]; } diff --git a/src/vs/platform/agentHost/node/commandAutoApprover.ts b/src/vs/platform/agentHost/node/commandAutoApprover.ts index 44ab147c63fda4..19a6b64e301aa9 100644 --- a/src/vs/platform/agentHost/node/commandAutoApprover.ts +++ b/src/vs/platform/agentHost/node/commandAutoApprover.ts @@ -6,9 +6,10 @@ import type { Language, Parser, Query, QueryCapture } from '@vscode/tree-sitter-wasm'; import * as fs from 'fs'; import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { FileAccess } from '../../../base/common/network.js'; +import { FileAccess, nodeModulesAsarUnpackedPath, nodeModulesPath } from '../../../base/common/network.js'; import { escapeRegExpCharacters, regExpLeadsToEndlessLoop } from '../../../base/common/strings.js'; import { URI } from '../../../base/common/uri.js'; +import product from '../../product/common/product.js'; import { ILogService } from '../../log/common/log.js'; import type { AgentHostTerminalAutoApproveRuleValue, AgentHostTerminalAutoApproveRules } from '../common/agentHostSchema.js'; @@ -298,8 +299,11 @@ export class CommandAutoApprover extends Disposable { return; } - // Resolve WASM files from node_modules - const moduleRoot = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules', '@vscode', 'tree-sitter-wasm', 'wasm'); + // Resolve WASM files from node_modules. In a built app the `.wasm` files + // are unpacked next to the ASAR archive (`node_modules.asar.unpacked`), + // while in dev they live in `node_modules`. + const moduleRootPath = product.commit ? nodeModulesAsarUnpackedPath : nodeModulesPath; + const moduleRoot = URI.joinPath(FileAccess.asFileUri(moduleRootPath), '@vscode', 'tree-sitter-wasm', 'wasm'); const wasmPath = URI.joinPath(moduleRoot, 'tree-sitter.wasm').fsPath; await TreeSitter.Parser.init({ diff --git a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts index ec3155d14c7830..06af63977f823d 100644 --- a/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts +++ b/src/vs/platform/agentHost/node/copilot/agentHostSandboxEngine.ts @@ -49,7 +49,11 @@ class AgentHostTerminalSandboxHost implements ITerminalSandboxEngineHost { async getRuntimeInfo(): Promise<ITerminalSandboxRuntimeInfo> { const appRoot = dirname(FileAccess.asFileUri('').path); const runAsNode = !!process.versions['electron']; - return { appRoot, execPath: process.execPath, runAsNode }; + // In a packaged build the native binaries (ripgrep-universal, mxc-sdk) are + // unpacked from the archive into `node_modules.asar.unpacked`; in dev they + // remain in plain `node_modules`. + const nativeModulesDir = this._environmentService.isBuilt ? 'node_modules.asar.unpacked' : 'node_modules'; + return { appRoot, execPath: process.execPath, runAsNode, nativeModulesDir }; } async getUserHome(): Promise<URI | undefined> { diff --git a/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts new file mode 100644 index 00000000000000..9e2643ce2a3234 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/buildSessionEvents.ts @@ -0,0 +1,279 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { SessionEvent } from '@github/copilot-sdk'; +import { generateUuid, isUUID } from '../../../../base/common/uuid.js'; +import { ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ToolCallCompletedState, type ToolResultContent, type ToolResultSubagentContent, type Turn } from '../../common/state/sessionState.js'; + +/** + * Default schema version stamped on the synthesized `session.start` event. + * + * The Copilot SDK owns the authoritative event-format schema version; this + * value is only meaningful when the resulting `events.jsonl` is actually + * resumed by a CLI. It is irrelevant to the reconstruction performed by + * {@link mapSessionEvents} (which ignores it), so unit tests can rely on the + * default. Callers that write a log for real resume SHOULD pass the version the + * target CLI expects. + */ +const DEFAULT_SESSION_EVENT_SCHEMA_VERSION = 1; + +/** + * Producer identifier stamped on the synthesized `session.start` event so a + * migrated event log is attributable to this translation path rather than a + * genuine agent run. + */ +const MIGRATION_PRODUCER = 'vscode-copilot-migration'; + +/** + * Options controlling how {@link buildSessionEventsFromTurns} synthesizes a + * Copilot SDK event log from VS Code turns. + */ +export interface IBuildSessionEventsOptions { + /** The target session id (stamped on the `session.start` event). */ + readonly sessionId: string; + /** Working directory of the session, recorded on `session.start` context. */ + readonly workingDirectory?: string; + /** Model id to attribute the synthesized assistant messages to, if known. */ + readonly model?: string; + /** Copilot application version string for `session.start`. Defaults to `0.0.0`. */ + readonly copilotVersion?: string; + /** Event-format schema version for `session.start`. See {@link DEFAULT_SESSION_EVENT_SCHEMA_VERSION}. */ + readonly schemaVersion?: number; + /** Base time for the synthesized (monotonically increasing) event timestamps. Defaults to now. */ + readonly startTime?: Date; +} + +/** + * Translates a sequence of VS Code {@link Turn}s into a Copilot SDK + * {@link SessionEvent} log (the on-disk `events.jsonl` shape), reversing the + * reconstruction performed by `mapSessionEvents`. + * + * The result is a valid parent-linked event chain: a leading `session.start` + * followed, per turn, by a `user.message` and the turn's response emitted in + * order — assistant markdown/reasoning as `assistant.message` events and each + * completed tool call as a `tool.execution_start` + `tool.execution_complete` + * pair (preceded by a `subagent.started` when the tool call carries sub-agent + * content, so the sub-agent name/description survive a resume). Assistant text + * accumulated before a tool call is flushed as its own `assistant.message` so + * the reconstructed part order matches the original. + * + * Every event envelope id must be a UUID (the Copilot runtime rejects non-UUID + * event ids). A turn whose {@link Turn.id} is already a UUID reuses it as the + * `user.message` envelope id, so the reconstructed turn keeps the same id the + * SDK's fork / truncate RPCs address and the caller can seed matching protocol + * turns; a non-UUID id is replaced with a minted UUID. + * + * Only completed tool calls are translated; streaming / pending tool states and + * file-edit content (which lives in the session database) are not yet emitted. + * A cancelled turn ({@link TurnState.Cancelled}) emits a trailing `abort` event + * so it reconstructs as cancelled. Content refs and system notifications are + * skipped. + */ +export function buildSessionEventsFromTurns(turns: readonly Turn[], options: IBuildSessionEventsOptions): SessionEvent[] { + const events: SessionEvent[] = []; + let parentId: string | null = null; + + // Synthesize strictly increasing ISO timestamps so the event order on disk + // is unambiguous even for turns that were originally seconds apart. + let clock = (options.startTime ?? new Date()).getTime(); + const nextTimestamp = (): string => new Date(clock++).toISOString(); + + const push = (event: SessionEvent): void => { + events.push(event); + parentId = event.id; + }; + + /** Emits the `tool.execution_start` + `tool.execution_complete` pair for a completed tool call. */ + const pushCompletedToolCall = (tc: ToolCallCompletedState): void => { + let toolArguments: Record<string, unknown> | undefined; + if (tc.toolInput) { + try { + const parsed = JSON.parse(tc.toolInput); + if (parsed && typeof parsed === 'object') { + toolArguments = parsed as Record<string, unknown>; + } + } catch { + // Non-JSON tool input: omit structured arguments (the forward + // mapper regenerates the invocation display from the tool name). + } + } + // If the tool call carries sub-agent identity, emit `subagent.started` + // first so a resume reconstructs the sub-agent name/description onto the + // parent tool call (the SDK keys this by `toolCallId`). Required fields + // fall back to the title so the event stays well-formed. + const subagent = tc.content?.find((c): c is ToolResultSubagentContent => c.type === ToolResultContentType.Subagent); + if (subagent) { + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'subagent.started', + data: { + toolCallId: tc.toolCallId, + agentName: subagent.agentName ?? subagent.title, + agentDisplayName: subagent.title, + agentDescription: subagent.description ?? '', + }, + }); + } + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'tool.execution_start', + data: { + toolCallId: tc.toolCallId, + toolName: tc.toolName, + ...(toolArguments ? { arguments: toolArguments } : {}), + }, + }); + const resultText = extractToolResultText(tc.content); + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'tool.execution_complete', + data: { + toolCallId: tc.toolCallId, + success: tc.success, + ...(tc.success ? { result: { content: resultText } } : {}), + ...(tc.error ? { error: { message: tc.error.message, ...(tc.error.code ? { code: tc.error.code } : {}) } } : {}), + }, + }); + }; + + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'session.start', + data: { + sessionId: options.sessionId, + copilotVersion: options.copilotVersion ?? '0.0.0', + producer: MIGRATION_PRODUCER, + startTime: nextTimestamp(), + version: options.schemaVersion ?? DEFAULT_SESSION_EVENT_SCHEMA_VERSION, + ...(options.model ? { selectedModel: options.model } : {}), + ...(options.workingDirectory ? { context: { cwd: options.workingDirectory } } : {}), + }, + }); + + for (const turn of turns) { + // Reuse the turn id as the user-message envelope id when it is already a + // UUID (the runtime rejects non-UUID event ids) so the reconstructed turn + // keeps the id the SDK's fork/truncate RPCs address and a caller can seed + // a matching protocol turn; otherwise mint a fresh UUID. + push({ + id: isUUID(turn.id) ? turn.id : generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'user.message', + data: { + content: turn.message.text, + source: 'user', + }, + }); + + let markdown = ''; + let reasoning = ''; + const flushAssistantMessage = (): void => { + if (!markdown && !reasoning) { + return; + } + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'assistant.message', + data: { + content: markdown, + messageId: generateUuid(), + ...(reasoning ? { reasoningText: reasoning } : {}), + ...(options.model ? { model: options.model } : {}), + }, + }); + markdown = ''; + reasoning = ''; + }; + + for (const part of turn.responseParts) { + if (part.kind === ResponsePartKind.Markdown) { + // Flush pending reasoning first: the reverse mapper emits reasoning + // before content within a single assistant.message, so interleaved + // reasoning/markdown must be split into separate messages to keep + // the original stream order. + if (reasoning) { + flushAssistantMessage(); + } + markdown += part.content; + } else if (part.kind === ResponsePartKind.Reasoning) { + if (markdown) { + flushAssistantMessage(); + } + reasoning += part.content; + } else if (part.kind === ResponsePartKind.ToolCall && part.toolCall.status === ToolCallStatus.Completed) { + // Flush accumulated assistant text before the tool call so the + // reconstructed part order matches the original interleaving. + flushAssistantMessage(); + pushCompletedToolCall(part.toolCall); + } + // Content refs and system notifications are not yet translated. + } + flushAssistantMessage(); + + // A cancelled turn reconstructs as `TurnState.Cancelled` only if the event + // stream ends without a finalizing assistant message (the reverse mapper + // defaults to cancelled and upgrades to complete on a final message). Emit + // an explicit `abort` after the already-flushed content so the turn is + // marked cancelled while keeping its text. + if (turn.state === TurnState.Cancelled) { + push({ + id: generateUuid(), + parentId, + timestamp: nextTimestamp(), + type: 'abort', + data: { reason: 'user_initiated' }, + }); + } + } + + return events; +} + +/** Concatenates the text of a completed tool call's textual result content blocks. */ +function extractToolResultText(content: readonly ToolResultContent[] | undefined): string { + if (!content) { + return ''; + } + let text = ''; + for (const item of content) { + if (item.type === ToolResultContentType.Text) { + text += item.text; + } + } + return text; +} + +/** + * Serializes SDK session events into the on-disk `events.jsonl` representation: + * one JSON object per line, terminated by a newline so a subsequent append + * starts on a fresh line. Returns the empty string for an empty event list. + */ +export function serializeSessionEventsToJsonl(events: readonly SessionEvent[]): string { + if (events.length === 0) { + return ''; + } + return events.map(event => JSON.stringify(event)).join('\n') + '\n'; +} + +/** + * Convenience combining {@link buildSessionEventsFromTurns} and + * {@link serializeSessionEventsToJsonl}: turns the given VS Code turns directly + * into the `events.jsonl` bytes to write for the target session. + */ +export function buildSessionEventLogFromTurns(turns: readonly Turn[], options: IBuildSessionEventsOptions): string { + return serializeSessionEventsToJsonl(buildSessionEventsFromTurns(turns, options)); +} + diff --git a/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts new file mode 100644 index 00000000000000..f101cd10c64757 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokLmProxyService.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as http from 'http'; +import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { ILogService } from '../../../log/common/log.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { parseProxyBearer } from '../claude/claudeProxyAuth.js'; +import { + ILoopbackProxyHandle, + ILoopbackProxyRuntime, + IProxyInFlight, + LoopbackProxyServer, + readProxyRequestBody, +} from '../shared/loopbackProxyServer.js'; +import { + IOpenAiChatRequest, + OpenAiTranslationError, + bridgeResultToSseFrames, + openAiErrorBody, + openAiRequestToBridge, +} from './byokOpenAiTranslation.js'; + +// #region Public types + +/** + * Handle returned by {@link IByokLmProxyService.start}. Refcounts the shared + * loopback server (see {@link LoopbackProxyServer}): when every handle is + * disposed the listener closes and the nonce is destroyed; the next `start()` + * rebinds with a fresh port and nonce. + * + * **Subprocess ownership invariant.** Callers that hand `baseUrl`/`nonce` to + * the Copilot SDK runtime subprocess MUST kill that subprocess before calling + * `dispose()` — after disposal the proxy may rebind on a different port and the + * subprocess would silently lose its endpoint (same contract as the Claude and + * Codex proxies). + */ +export interface IByokLmProxyHandle extends ILoopbackProxyHandle { + /** e.g. `http://127.0.0.1:54321` — no trailing slash. */ + readonly baseUrl: string; + /** 256-bit hex string. Combine with a session id as `Bearer <nonce>.<sessionId>`. */ + readonly nonce: string; + /** + * Build the provider `baseUrl` for a given BYOK vendor. The vendor is + * encoded into the path so a single proxy can serve every vendor; the + * runtime appends `/chat/completions` to this URL. + */ + providerBaseUrl(vendor: string): string; +} + +export const IByokLmProxyService = createDecorator<IByokLmProxyService>('byokLmProxyService'); + +export interface IByokLmProxyService { + readonly _serviceBrand: undefined; + + /** Start the proxy (if not already running) and return a refcounted handle. */ + start(): Promise<IByokLmProxyHandle>; + + /** + * Force-close the proxy regardless of refcount and abort in-flight + * requests. Idempotent; subsequent `start()` calls rebind. + */ + dispose(): void; +} + +// #endregion + +const PROXY_USER_FACING_NAME = 'ByokLmProxyService'; +const VENDOR_PATH_PREFIX = '/v/'; +const CHAT_COMPLETIONS_SUFFIX = '/chat/completions'; + +/** + * The BYOK proxy keeps no per-bind mutable state: the active renderer bridge is + * resolved from {@link IByokLmBridgeRegistry} at request time, and the nonce + * lives on the runtime owned by {@link LoopbackProxyServer}. + */ +type ByokLmProxyState = undefined; + +/** + * Local OpenAI-compatible HTTP proxy that lets the Copilot SDK runtime run + * BYOK models provided by VS Code extensions. The runtime is configured with a + * `type: 'openai'`, `wireApi: 'completions'` provider whose `baseUrl` points + * here; inbound `POST /v/<vendor>/chat/completions` requests are authenticated, + * translated, and forwarded to the renderer LM API via + * {@link IByokLmBridgeRegistry}, and the buffered completion is streamed back + * as OpenAI Chat Completions SSE. + * + * The server lifecycle — lazy bind on `127.0.0.1`, nonce minting, refcounted + * handles, in-flight tracking, and teardown — is inherited from + * {@link LoopbackProxyServer}; this subclass only implements request routing. + */ +export class ByokLmProxyService extends LoopbackProxyServer<ByokLmProxyState> implements IByokLmProxyService { + + declare readonly _serviceBrand: undefined; + + constructor( + @ILogService logService: ILogService, + @IByokLmBridgeRegistry private readonly _bridgeRegistry: IByokLmBridgeRegistry, + ) { + super(PROXY_USER_FACING_NAME, logService); + } + + protected createState(): ByokLmProxyState { + // No per-bind state — the bridge is resolved from the registry per request. + return undefined; + } + + async start(): Promise<IByokLmProxyHandle> { + const { runtime, release } = await this.acquire(); + + let disposed = false; + return { + baseUrl: runtime.baseUrl, + nonce: runtime.nonce, + providerBaseUrl: (vendor: string) => `${runtime.baseUrl}${VENDOR_PATH_PREFIX}${encodeURIComponent(vendor)}`, + dispose: () => { + if (disposed) { + return; + } + disposed = true; + release(); + }, + }; + } + + /** Emit the base's fallback failure using the OpenAI error envelope. */ + protected override writeInternalError(res: http.ServerResponse): void { + this._writeJsonError(res, 500, 'Internal proxy error'); + } + + protected override async handleRequest(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime<ByokLmProxyState>): Promise<void> { + const method = req.method ?? 'GET'; + const pathname = new URL(req.url ?? '/', 'http://127.0.0.1').pathname; + this._logService.trace(`[${PROXY_USER_FACING_NAME}] ${method} ${pathname}`); + + if (method === 'GET' && pathname === '/') { + res.writeHead(200, { 'Content-Type': 'text/plain' }); + res.end('ok'); + return; + } + + // Inbound requests carry `Bearer <nonce>.<sessionId>`; the runtime is + // handed `<nonce>.<sessionId>` at session launch. + const auth = parseProxyBearer(req.headers, runtime.nonce); + if (!auth.valid || !auth.sessionId) { + this._writeJsonError(res, 401, 'Invalid authentication', 'authentication_error'); + return; + } + + const vendor = this._parseVendorFromChatPath(pathname); + if (method === 'POST' && vendor !== undefined) { + await this._handleChatCompletions(req, res, runtime, vendor); + return; + } + + this._writeJsonError(res, 404, `No route for ${method} ${pathname}`, 'not_found_error'); + } + + /** + * Extract the vendor from a `/v/<vendor>/chat/completions` path, or return + * `undefined` when the path is not a chat-completions route. + */ + private _parseVendorFromChatPath(pathname: string): string | undefined { + if (!pathname.startsWith(VENDOR_PATH_PREFIX) || !pathname.endsWith(CHAT_COMPLETIONS_SUFFIX)) { + return undefined; + } + const vendorSegment = pathname.slice(VENDOR_PATH_PREFIX.length, pathname.length - CHAT_COMPLETIONS_SUFFIX.length); + if (!vendorSegment) { + return undefined; + } + let vendor: string; + try { + vendor = decodeURIComponent(vendorSegment); + } catch { + return undefined; + } + // Re-check for a path separator *after* decoding: a `%2F` survives the + // pre-decode prefix/suffix checks but would decode into a second path + // segment, breaking the single-segment `vendor/id` selection-id convention. + if (!vendor || vendor.includes('/')) { + return undefined; + } + return vendor; + } + + private async _handleChatCompletions(req: http.IncomingMessage, res: http.ServerResponse, runtime: ILoopbackProxyRuntime<ByokLmProxyState>, vendor: string): Promise<void> { + let body: IOpenAiChatRequest; + try { + const raw = await readProxyRequestBody(req); + body = JSON.parse(raw) as IOpenAiChatRequest; + } catch (err) { + this._writeJsonError(res, 400, `Invalid request body: ${err instanceof Error ? err.message : String(err)}`, 'invalid_request_error'); + return; + } + + let bridgeRequest; + try { + bridgeRequest = openAiRequestToBridge(vendor, body); + } catch (err) { + const message = err instanceof OpenAiTranslationError ? err.message : String(err); + this._writeJsonError(res, 400, message, 'invalid_request_error'); + return; + } + + const connection = this._bridgeRegistry.getServingConnection(); + if (!connection) { + this._writeJsonError(res, 503, 'No renderer connection available to service BYOK models', 'api_error'); + return; + } + + // Register the request so {@link LoopbackProxyServer} aborts it on + // teardown; a client-side disconnect also flips `clientGone` and aborts. + // Both surface through the shared `AbortController`, which we re-check + // after the async bridge hop before touching the response. + const entry: IProxyInFlight = { ac: new AbortController(), res, clientGone: false }; + runtime.inFlight.add(entry); + const onClose = () => { + entry.clientGone = true; + entry.ac.abort(); + }; + res.on('close', onClose); + + try { + const result = await connection.chat(bridgeRequest); + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + if (result.error) { + this._writeJsonError(res, 502, result.error, 'api_error'); + return; + } + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + for (const frame of bridgeResultToSseFrames(result, bridgeRequest.modelId)) { + res.write(frame); + } + res.end(); + } catch (err) { + if (entry.ac.signal.aborted || res.writableEnded) { + return; + } + const message = err instanceof Error ? err.message : String(err); + if (!res.headersSent) { + this._writeJsonError(res, 502, message, 'api_error'); + } else { + try { res.end(); } catch { /* ignore */ } + } + } finally { + res.removeListener('close', onClose); + runtime.inFlight.delete(entry); + } + } + + private _writeJsonError(res: http.ServerResponse, status: number, message: string, type = 'api_error'): void { + if (res.headersSent || res.writableEnded) { + return; + } + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(openAiErrorBody(message, type)); + } +} + +/** + * No-op {@link IByokLmProxyService} for agent host entrypoints that do not + * support BYOK — e.g. the remote agent host, where no extension host runs + * alongside the agent host to serve the renderer LM API. + * + */ +export class NullByokLmProxyService implements IByokLmProxyService { + + declare readonly _serviceBrand: undefined; + + start(): Promise<IByokLmProxyHandle> { + return Promise.reject(new Error('BYOK is not supported in this agent host')); + } + + dispose(): void { + // No-op: the null proxy never binds a socket, so there is nothing to close. + } +} diff --git a/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts new file mode 100644 index 00000000000000..d5deb2786ea32f --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/byokOpenAiTranslation.ts @@ -0,0 +1,241 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { + IByokLmChatMessage, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmTool, + IByokLmToolCall, +} from '../../common/agentHostByokLm.js'; + +/** + * Minimal subset of the OpenAI Chat Completions wire format the Copilot SDK + * runtime emits for a `type: 'openai'`, `wireApi: 'completions'` provider + * (verified against the runtime's `chat_completion_transport.rs`, which POSTs + * to `{baseUrl}/chat/completions`). Only the fields this proxy understands are + * modeled; unknown fields are ignored. + */ + +interface IOpenAiTextContentPart { + readonly type: 'text'; + readonly text: string; +} + +type IOpenAiContentPart = IOpenAiTextContentPart | { readonly type: string;[k: string]: unknown }; + +interface IOpenAiToolCall { + readonly id?: string; + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly arguments?: string; + }; +} + +interface IOpenAiRequestMessage { + readonly role?: string; + readonly content?: string | IOpenAiContentPart[] | null; + readonly tool_calls?: IOpenAiToolCall[]; + readonly tool_call_id?: string; +} + +interface IOpenAiToolDefinition { + readonly type?: string; + readonly function?: { + readonly name?: string; + readonly description?: string; + readonly parameters?: object; + }; +} + +export interface IOpenAiChatRequest { + readonly model?: string; + readonly messages?: IOpenAiRequestMessage[]; + readonly tools?: IOpenAiToolDefinition[]; + readonly stream?: boolean; + readonly temperature?: number; + readonly top_p?: number; + readonly max_tokens?: number; + readonly [k: string]: unknown; +} + +/** Thrown when the inbound body cannot be mapped to a bridge request. */ +export class OpenAiTranslationError extends Error { } + +function flattenContent(content: string | IOpenAiContentPart[] | null | undefined): string { + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + let out = ''; + for (const part of content) { + if (part && part.type === 'text' && typeof (part as IOpenAiTextContentPart).text === 'string') { + out += (part as IOpenAiTextContentPart).text; + } + } + return out; + } + return ''; +} + +function toBridgeRole(role: string | undefined): IByokLmChatMessage['role'] { + switch (role) { + case 'system': + case 'developer': + return 'system'; + case 'assistant': + return 'assistant'; + case 'tool': + case 'function': + return 'tool'; + case 'user': + default: + return 'user'; + } +} + +function toBridgeToolCalls(toolCalls: IOpenAiToolCall[] | undefined): IByokLmToolCall[] | undefined { + if (!toolCalls || toolCalls.length === 0) { + return undefined; + } + const mapped: IByokLmToolCall[] = []; + for (let i = 0; i < toolCalls.length; i++) { + const call = toolCalls[i]; + const name = call.function?.name; + if (!name) { + // A tool call without a function name is malformed: reject at the + // boundary (→ 400) rather than forwarding an invalid `tool_use` part + // that would fail later, deeper in the renderer. + throw new OpenAiTranslationError(`tool_calls[${i}].function.name is required`); + } + mapped.push({ + id: call.id ?? `call_${i}`, + name, + argumentsJson: call.function?.arguments ?? '{}', + }); + } + return mapped; +} + +function toBridgeTools(tools: IOpenAiToolDefinition[] | undefined): IByokLmTool[] | undefined { + if (!tools || tools.length === 0) { + return undefined; + } + const mapped: IByokLmTool[] = []; + for (const tool of tools) { + const fn = tool.function; + if (!fn?.name) { + continue; + } + mapped.push({ + name: fn.name, + description: fn.description, + parametersSchema: fn.parameters, + }); + } + return mapped.length ? mapped : undefined; +} + +/** + * Convert a parsed OpenAI Chat Completions request into the serializable + * bridge request. `vendor` is the synthesized provider name the runtime used + * (it is not present in the OpenAI body); `model` becomes the provider-local + * wire model id resolved on the renderer. + */ +export function openAiRequestToBridge(vendor: string, body: IOpenAiChatRequest): IByokLmChatRequest { + const model = typeof body.model === 'string' ? body.model : ''; + if (!model) { + throw new OpenAiTranslationError('Request is missing the "model" field'); + } + const sourceMessages = Array.isArray(body.messages) ? body.messages : []; + const messages: IByokLmChatMessage[] = sourceMessages.map(message => ({ + role: toBridgeRole(message.role), + content: flattenContent(message.content), + toolCalls: toBridgeToolCalls(message.tool_calls), + toolCallId: message.tool_call_id, + })); + + const modelOptions: Record<string, unknown> = {}; + if (typeof body.temperature === 'number') { + modelOptions.temperature = body.temperature; + } + if (typeof body.top_p === 'number') { + modelOptions.top_p = body.top_p; + } + if (typeof body.max_tokens === 'number') { + modelOptions.max_tokens = body.max_tokens; + } + + return { + vendor, + modelId: model, + messages, + tools: toBridgeTools(body.tools), + modelOptions: Object.keys(modelOptions).length ? modelOptions : undefined, + }; +} + +let chunkCounter = 0; + +function nextCompletionId(): string { + chunkCounter = (chunkCounter + 1) % Number.MAX_SAFE_INTEGER; + return `chatcmpl-byok-${Date.now().toString(36)}-${chunkCounter.toString(36)}`; +} + +/** Serialize a single SSE `data:` frame. */ +function sseFrame(payload: unknown): string { + return `data: ${JSON.stringify(payload)}\n\n`; +} + +/** + * Encode a buffered {@link IByokLmChatResult} as a sequence of OpenAI + * `chat.completion.chunk` SSE frames terminated by `data: [DONE]`. + * + * The whole completion is emitted in one content delta (Stage 1 is + * non-streaming end-to-end); the runtime's SSE parser accepts this shape. + */ +export function bridgeResultToSseFrames(result: IByokLmChatResult, model: string): string[] { + const id = nextCompletionId(); + const created = Math.floor(Date.now() / 1000); + const base = { id, object: 'chat.completion.chunk', created, model }; + const frames: string[] = []; + + // Role delta first, matching the OpenAI streaming contract. + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] })); + + if (result.content) { + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { content: result.content }, finish_reason: null }] })); + } + + let finishReason: 'stop' | 'tool_calls' = 'stop'; + if (result.toolCalls && result.toolCalls.length > 0) { + finishReason = 'tool_calls'; + const toolCallsDelta = result.toolCalls.map((call, index) => ({ + index, + id: call.id, + type: 'function', + function: { name: call.name, arguments: call.argumentsJson }, + })); + frames.push(sseFrame({ ...base, choices: [{ index: 0, delta: { tool_calls: toolCallsDelta }, finish_reason: null }] })); + } + + const finalChunk: Record<string, unknown> = { ...base, choices: [{ index: 0, delta: {}, finish_reason: finishReason }] }; + if (result.usage) { + finalChunk.usage = { + prompt_tokens: result.usage.promptTokens ?? 0, + completion_tokens: result.usage.completionTokens ?? 0, + total_tokens: (result.usage.promptTokens ?? 0) + (result.usage.completionTokens ?? 0), + }; + } + frames.push(sseFrame(finalChunk)); + frames.push('data: [DONE]\n\n'); + return frames; +} + +/** Build an OpenAI-style error envelope body. */ +export function openAiErrorBody(message: string, type = 'api_error'): string { + return JSON.stringify({ error: { message, type } }); +} diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 2b4c8b2605bd7c..f7474f734743ba 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -11,12 +11,12 @@ import { type CancellationToken } from '../../../../base/common/cancellation.js' import { CancellationError } from '../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; -import { Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, Disposable, DisposableMap, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; -import { FileAccess } from '../../../../base/common/network.js'; +import { FileAccess, nodeModulesAsarUnpackedPath, nodeModulesPath } from '../../../../base/common/network.js'; import { formatTokenCount } from '../../../../base/common/numbers.js'; import { equals } from '../../../../base/common/objects.js'; -import { observableValue } from '../../../../base/common/observable.js'; +import { autorun, observableValue, type ISettableObservable } from '../../../../base/common/observable.js'; import { basename, delimiter, dirname, join } from '../../../../base/common/path.js'; import { basename as resourceBasename, isEqual, isEqualOrParent, joinPath as resourceJoinPath, relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -27,40 +27,59 @@ import { IParsedAgent, IParsedPlugin, IParsedRule, IParsedSkill, parseAgentFile, import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; +import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; -import { AgentHostMcpServersConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; +import { AgentHostMcpServersConfigKey, AgentHostPreferLongContextEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AutoApproveLevel, ISchemaProperty, SessionMode, createSchema, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE, IActiveClient, IAgent, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js'; -import { getEffectiveAgents } from '../../common/customAgents.js'; +import { AgentSessionEntry, decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentPeerChats.js'; +import { AgentSession, AgentSignal, AuthenticateParams, IActiveClient, IAgent, IAgentChatDataChange, IAgentChats, IAgentLegacyChat, IAgentCreateChatForkSource, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentDescriptor, IAgentMaterializeSessionEvent, IAgentModelInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, IAgentSessionProjectInfo, IAgentSpawnChatEvent, IMcpNotification, IRestoredSubagentSession, SubagentChatSignal } from '../../common/agentService.js'; import { getReasoningEffortDescription, getReasoningEffortLabel } from '../../common/reasoningEffort.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { ISessionDataService, SESSION_DB_FILENAME } from '../../common/sessionDataService.js'; +import { IAgentHostProxyResolver } from '../agentHostProxyResolver.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ProtectedResourceMetadata, type AgentSelection, type ChildCustomizationType, type ConfigPropertySchema, type ConfigSchema, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type SessionAction } from '../../common/state/sessionActions.js'; -import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseSubagentSessionUri, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; +import { AgentCustomization, CustomizationLoadStatus, CustomizationType, ResponsePartKind, RuleCustomization, ChatInputResponseKind, SkillCustomization, customizationId, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, AH_META_WORKSPACELESS_DB_KEY, type ChildCustomization, type ClientPluginCustomization, type Customization, type DirectoryCustomization, type HookCustomization, type MessageAttachment, type PendingMessage, type PluginCustomization, type PolicyState, type ResponsePart, type ChatInputAnswer, type ToolCallResult, type Turn } from '../../common/state/sessionState.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; import { IAgentHostCompletions } from '../agentHostCompletions.js'; import { IAgentHostGitService, META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; -import { findMcpChildId } from '../shared/mcpCustomizationController.js'; +import { findMcpChildId, type IMcpServerRuntimeState } from '../shared/mcpCustomizationController.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; import { COPILOT_BRANCH_PREFIX, ICopilotBranchNameGenerator } from './copilotBranchNameGenerator.js'; +import { buildSessionEventLogFromTurns } from './buildSessionEvents.js'; import { CopilotAgentSession, type CopilotSdkMode } from './copilotAgentSession.js'; import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js'; import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; -import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, getCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; import { ShellManager } from './copilotShellTools.js'; -import { isRestrictedTelemetryEnabled } from './copilotTokenFields.js'; +import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; import { CopilotSlashCommandCompletionProvider } from './copilotSlashCommandCompletionProvider.js'; import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; +import product from '../../../product/common/product.js'; const RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; +const COPILOT_CAPI_URL = 'https://api.githubcopilot.com'; +/** + * Proxy env vars that indicate the environment already configures a proxy. + */ +const COPILOT_PROXY_ENV_KEYS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'] as const; +/** + * Proxy env vars we set when injecting the resolved CAPI proxy. + */ +const COPILOT_PROXY_SET_ENV_KEYS = ['HTTP_PROXY', 'HTTPS_PROXY'] as const; /** * Maps a VS Code {@link LogLevel} to the Copilot CLI runtime's `logLevel` @@ -168,6 +187,8 @@ interface IProvisionalSession { agent: AgentSelection | undefined; /** Project info eagerly resolved at create time so the summary renders. */ readonly project: IAgentSessionProjectInfo | undefined; + /** Whether this session is workspace-less (surfaced in the sessions UI as a "Quick Chat"). */ + readonly workspaceless?: boolean; } export { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from './prompts/systemMessage.js'; @@ -179,17 +200,6 @@ interface ISerializedModelSelection { config?: unknown; } -/** - * A persisted additional (non-default) peer chat. Records the SDK conversation - * id that backs the chat so it can be resumed after a process restart, along - * with any model override chosen at creation time. - */ -interface IPersistedChat { - readonly sdkSessionId: string; - readonly model?: ModelSelection; -} - - /** * Subset of the JSON-RPC `MessageConnection` we reach into via the SDK's private `connection` field to wire plan mode. * See {@link CopilotAgent._enablePlanModeOnClient}. @@ -228,13 +238,18 @@ export function getCopilotWorktreesRoot(repositoryRoot: URI): URI { return URI.joinPath(repositoryRoot, '..', `${basename(repositoryRoot.fsPath)}.worktrees`); } -export function getCopilotWorktreeName(branchName: string): string { - // Strip the `agents/` branch prefix so the worktree directory name stays - // concise, then flatten any remaining path separators. - const withoutPrefix = branchName.startsWith(COPILOT_BRANCH_PREFIX) - ? branchName.substring(COPILOT_BRANCH_PREFIX.length) - : branchName; - return withoutPrefix.replace(/\//g, '-'); +export function getCopilotWorktreeDirectoryName(branchName: string, branchPrefix: string = ''): string { + // Strip the caller-supplied prefix (e.g. `git.branchPrefix`) and the + // built-in `agents/` prefix so the worktree directory name stays concise, + // then flatten any remaining path separators. + let name = branchName; + if (branchPrefix && name.startsWith(branchPrefix)) { + name = name.substring(branchPrefix.length); + } + if (name.startsWith(COPILOT_BRANCH_PREFIX)) { + name = name.substring(COPILOT_BRANCH_PREFIX.length); + } + return name.replace(/\//g, '-'); } /** @@ -314,6 +329,19 @@ function prependAnnouncementToFirstTurn( return result; } +/** + * Per-session container. Owns the session's default (main) chat and any + * additional peer chats, keeping all chats of a session together in a single + * {@link CopilotAgent._sessions} map (no parallel maps). The default chat is + * optional because a Copilot session can exist as a provisional record (in + * {@link CopilotAgent._provisionalSessions}) whose SDK-backed default chat has + * not materialized yet — a peer chat may still be created on it. Disposing the + * entry disposes the default chat and every peer chat. + * + * Exported for tests, which inject fake sessions into the container. + */ +export class CopilotSessionEntry extends AgentSessionEntry<CopilotAgentSession> { } + /** * Agent provider backed by the Copilot SDK {@link CopilotClient}. */ @@ -323,6 +351,14 @@ export class CopilotAgent extends Disposable implements IAgent { private readonly _onDidSessionProgress = this._register(new Emitter<AgentSignal>()); readonly onDidSessionProgress = this._onDidSessionProgress.event; + /** + * Membership channel for chats the agent spawns itself — sub-agents + * delegated by a tool call (the same fan-out the `subagent_started` / + * `subagent_completed` signals drive). The orchestrator routes these into + * the chat catalog so harness-spawned and user-driven chats share one path. + */ + private readonly _onDidSpawnChat = this._register(new Emitter<IAgentSpawnChatEvent>()); + readonly onDidSpawnChat = this._onDidSpawnChat.event; private readonly _onDidMaterializeSession = this._register(new Emitter<IAgentMaterializeSessionEvent>()); readonly onDidMaterializeSession = this._onDidMaterializeSession.event; /** @@ -334,6 +370,15 @@ export class CopilotAgent extends Disposable implements IAgent { readonly onMcpNotification = this._onMcpNotification.event; private readonly _models = observableValue<readonly IAgentModelInfo[]>(this, []); readonly models = this._models; + /** + * The two sources merged into {@link _models}: CAPI models from the CLI's + * `models.list` and BYOK models from the renderer bridge registry's serving + * window. Tracked separately so each can refresh independently without + * clobbering the other; {@link _publishModels} concatenates them for the + * picker. + */ + private _capiModels: readonly IAgentModelInfo[] = []; + private _byokModels: readonly IAgentModelInfo[] = []; /** Model IDs whose long-context tier costs the same as the default tier. */ private readonly _freeLongContextModels = new Set<string>(); @@ -354,6 +399,12 @@ export class CopilotAgent extends Disposable implements IAgent { private _client: CopilotClient | undefined; private _clientStarting: Promise<CopilotClient> | undefined; + /** + * Proxy URL injected into the running client's subprocess env (`undefined` + * when none was injected). Used to detect when a token change alters the + * token-discovered CAPI endpoint's proxy so we can restart the client. + */ + private _appliedProxy: string | undefined; private _githubToken: string | undefined; private _serverToolHost: IAgentServerToolHost | undefined; @@ -370,14 +421,24 @@ export class CopilotAgent extends Disposable implements IAgent { return this._restrictedTelemetryEnabled; } - private readonly _sessions = this._register(new DisposableMap<string, CopilotAgentSession>()); + private readonly _sessions = this._register(new DisposableMap<string, CopilotSessionEntry>()); /** - * Additional (non-default) chats within a session, keyed by chat channel - * URI string. Each entry is its own Copilot SDK conversation sharing the - * owning session's working directory/model scope. The default chat is not - * tracked here — it maps to the primary {@link _sessions} entry. + * Live `chatUri → backing` map for additional (non-default) peer chats, + * keyed by chat channel URI string. Records the SDK chat id (and + * optional model override) that backs each peer chat so the agent can + * resume it without consulting on-disk persistence. Populated by + * {@link createChat} on creation and by {@link materializeChat} on + * restore; the orchestrator now owns the durable peer-chat catalog (the + * agent no longer writes `copilot.chats`). */ - private readonly _chatSessions = this._register(new DisposableMap<string, CopilotAgentSession>()); + private readonly _chatBackings = new Map<string, IPersistedChat>(); + /** + * Fires when a peer chat's opaque `providerData` blob changes after + * creation (e.g. a per-chat model switch), so the orchestrator re-persists + * the refreshed token. See {@link IAgent.onDidChangeChatData}. + */ + private readonly _onDidChangeChatData = this._register(new Emitter<IAgentChatDataChange>()); + readonly onDidChangeChatData: Event<IAgentChatDataChange> = this._onDidChangeChatData.event; /** * Per-session MCP-notification subscriptions, keyed by `sessionId`. * Disposed in lockstep with the matching {@link _sessions} entry so @@ -425,19 +486,34 @@ export class CopilotAgent extends Disposable implements IAgent { @ISessionDataService private readonly _sessionDataService: ISessionDataService, @IAgentHostGitService private readonly _gitService: IAgentHostGitService, @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, @IAgentHostOTelService private readonly _otelService: IAgentHostOTelService, @ICopilotBranchNameGenerator private readonly _branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, @IAgentHostCheckpointService private readonly _checkpointService: IAgentHostCheckpointService, + @IAgentHostReviewService private readonly _reviewService: IAgentHostReviewService, + @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry private readonly _byokBridgeRegistry: IByokLmBridgeRegistry, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IAgentHostProxyResolver private readonly _proxyResolver: IAgentHostProxyResolver, ) { super(); this._plugins = this._register(this._instantiationService.createInstance(PluginController)); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this.onDidCustomizationsChange = this._plugins.onDidChange; - this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, { - isRubberDuckEnabled: () => this._isRubberDuckEnabled(), - getRuntimeSlashCommands: async (sessionId, options) => this._sessions.get(sessionId)?.getRuntimeSlashCommands(options) ?? [], - }, RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS))); + // Mirror the sub-agent fan-out signals onto the first-class spawned- + // chat channel so the orchestrator manages sub-agent chats + // through the same membership path as user-driven chats. + this._register(this._onDidSessionProgress.event(signal => this._emitSpawnedChatForSubagentSignal(signal))); + this._register(completions.registerProvider(new CopilotSlashCommandCompletionProvider(this.id, + { + isRubberDuckEnabled: () => this._isRubberDuckEnabled(), + getRuntimeSlashCommands: async (sessionId, options) => this._findAnySession(sessionId)?.getRuntimeSlashCommands(options) ?? [], + getSessionCustomizations: (sessionId) => this.getSessionCustomizations(AgentSession.uri(this.id, sessionId)), + }, + RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, + ))); // Restart the CLI client when a setting baked into the client/subprocess at // startup changes, disposing any active sessions. Both session sync (a client @@ -448,40 +524,90 @@ export class CopilotAgent extends Disposable implements IAgent { this._logService.error('[Copilot] Failed to restart client after config change', err) ); })); + + // Surface renderer BYOK models in the picker: republish them whenever the + // set of connected renderer bridges, or any renderer's models, change. + // The registry is only populated when `chat.agentHost.byokModels.enabled` + // is on, so this stays a no-op (empty list) while the feature is off. + this._register(this._byokBridgeRegistry.onDidChangeModels(() => { + this._logService.info('[Copilot] BYOK bridge changed; refreshing models'); + this._refreshByokModels(); + })); + + // `COPILOT_GH_HOST` is a subprocess env var (applied in `_ensureClient`) the + // CLI reads only at spawn time. When the configured GitHub Enterprise host + // changes - notably the startup race where the workbench pushes + // `githubEnterpriseUri` just after the client's initial spawn - restart the + // client so it comes up pointed at the right host. Driven off the endpoint + // service's `onDidChange` (which fires after its endpoints are recomputed) + // rather than the raw config event, so `getEnterpriseHost()` is current here. + this._register(this._gitHubEndpointService.onDidChange(() => { + this._restartClientIfStartupConfigChanged().catch(err => + this._logService.error('[Copilot] Failed to restart client after endpoint change', err) + ); + })); + } + + /** + * Translates the sub-agent fan-out signals into the first-class spawned- + * chat channel: `subagent_started` -> {@link onDidSpawnChat} + * (carrying the spawning tool call as the chat's parent edge). A completed + * subagent chat stays live and subscribable (it is removed only on session + * teardown), so there is no corresponding end event. The signals themselves + * are left untouched so the existing sub-agent behavior is preserved. + */ + private _emitSpawnedChatForSubagentSignal(signal: AgentSignal): void { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + this._onDidSpawnChat.fire(spawn); + } } private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled(); private _lastRubberDuckEnabled: boolean = this._isRubberDuckEnabled(); + private _lastEnterpriseHost: string | undefined = this._getEnterpriseHost(); private _isSessionSyncEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostSessionSyncEnabledConfigKey) === true; } private _isRubberDuckEnabled(): boolean { - return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) === true; + return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) === true; + } + + private _getEnterpriseHost(): string | undefined { + return this._gitHubEndpointService.getEnterpriseHost(); + } + + private _isPreferLongContextEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostPreferLongContextEnabledConfigKey) === true; } /** * Restarts the CLI client when a config value that is only read at client * startup ({@link _isSessionSyncEnabled} client option, {@link _isRubberDuckEnabled} - * subprocess env var) has changed. Any active sessions are disposed before - * the client is stopped; the latest values are picked up the next time - * {@link _ensureClient} runs. If the client is still starting up, the - * in-flight start detects the change against {@link _lastSessionSyncEnabled} / - * {@link _lastRubberDuckEnabled} and aborts so it never comes up stale. + * subprocess env var, or the `COPILOT_GH_HOST` enterprise host env var) has + * changed. Any active sessions are disposed before the client is stopped; the + * latest values are picked up the next time {@link _ensureClient} runs. If the + * client is still starting up, the in-flight start detects the change against + * {@link _lastSessionSyncEnabled} / {@link _lastRubberDuckEnabled} / + * {@link _lastEnterpriseHost} and aborts so it never comes up stale. */ private async _restartClientIfStartupConfigChanged(): Promise<void> { const sessionSync = this._isSessionSyncEnabled(); const rubberDuck = this._isRubberDuckEnabled(); - if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck) { + const enterpriseHost = this._getEnterpriseHost(); + if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck && this._lastEnterpriseHost === enterpriseHost) { return; } const changed = [ this._lastSessionSyncEnabled !== sessionSync ? `sessionSync=${sessionSync}` : undefined, this._lastRubberDuckEnabled !== rubberDuck ? `rubberDuck=${rubberDuck}` : undefined, + this._lastEnterpriseHost !== enterpriseHost ? `enterpriseHost=${enterpriseHost}` : undefined, ].filter((v): v is string => v !== undefined).join(', '); this._lastSessionSyncEnabled = sessionSync; this._lastRubberDuckEnabled = rubberDuck; + this._lastEnterpriseHost = enterpriseHost; if (this._client) { this._logService.info(`[Copilot] Startup config changed (${changed}), restarting CopilotClient`); this._sessions.clearAndDisposeAll(); @@ -500,14 +626,15 @@ export class CopilotAgent extends Disposable implements IAgent { return { provider: 'copilotcli', displayName: 'Copilot', - description: 'Copilot SDK agent running in a dedicated process', + description: localize('copilotAgent.description', "Copilot SDK agent running in the local agent host process"), + capabilities: { multipleChats: { fork: true } }, }; } getProtectedResources(): ProtectedResourceMetadata[] { return [ - GITHUB_COPILOT_PROTECTED_RESOURCE, - GITHUB_REPO_PROTECTED_RESOURCE + this._gitHubEndpointService.getCopilotResource(), + this._gitHubEndpointService.getRepoResource() ]; } @@ -520,7 +647,7 @@ export class CopilotAgent extends Disposable implements IAgent { const activeClient = this._getOrCreateActiveClient(session, directory); const fromPlugins = await activeClient.pluginController.getCustomizationsSettled(); const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const topLevelMcp = entry?.topLevelMcpCustomizations() ?? []; if (topLevelMcp.length === 0) { return fromPlugins; @@ -530,20 +657,30 @@ export class CopilotAgent extends Disposable implements IAgent { async handleMcpRequest(session: URI, serverName: string, method: string, params: Record<string, unknown> | undefined): Promise<unknown> { const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); if (!entry) { throw new Error(`Method not found: no active session ${sessionId}`); } return entry.handleMcpRequest(serverName, method, params); } + async startMcpServer(session: URI, id: string): Promise<void> { + const sessionId = AgentSession.id(session); + await this._findAnySession(sessionId)?.startMcpServer(id); + } + + async stopMcpServer(session: URI, id: string): Promise<void> { + const sessionId = AgentSession.id(session); + await this._findAnySession(sessionId)?.stopMcpServer(id); + } + private async _getSessionCustomizationDirectory(session: URI): Promise<URI | undefined> { const sessionId = AgentSession.id(session); const provisional = this._provisionalSessions.get(sessionId); if (provisional) { return provisional.workingDirectory; } - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const metadata = entry ? undefined : await this._readSessionMetadata(session); // For non-provisional sessions the anchor follows the working directory // (the worktree). Prefer it over a persisted `customizationDirectory`, @@ -552,10 +689,10 @@ export class CopilotAgent extends Disposable implements IAgent { } async authenticate(resource: string, token: string): Promise<boolean> { - if (resource === GITHUB_REPO_PROTECTED_RESOURCE.resource) { + if (resource === this._gitHubEndpointService.getRepoResource().resource) { return true; } - if (resource !== GITHUB_COPILOT_PROTECTED_RESOURCE.resource) { + if (resource !== this._gitHubEndpointService.getCopilotResource().resource) { return false; } const tokenChanged = this._githubToken !== token; @@ -563,18 +700,65 @@ export class CopilotAgent extends Disposable implements IAgent { this._updateRestrictedTelemetry(token); this._logService.info(`[Copilot] Auth token ${tokenChanged ? 'updated' : 'unchanged'}`); if (tokenChanged) { + await this._restartClientIfProxyChanged(); void this._refreshModels(); } return true; } - private _updateRestrictedTelemetry(token: string | undefined): void { - const rtEnabled = isRestrictedTelemetryEnabled(token); + async handleAuthenticationToken(params: AuthenticateParams): Promise<boolean> { + let handled = false; + for (const [, entry] of this._sessions) { + for (const session of entry.allChatSessions()) { + const didHandle = await session.resolveMcpAuthentication(params); + handled ||= didHandle; + } + } + return handled; + } + + private _updateRestrictedTelemetry(githubToken: string | undefined): void { + // Safe default synchronously: keep restricted/enhanced telemetry disabled until the minted + // CAPI Copilot session token confirms the `rt=1` opt-in. The GitHub token here carries no + // `rt`/`tid` claims — those live in the Copilot session token, which the API service mints — + // so the real values are resolved asynchronously below. Mirrors how the Copilot extension + // reads `rt`/`tid` off its `CopilotToken` rather than the GitHub token. + this._applyRestrictedTelemetry(false, undefined, undefined); + if (githubToken) { + void this._resolveRestrictedTelemetry(githubToken); + } + } + + private async _resolveRestrictedTelemetry(githubToken: string): Promise<void> { + try { + const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken); + if (this._githubToken !== githubToken) { + return; // token changed while resolving; a newer call owns the state + } + this._applyRestrictedTelemetry( + ctx.restrictedTelemetryEnabled, + ctx.trackingId, + ctx.telemetryEndpoint ? `${ctx.telemetryEndpoint}/telemetry` : undefined, + ); + } catch (err) { + this._logService.debug(`[Copilot] Restricted telemetry resolution failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + private _applyRestrictedTelemetry(rtEnabled: boolean, trackingId: string | undefined, telemetryEndpoint: string | undefined): void { if (rtEnabled !== this._restrictedTelemetryEnabled) { this._restrictedTelemetryEnabled = rtEnabled; - this._logService.info(`[Copilot] Restricted telemetry ${rtEnabled ? 'enabled' : 'disabled'}`); + this._logService.info(`[Copilot] Enhanced (restricted) telemetry ${rtEnabled ? 'enabled for this account' : 'disabled'}`); this._onDidChangeRestrictedTelemetry.fire(); } + // Push the token-derived telemetry policy/identity to the restricted sender: `rt` gates + // enhanced GH telemetry (kept off for public users), `tid` becomes `copilot_trackingId`, and + // the endpoint routes at the user's CAPI telemetry host (dotcom, GHE, or proxy). + if (isAgentHostTelemetryService(this._telemetryService)) { + this._telemetryService.setRestrictedTelemetryEnabled(rtEnabled); + this._telemetryService.setCopilotTrackingId(trackingId); + this._telemetryService.setRestrictedTelemetryEndpoint(telemetryEndpoint); + } } private async _refreshModels(attempt = 0): Promise<void> { @@ -590,13 +774,15 @@ export class CopilotAgent extends Disposable implements IAgent { const tokenAtRefreshStart = this._githubToken; if (!tokenAtRefreshStart) { - this._models.set([], undefined); + this._capiModels = []; + this._publishModels(); return; } try { const models = await this._listModels(tokenAtRefreshStart); if (this._githubToken === tokenAtRefreshStart) { - this._models.set(models, undefined); + this._capiModels = models; + this._publishModels(); } } catch (err) { // Token rotated mid-flight — a newer refresh owns the result — or @@ -613,14 +799,47 @@ export class CopilotAgent extends Disposable implements IAgent { }, delay); return; } - // Retries exhausted: surface the error. Only blank the list when we - // have nothing to show, so a transient failure never wipes a - // previously loaded, good model list. + // Retries exhausted: surface the error but keep the last-known CAPI + // list so a transient failure never wipes a previously loaded, good + // model list. Republish so a concurrently-updated BYOK list still + // shows through. this._logService.error(err, '[Copilot] Failed to refresh models'); - if (this._models.get().length === 0) { - this._models.set([], undefined); - } + this._publishModels(); + } + } + + /** + * Re-emit the merged CAPI + BYOK model list to the picker. A fresh array is + * allocated each call so the observable always notifies its consumers. + */ + private _publishModels(): void { + this._models.set([...this._capiModels, ...this._byokModels], undefined); + } + + /** + * (Re)publish the renderer BYOK models from the bridge registry's serving + * window. Triggered when any renderer bridge connects, disconnects, or + * reports a model change — the registry owns enumeration (with its own + * connect-time retry) and caches the serving window's models, so this is a + * cheap synchronous read of that cache. + * + * Each model is surfaced under the provider-qualified id `vendor/id` so a + * selection round-trips to the per-session provider config synthesized by + * `resolveByokSessionConfig`. + */ + private _refreshByokModels(): void { + if (this._shutdownPromise) { + return; } + this._byokModels = this._byokBridgeRegistry.getModels().map((m): IAgentModelInfo => ({ + provider: this.id, + id: `${m.vendor}/${m.id}`, + name: m.name ?? m.id, + maxContextWindow: m.maxContextWindowTokens, + supportsVision: m.supportsVision ?? false, + })); + this._logService.trace(`[Copilot] Found ${this._byokModels.length} BYOK models${this._byokModels.length ? ': ' + this._byokModels.map(m => m.name).join(', ') : ''}`); + this._publishModels(); } /** @@ -641,6 +860,10 @@ export class CopilotAgent extends Disposable implements IAgent { this._client = undefined; this._clientStarting = undefined; await client?.stop(); + // The runtime subprocess is now dead, so it is safe to release the BYOK + // proxy handle: the next session launch mints a fresh nonce. See the + // ownership invariant on `CopilotSessionLauncher.disposeByokProxyHandle`. + await this._sessionLauncher.disposeByokProxyHandle(); } /** @@ -695,6 +918,7 @@ export class CopilotAgent extends Disposable implements IAgent { // into the client options / subprocess env below). const sessionSyncAtStartup = this._isSessionSyncEnabled(); const rubberDuckAtStartup = this._isRubberDuckEnabled(); + const enterpriseHostAtStartup = this._getEnterpriseHost(); const clientStarting = (async () => { this._logService.info('[Copilot] Starting CopilotClient...'); @@ -709,6 +933,10 @@ export class CopilotAgent extends Disposable implements IAgent { if (key === 'ELECTRON_RUN_AS_NODE') { continue; } + if (key === 'VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE') { + // used for running the CLI in a test harness against a mock CAPI server + continue; + } if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { delete env[key]; } @@ -716,6 +944,7 @@ export class CopilotAgent extends Disposable implements IAgent { env['COPILOT_CLI_RUN_AS_NODE'] = '1'; env['USE_BUILTIN_RIPGREP'] = 'false'; env['COPILOT_MCP_APPS'] = 'true'; + await this._configureProxyEnv(env); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can @@ -738,6 +967,16 @@ export class CopilotAgent extends Disposable implements IAgent { env['GITHUB_COPILOT_INTEGRATION_ID'] = COPILOT_INTEGRATION_ID; this._logService.info(`[Copilot] Set CLI env: GITHUB_COPILOT_INTEGRATION_ID=${COPILOT_INTEGRATION_ID}`); + // Point the Copilot CLI at a configured GitHub Enterprise host for its + // authentication and CAPI endpoint discovery. `COPILOT_GH_HOST` is + // Copilot-CLI-specific (it does not affect the `gh` CLI). Unset for + // github.com so the CLI uses its default host. + const enterpriseHost = this._getEnterpriseHost(); + if (enterpriseHost) { + env['COPILOT_GH_HOST'] = enterpriseHost; + this._logService.info(`[Copilot] Set CLI env: COPILOT_GH_HOST=${enterpriseHost}`); + } + // Enable the rubber duck critic subagent in the CLI when the agent host // config opts in. `RUBBER_DUCK_AGENT` is the SDK's required interface for // gating this experimental feature @@ -747,15 +986,20 @@ export class CopilotAgent extends Disposable implements IAgent { delete env['RUBBER_DUCK_AGENT']; } - // Resolve the CLI entry point from node_modules. We can't use require.resolve() - // because @github/copilot's exports map blocks direct subpath access. - // FileAccess.asFileUri('') points to the `out/` directory; node_modules is one level up. - const nodeModulesUri = URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); + // Resolve the CLI entry point and native SDK binaries from node_modules. + // In a built app these live next to the ASAR archive in + // `node_modules.asar.unpacked` (the `@github/copilot-<platform>` CLI and + // the `@microsoft/mxc-sdk/bin` executables are unpacked so they can be + // spawned), while in dev they live in `node_modules`. + // We can't use require.resolve() because @github/copilot's exports map + // blocks direct subpath access. + const moduleRootPath = product.commit ? nodeModulesAsarUnpackedPath : nodeModulesPath; + const nodeModulesUri = FileAccess.asFileUri(moduleRootPath); const cliPath = await resolveCopilotCliPath(nodeModulesUri); // The SDK's sandbox auto-detection looks for `<MXC_BIN_DIR>/<arch>/wxc-exec.exe` // (and the Linux/macOS equivalents). VS Code core ships the MXC sandbox binaries - // at `node_modules/@microsoft/mxc-sdk/bin/<arch>/`, so point `MXC_BIN_DIR` there. + // at `<nodeModules>/@microsoft/mxc-sdk/bin/<arch>/`, so point `MXC_BIN_DIR` there. // The @github/copilot package's own `mxc-bin/` is excluded from the product build // (see build/.moduleignore), mirroring `CopilotCLISDK.getPackage` in the extension. env['MXC_BIN_DIR'] = URI.joinPath(nodeModulesUri, '@microsoft', 'mxc-sdk', 'bin').fsPath; @@ -782,7 +1026,7 @@ export class CopilotAgent extends Disposable implements IAgent { }; const client = this._createCopilotClient(clientOptions); await client.start(); - if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup) { + if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup || this._getEnterpriseHost() !== enterpriseHostAtStartup) { await client.stop(); throw new Error('Copilot startup config changed while the client was starting'); } @@ -820,7 +1064,7 @@ export class CopilotAgent extends Disposable implements IAgent { /** * Synthesize a `contextSize` config property when the model exposes a `long_context` pricing tier with a distinct * context-max. Picker surfaces this as the "Context Size" button. Mirrors `getContextSizeOptions` in - * `extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts`. + * `extensions/copilot/src/extension/chat/vscode-node/languageModelAccess.ts`. * * The `enum` values are the two context-window sizes (in tokens), smallest first, so the numeric token counts * flow to the client. The chosen value comes back in the model's `config` bag and is mapped to the SDK's @@ -838,9 +1082,8 @@ export class CopilotAgent extends Disposable implements IAgent { return undefined; } - // When both tiers cost the same, show only the long-context option as - // a non-switchable indicator — the user always gets the full window. - if (!hasLongContextSurcharge(billing as ICAPIModelBilling | undefined)) { + // When both tiers cost the same and the user prefers long context, show only the long-context option as a non-switchable indicator. See microsoft/vscode#322950, microsoft/vscode#323116. + if (this._isPreferLongContextEnabled() && !hasLongContextSurcharge(billing as ICAPIModelBilling | undefined)) { return { type: 'number', title: localize('copilot.modelContextSize.title', "Context Size"), @@ -972,23 +1215,16 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Resolves an {@link AgentSelection}'s SDK-facing name by looking it up in - * the active client snapshot's parsed plugin agents. Falls back to `undefined` - * when no matching plugin agent is found. + * Resolves an {@link AgentSelection}'s SDK-facing name from the plugin + * snapshot that is, or will be, applied to the SDK session. */ - private async _resolveAgentName(sessionUri: URI, snapshot: IActiveClientSnapshot, agent: AgentSelection): Promise<string | undefined> { + private _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined { for (const plugin of snapshot.plugins) { const found = plugin.agents.find(a => a.uri.toString() === agent.uri); if (found) { return found.name; } } - const customizations = await this.getSessionCustomizations(sessionUri); - const agents = getEffectiveAgents(customizations); - const found = agents.find(a => a.uri.toString() === agent.uri); - if (found) { - return found.name; - } return undefined; } @@ -1063,15 +1299,15 @@ export class CopilotAgent extends Disposable implements IAgent { const client = await this._ensureClient(); const { models } = await client.rpc.models.list({ gitHubToken }); this._freeLongContextModels.clear(); + const preferLongContext = this._isPreferLongContextEnabled(); const result = models.map((m): IAgentModelInfo => { const configSchema = this._createModelConfigSchema(m); - // A model has free long context when billing shows a larger long-context - // window but there is no surcharge for using it. + // A model has free long context (larger window, no surcharge), but only treat it as free when the user prefers long context. const tokenPrices = m.billing?.tokenPrices; const hasLargerLongContext = !!tokenPrices?.contextMax && !!tokenPrices.longContext?.contextMax && tokenPrices.longContext.contextMax > tokenPrices.contextMax; - if (hasLargerLongContext && !hasLongContextSurcharge(m.billing as ICAPIModelBilling | undefined)) { + if (preferLongContext && hasLargerLongContext && !hasLongContextSurcharge(m.billing as ICAPIModelBilling | undefined)) { this._freeLongContextModels.add(m.id); } return { @@ -1095,26 +1331,179 @@ export class CopilotAgent extends Disposable implements IAgent { /** * Resolves the working directory for a {@link createSession} call: the caller-supplied folder, else a - * still-provisional session's folder for an idempotent re-create, else a freshly created empty directory under the - * OS temp dir (used when the editor has no workspace open). + * still-provisional session's folder for an idempotent re-create, else — when the session is workspace-less + * (no `workingDirectory` supplied) — a stable per-session scratch directory. */ - private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string): Promise<URI> { + private async _resolveCreateWorkingDirectory(sessionConfig: IAgentCreateSessionConfig, sessionId: string, isWorkspaceless: boolean): Promise<URI> { const existing = sessionConfig.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; if (existing) { return existing; } + // A workspace-less session (inferred from an absent input + // `workingDirectory`) gets a STABLE, deterministic per-session scratch + // dir (mirroring the GitHub app's `<copilotHome>/chats/<id>`) rather than + // a throwaway `os.tmpdir()` dir, so the cwd survives reloads and isn't + // lost to OS temp reaping. + if (isWorkspaceless) { + const scratchDir = this._workspacelessScratchDir(sessionId); + await fs.mkdir(scratchDir.fsPath, { recursive: true }); + return scratchDir; + } const tmpPath = await fs.mkdtemp(join(os.tmpdir(), 'agent-host-session-')); const workingDirectory = URI.file(tmpPath); this._logService.trace(`[Copilot] No workingDirectory provided, defaulting to temp directory: ${workingDirectory.fsPath}`); return workingDirectory; } + /** + * Stable per-session scratch directory for a workspace-less chat: + * `<userHome>/.copilot/chats/<sessionId>`. Deterministic, persistent, and + * cleaned up on session delete (see {@link _cleanupWorkspacelessScratchDir}). + */ + private _workspacelessScratchDir(sessionId: string): URI { + return workspacelessScratchDir(this._environmentService.userHome, sessionId); + } + + /** Ensures a workspace-less chat's scratch dir exists (mkdir -p), recreating it if it was reaped. */ + private async _ensureWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise<void> { + try { + await fs.mkdir(scratchDir.fsPath, { recursive: true }); + this._logService.trace(`[Copilot:${sessionId}] Workspace-less scratch directory ready: ${scratchDir.fsPath}`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to ensure workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** Removes a workspace-less chat's stable scratch dir on session delete/dispose. */ + private async _cleanupWorkspacelessScratchDir(scratchDir: URI, sessionId: string): Promise<void> { + try { + await fs.rm(scratchDir.fsPath, { recursive: true, force: true }); + this._logService.trace(`[Copilot:${sessionId}] Removed workspace-less scratch directory: ${scratchDir.fsPath}`); + } catch (error) { + this._logService.warn(`[Copilot:${sessionId}] Failed to remove workspace-less scratch directory '${scratchDir.fsPath}': ${error instanceof Error ? error.message : String(error)}`); + } + } + + // ---- Chat surface ------------------------------------------------------ + // + // The chat-addressed operation surface (see + // {@link IAgent.chats}). The orchestrator owns the feature-level + // `(session, chat)` mapping and hands these methods a single, + // concrete chat channel URI: the default chat channel or an additional + // peer chat channel. Each method re-derives the `(session, chat)` pair + // the agent's internal SDK storage is keyed by via + // {@link _resolveChatTarget}. + + /** + * Maps a resolved chat URI to the `(session, chat)` pair the agent's + * internal storage is keyed by. A peer (`ahp-chat`) chat carries its + * owning session in its URI. The default chat is addressed by its + * deterministic chat channel URI. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Copilot chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: chat }; + } + + private _getChatContext(chatOrSession: URI): { session: URI; sessionId: string; chatKey: string; target: CopilotAgentSession | undefined; isPeerChat: boolean } { + // Accept either a chat channel URI or a bare session URI: per the AHP + // convention the default chat's URI equals the session URI, so callers + // that address the default chat by the session URI resolve here in one + // place rather than each operational method re-deriving it. + const chat = parseChatUri(chatOrSession) ? chatOrSession : URI.parse(buildDefaultChatUri(chatOrSession)); + const session = URI.parse(parseRequiredSessionUriFromChatUri(chat)); + const sessionId = AgentSession.id(session); + const chatKey = chat.toString(); + const resolved = this._sessions.get(sessionId)?.resolveChat(chatKey); + return { + session, + sessionId, + chatKey, + target: resolved?.chatSession, + isPeerChat: resolved ? !resolved.isDefault : chatKey !== buildDefaultChatUri(session), + }; + } + + /** + * Resolve the session's materialized default (main) chat by raw session id, + * or `undefined` when the session is provisional or not in memory. The + * default chat is the primary {@link CopilotAgentSession} of the owning + * {@link CopilotSessionEntry}. + */ + private _findAnySession(sessionId: string): CopilotAgentSession | undefined { + return this._sessions.get(sessionId)?.defaultChat; + } + + /** + * Resolve a live peer (non-default) chat — its own SDK chat — by + * looking it up within the owning session's entry. Returns `undefined` when + * the session (or the peer chat) is not in memory. + */ + private _findPeerChat(session: URI, chat: URI): CopilotAgentSession | undefined { + return this._sessions.get(AgentSession.id(session))?.getPeerChat(chat.toString()); + } + + /** + * Return the owning session's entry, creating an empty one (no default chat + * yet) if needed so a peer chat can be hosted on a still-provisional parent. + */ + private _ensureEntry(sessionId: string): CopilotSessionEntry { + let entry = this._sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + this._sessions.set(sessionId, entry); + } + return entry; + } + + /** + * Chat-addressed surface for the chats within a session. + */ + readonly chats: IAgentChats = { + createChat: (chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + return this._createChat(chat, options); + }, + fork: (chat: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + return this._createChat(chat, { ...options, fork: source }); + }, + disposeChat: (chatUri: URI): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this._disposeChat(session, chat); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise<void> => { + return this._sendMessage(chatUri, prompt, attachments, turnId, senderClientId); + }, + abort: (chatUri: URI): Promise<void> => { + return this._abortSession(chatUri); + }, + changeModel: (chatUri: URI, model: ModelSelection): Promise<void> => { + return this._changeModel(chatUri, model); + }, + changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise<void> => { + return this._changeAgent(chatUri, agent); + }, + getMessages: (chat: URI): Promise<readonly Turn[]> => { + return this.getSessionMessages(chat); + }, + }; + async createSession(config?: IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> { const sessionConfig = config ?? {}; this._logService.info(`[Copilot] Creating session... ${sessionConfig.model ? `model=${sessionConfig.model.id}` : ''}`); const sessionId = sessionConfig.session ? AgentSession.id(sessionConfig.session) : generateUuid(); - const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId); + // Workspace-less is inferred at create from an absent input + // `workingDirectory`: such a session is run in a stable scratch dir. The + // AH service persists the marker centrally (`agentHost.workspaceless`) and + // hands it back on restore; the agent only reads it (never persists it) to + // pick the workspace-less system prompt. Forks always inherit the source + // session's context, so they are never inferred workspace-less even when no + // `workingDirectory` is passed. + const isWorkspaceless = !sessionConfig.fork && !sessionConfig.workingDirectory; + const workingDirectory = await this._resolveCreateWorkingDirectory(sessionConfig, sessionId, isWorkspaceless); const client = await this._ensureClient(); // When forking, use the SDK's sessions.fork RPC. Forking from a source // session that has no turns is equivalent to creating a fresh session; @@ -1128,7 +1517,7 @@ export class CopilotAgent extends Disposable implements IAgent { return this._sessionSequencer.queue(sourceSessionId, async () => { this._logService.info(`[Copilot] Forking session ${sourceSessionId} at turnId=${sessionConfig.fork!.turnId}`); - const sourceEntry = this._sessions.get(sourceSessionId) ?? await this._resumeSession(sourceSessionId); + const sourceEntry = this._findAnySession(sourceSessionId) ?? await this._resumeSession(sourceSessionId); // Look up the SDK event ID for the turn *after* the fork point. // toEventId is exclusive — events before it are included. @@ -1151,6 +1540,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (sourceDbRef) { try { await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + // VACUUM INTO fails if the target already exists; clear + // any stale DB left by a previous (e.g. crashed) attempt. + await fs.rm(targetDbPath.fsPath, { force: true }); await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); } finally { sourceDbRef.dispose(); @@ -1170,6 +1562,16 @@ export class CopilotAgent extends Disposable implements IAgent { const session = agentSession.sessionUri; this._logService.info(`[Copilot] Forked session created: ${session.toString()}`); + + // Copy the source session's reviewed ref so the fork starts with + // the parent's review progress (best-effort; a failure just means + // the fork starts unreviewed). + try { + await this._reviewService.copyReviewedRef(sessionConfig.fork!.session.toString(), session.toString(), workingDirectory); + } catch (err) { + this._logService.warn(`[Copilot] Failed to copy reviewed ref for fork: ${err instanceof Error ? err.message : String(err)}`); + } + const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); await this._storeSessionMetadata(session, sessionConfig.model, workingDirectory, workingDirectory, project, true); if (sessionConfig.agent !== undefined) { @@ -1179,6 +1581,10 @@ export class CopilotAgent extends Disposable implements IAgent { }); } + if (sessionConfig.importConversation) { + return this._importConversation(sessionConfig, sessionId, workingDirectory); + } + // Non-fork path: create a *provisional* session. The Copilot SDK // session, the worktree (if any), and the on-disk metadata are all // deferred until the first {@link sendMessage} via @@ -1193,7 +1599,7 @@ export class CopilotAgent extends Disposable implements IAgent { // non-provisional result so the caller doesn't re-fire `SessionAdded`. // This guards against client retries that race a successful first // message. - if (this._sessions.has(sessionId)) { + if (this._findAnySession(sessionId)) { this._logService.info(`[Copilot] createSession is a no-op: session already materialized: ${sessionUri.toString()}`); const project = await projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; @@ -1239,6 +1645,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: sessionConfig.model, agent: sessionConfig.agent, project, + workspaceless: isWorkspaceless, }); } @@ -1246,6 +1653,62 @@ export class CopilotAgent extends Disposable implements IAgent { return { session: sessionUri, workingDirectory, provisional: true, ...(project ? { project } : {}) }; } + /** + * Root directory the Copilot CLI uses for per-session state. The CLI stores + * each session's files under `<root>/session-state/<sessionId>/` and resolves + * `<root>` to `$COPILOT_HOME` or `~/.copilot`. The CLI subprocess inherits + * `COPILOT_HOME` from this process's environment (see {@link _ensureClient}, + * which never overrides it), so reading it here matches what the CLI sees. + */ + private _copilotConfigRoot(): string { + return process.env['COPILOT_HOME'] || join(os.homedir(), '.copilot'); + } + + /** + * Materializes an imported conversation into a real, editable Copilot + * session. Translates the supplied turns into a Copilot event log, seeds it + * at the CLI's native per-session store, then resumes the session so the + * SDK reconstitutes the turns as genuine backend events (editable / forkable + * / truncatable). The turns arrive with fresh UUID ids assigned by the + * service layer, so the seeded event ids and the seeded protocol turns stay + * aligned. Mirrors the immediate-materialization shape of the fork path. + */ + private async _importConversation(sessionConfig: IAgentCreateSessionConfig, sessionId: string, workingDirectory: URI): Promise<IAgentCreateSessionResult> { + const importConfig = sessionConfig.importConversation!; + const sessionUri = AgentSession.uri(this.id, sessionId); + return this._sessionSequencer.queue(sessionId, async () => { + this._logService.info(`[Copilot] Importing conversation into session ${sessionId} (${importConfig.turns.length} turns)`); + const model = importConfig.model ?? sessionConfig.model; + + // Translate the conversation and seed it at the CLI's native + // per-session store so a normal resume reconstitutes editable turns. + // Detect the project concurrently with the (independent) event-log write + // so the git probe and file I/O overlap on the session-creation path. + const projectPromise = projectFromCopilotContext({ cwd: workingDirectory.fsPath }, this._gitService); + const eventsPath = join(this._copilotConfigRoot(), 'session-state', sessionId, 'events.jsonl'); + const jsonl = buildSessionEventLogFromTurns(importConfig.turns, { + sessionId, + workingDirectory: workingDirectory.fsPath, + model: model?.id, + }); + await fs.mkdir(dirname(eventsPath), { recursive: true }); + await fs.writeFile(eventsPath, jsonl, 'utf8'); + + // Persist metadata before resume so `_resumeSession` can resolve the + // working directory and model. + const project = await projectPromise; + await this._storeSessionMetadata(sessionUri, model, workingDirectory, workingDirectory, project); + if (sessionConfig.agent !== undefined) { + await this._storeSessionAgentMetadata(sessionUri, sessionConfig.agent); + } + + // Resume so the SDK loads the seeded history as editable turns. + await this._resumeSession(sessionId); + this._logService.info(`[Copilot] Imported session created: ${sessionUri.toString()}`); + return { session: sessionUri, workingDirectory, ...(project ? { project } : {}) }; + }); + } + /** * Promotes a {@link IProvisionalSession} into a real Copilot SDK session * by performing the work that {@link createSession} previously did @@ -1300,14 +1763,16 @@ export class CopilotAgent extends Disposable implements IAgent { const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); let agentSession: CopilotAgentSession | undefined; + let agent: AgentSelection | undefined; try { - const resolvedAgentName = provisional.agent ? await this._resolveAgentName(provisional.sessionUri, snapshot, provisional.agent) : undefined; + const resolvedAgent = await this._resolveAgentWhenMaterializing(provisional, snapshot, workingDirectory); + agent = resolvedAgent?.agent; const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client, sessionId, workingDirectory, - resolvedAgentName, + resolvedAgentName: resolvedAgent?.name, snapshot, activeClientToolSet: activeClient.toolSet, shellManager, @@ -1315,6 +1780,7 @@ export class CopilotAgent extends Disposable implements IAgent { model: provisional.model, longContextWindow: this._longContextWindowFor(provisional.model?.id), freeLongContext: this._isFreeLongContext(provisional.model?.id), + workspaceless: provisional.workspaceless, }; agentSession = this._createAgentSession(launchPlan, customizationDirectory, activeClient); await agentSession.initializeSession(); @@ -1329,8 +1795,8 @@ export class CopilotAgent extends Disposable implements IAgent { this._provisionalSessions.delete(sessionId); await this._storeSessionMetadata(sessionUri, provisional.model, workingDirectory, customizationDirectory, project, true); - if (provisional.agent !== undefined) { - await this._storeSessionAgentMetadata(sessionUri, provisional.agent); + if (agent !== undefined) { + await this._storeSessionAgentMetadata(sessionUri, agent); } // Capture the per-session baseline (turn/0) git checkpoint so @@ -1348,6 +1814,41 @@ export class CopilotAgent extends Disposable implements IAgent { return agentSession; } + private async _resolveAgentWhenMaterializing(provisional: IProvisionalSession, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined> { + const agent = provisional.agent; + if (!agent) { + return undefined; + } + const alternativeAgent = this._getAlternativeAgentForWorktree(provisional, workingDirectory); + + const originalAgentName = this._resolveAgentName(snapshot, agent); + const alternativeAgentName = alternativeAgent ? this._resolveAgentName(snapshot, alternativeAgent) : undefined; + + if (originalAgentName) { + return { agent: agent, name: originalAgentName }; + } + if (alternativeAgentName && alternativeAgent) { + this._logService.info(`[Copilot] Agent file ${agent.uri} is in the original repo; using worktree agent ${alternativeAgent?.uri}`); + return { agent: alternativeAgent, name: alternativeAgentName }; + } + return undefined; + } + private _getAlternativeAgentForWorktree(provisional: IProvisionalSession, workingDirectory: URI | undefined): AgentSelection | undefined { + const agent = provisional.agent; + if (!agent) { + return undefined; + } + if (!provisional.workingDirectory || !workingDirectory) { + return undefined; + } + if (isEqual(provisional.workingDirectory, workingDirectory)) { + return undefined; + } + const agentUri = URI.parse(agent.uri); + const alternativeAgentUri = rebaseUnder(agentUri, provisional.workingDirectory, workingDirectory); + return alternativeAgentUri ? { uri: alternativeAgentUri.toString() } : undefined; + } + async resolveSessionConfig(params: IAgentResolveSessionConfigParams): Promise<ResolveSessionConfigResult> { const gitInfo = params.workingDirectory ? await this._getGitInfo(params.workingDirectory) : undefined; @@ -1372,6 +1873,7 @@ export class CopilotAgent extends Disposable implements IAgent { let branchProperty: ISchemaProperty<string> | undefined; let branchDefault: string | undefined; + let worktreeBranchPrefixProperty: ISchemaProperty<string> | undefined; if (gitInfo) { const branchReadOnly = isolationValue === 'folder'; branchDefault = isolationValue === 'worktree' ? gitInfo.defaultBranch : gitInfo.currentBranch; @@ -1386,12 +1888,35 @@ export class CopilotAgent extends Disposable implements IAgent { readOnly: branchReadOnly, sessionMutable: false, }); + + // Carrier for the client's `git.branchPrefix`: the agent prepends it + // to the branch it creates for an isolated worktree. Declared for + // both isolations (like `branch`), so the value rides + // `_config.values` and survives isolation toggles — a user who flips + // worktree → folder → worktree keeps the prefix, and it reaches the + // agent via the send-time config snapshot. It has no + // `enum`/`enumDynamic`, so the config picker treats it as + // non-pickable. To keep it from surfacing as a read-only chip in the + // workbench chat input, its key is also listed in the client-side + // `WELL_KNOWN_PICKER_PROPERTIES` (see `agentHostChatInputPicker.ts`), + // which the generic chip lane filters out. The client seeds it + // (from `git.branchPrefix`), the user never edits it, and the agent + // only *consumes* it for worktree isolation (see + // `_resolveSessionWorkingDirectory`). + worktreeBranchPrefixProperty = schemaProperty<string>({ + type: 'string', + title: localize('agentHost.sessionConfig.worktreeBranchPrefix', "Worktree Branch Prefix"), + description: localize('agentHost.sessionConfig.worktreeBranchPrefixDescription', "Prefix applied to the branch created for an isolated worktree."), + readOnly: true, + sessionMutable: false, + }); } const sessionSchema = createSchema({ [SessionConfigKey.Isolation]: isolationProperty, ...platformSessionSchema.definition, ...(branchProperty ? { [SessionConfigKey.Branch]: branchProperty } : {}), + ...(worktreeBranchPrefixProperty ? { [SessionConfigKey.WorktreeBranchPrefix]: worktreeBranchPrefixProperty } : {}), }); const values = sessionSchema.validateOrDefault(migrateLegacyAutopilotConfig(params.config), { @@ -1402,6 +1927,9 @@ export class CopilotAgent extends Disposable implements IAgent { // falls through to the host-level `permissions` default, and only // materializes on the session once the user hits "Allow in this // Session". + // worktreeBranchPrefix intentionally omitted from defaults — the + // value originates on the client (`git.branchPrefix`); when the + // client doesn't supply one it simply stays unset. ...(branchDefault !== undefined ? { [SessionConfigKey.Branch]: branchDefault } : {}), }); @@ -1441,20 +1969,26 @@ export class CopilotAgent extends Disposable implements IAgent { } onClientToolCallComplete(session: URI, chat: URI, toolCallId: string, result: ToolCallResult): void { - // Peer (non-default) chats own their SDK conversation in `_chatSessions`, - // keyed by the chat URI. Mirrors the routing in `sendMessage`. + const sessionId = AgentSession.id(session); + // Peer (non-default) chats own their SDK chat within the owning + // session entry, keyed by the chat URI. Mirrors the routing in `sendMessage`. if (!isDefaultChatUri(chat)) { - this._chatSessions.get(chat.toString())?.handleClientToolCallComplete(toolCallId, result); - return; - } - // Default chat (and subagents): walk up the subagent chain to reach the - // root SDK session entry, since `_sessions` is keyed by root session ids. - let target = session; - let parsed; - while ((parsed = parseSubagentSessionUri(target))) { - target = parsed.parentSession; + const peerChat = this._findPeerChat(session, chat); + if (!peerChat) { + this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + return; + } + this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to peer chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + peerChat.handleClientToolCallComplete(toolCallId, result); + } else { + const entry = this._findAnySession(sessionId); + if (!entry) { + this._logService.warn(`[Copilot:${sessionId}] Dropping client tool completion for missing default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + return; + } + this._logService.info(`[Copilot:${sessionId}] Routing client tool completion to default chat: chat=${chat.toString()}, toolCallId=${toolCallId}, success=${result.success}`); + entry.handleClientToolCallComplete(toolCallId, result); } - this._sessions.get(AgentSession.id(target))?.handleClientToolCallComplete(toolCallId, result); } setCustomizationEnabled(uri: string, enabled: boolean): void { @@ -1466,56 +2000,58 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise<void> { + private async _sendMessage(chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise<void> { + const context = this._getChatContext(chat); // Additional (non-default) chats are backed by their own SDK - // conversation tracked in `_chatSessions`, keyed by the chat URI. - if (!isDefaultChatUri(chat)) { - const entry = await this._ensureChatSession(session, chat); + // chat hosted on the owning session entry, keyed by the chat URI. + if (context.isPeerChat) { + const entry = await this._ensureChatSession(context.session, chat); if (!entry) { throw new Error(`[Copilot] sendMessage for unknown chat: ${chat.toString()}`); } if (turnId) { - entry.resetTurnState(turnId); + entry.resetTurnState(turnId, senderClientId); } - await entry.send(prompt, attachments, turnId, this._resolveSdkMode(session)); + await entry.send(prompt, attachments, turnId, this._resolveSdkMode(context.session), senderClientId); return; } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - await this._activeClients.get(session)?.pluginController.retryFailedClientSyncIfNeeded(); + await this._sessionSequencer.queue(context.sessionId, async () => { + await this._activeClients.get(context.session)?.pluginController.retryFailedClientSyncIfNeeded(); // First message on a provisional session: materialize the SDK // session, worktree, and on-disk metadata before continuing. The // prompt is forwarded so a worktree-isolated session can derive // its branch-name hint from the user's first message. let entry: CopilotAgentSession | undefined; - if (this._provisionalSessions.has(sessionId)) { - entry = await this._materializeProvisional(sessionId, prompt); + if (this._provisionalSessions.has(context.sessionId)) { + entry = await this._materializeProvisional(context.sessionId, prompt); } else { - entry = this._sessions.get(sessionId); + entry = this._getChatContext(chat).target; } // If the active client's config changed (tools or plugins), // dispose this session so it gets resumed with the updated config. - const activeClient = this._activeClients.get(session); + const activeClient = this._activeClients.get(context.session); const hadCachedEntry = !!entry; - this._logService.info(`[Copilot:${sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); + this._logService.info(`[Copilot:${context.sessionId}] sendMessage: cachedEntry=${hadCachedEntry}, hasActiveClient=${!!activeClient}, activeClientId=${activeClient ? '(set)' : '(none)'}`); if (entry && activeClient && await activeClient.requiresRestart(entry.appliedSnapshot)) { - this._logService.info(`[Copilot:${sessionId}] Session config changed (requiresRestart=true), refreshing session. clients=[${[...activeClient.toolSet.clientIds()].join(', ') || '(none)'}]`); - this._sessions.deleteAndDispose(sessionId); + this._logService.info(`[Copilot:${context.sessionId}] Session config changed (requiresRestart=true), refreshing session. clients=[${[...activeClient.toolSet.clientIds()].join(', ') || '(none)'}]`); + // Dispose only the default chat so it resumes with the updated + // config; peer chats on the same entry are left intact. + this._sessions.get(context.sessionId)?.clearDefaultChat(); entry = undefined; } if (!entry) { - this._logService.info(`[Copilot:${sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); + this._logService.info(`[Copilot:${context.sessionId}] No cached entry${hadCachedEntry ? ' (was evicted by requiresRestart)' : ''}, calling _resumeSession`); } - entry ??= await this._resumeSession(sessionId); + entry ??= await this._resumeSession(context.sessionId); // Reset per-turn streaming state on the session so that the // next text/reasoning chunk (and any host-emitted announcement) // allocates a fresh response part. if (turnId) { - entry.resetTurnState(turnId); + entry.resetTurnState(turnId, senderClientId); } // Emit any pending first-turn announcement (e.g. worktree @@ -1523,19 +2059,19 @@ export class CopilotAgent extends Disposable implements IAgent { // delegating to the SDK. The SDK's subsequent deltas append to // the same markdown part because the session has already // allocated `_currentMarkdownPartId`. - const announcement = this._pendingFirstTurnAnnouncements.get(sessionId); + const announcement = this._pendingFirstTurnAnnouncements.get(context.sessionId); if (announcement !== undefined) { - this._pendingFirstTurnAnnouncements.delete(sessionId); + this._pendingFirstTurnAnnouncements.delete(context.sessionId); entry.emitInitialMarkdown(announcement); } try { - const sdkMode = this._resolveSdkMode(session); - await entry.send(prompt, attachments, turnId, sdkMode); + const sdkMode = this._resolveSdkMode(context.session); + await entry.send(prompt, attachments, turnId, sdkMode, senderClientId); } catch (err) { const errCode = (err as { code?: number })?.code; const errMsg = err instanceof Error ? err.message : String(err); - this._logService.error(`[Copilot:${sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); + this._logService.error(`[Copilot:${context.sessionId}] entry.send() failed: code=${errCode}, message=${errMsg}, hadCachedEntry=${hadCachedEntry}, errorType=${err?.constructor?.name}`); throw err; } }); @@ -1575,7 +2111,7 @@ export class CopilotAgent extends Disposable implements IAgent { setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, _queuedMessages: readonly PendingMessage[]): void { const sessionId = AgentSession.id(session); - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); if (!entry) { this._logService.warn(`[Copilot:${sessionId}] setPendingMessages: session not found`); return; @@ -1592,15 +2128,6 @@ export class CopilotAgent extends Disposable implements IAgent { } async getSessionMessages(session: URI): Promise<readonly Turn[]> { - // An additional (non-default) peer chat is addressed by its `ahp-chat` - // channel URI. Resume its backing SDK conversation and return its turns. - const chatInfo = parseChatUri(session); - if (chatInfo && !isDefaultChatUri(session)) { - const parentSession = URI.parse(chatInfo.session); - const entry = await this._ensureChatSession(parentSession, session); - return entry ? entry.getMessages() : []; - } - // If the URI describes a subagent child session (`<parent>/subagent/<toolCallId>`), // load the parent's events once and extract the child's filtered turns. const subagentInfo = parseSubagentSessionUri(session); @@ -1613,7 +2140,7 @@ export class CopilotAgent extends Disposable implements IAgent { rootSession = parentParsed.parentSession; } const rootSessionId = AgentSession.id(rootSession); - const parentEntry = this._sessions.get(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { + const parentEntry = this._findAnySession(rootSessionId) ?? await this._resumeSession(rootSessionId).catch(err => { this._logService.warn(`[Copilot:${rootSessionId}] Failed to resume root for subagent restore`, err); return undefined; }); @@ -1623,12 +2150,19 @@ export class CopilotAgent extends Disposable implements IAgent { return parentEntry.getSubagentMessages(subagentInfo.toolCallId); } - const sessionId = AgentSession.id(session); + const chat = parseChatUri(session) ? session : URI.parse(buildDefaultChatUri(session)); + const context = this._getChatContext(chat); + if (context.isPeerChat) { + const entry = await this._ensureChatSession(context.session, chat); + return entry ? entry.getMessages() : []; + } + + const sessionId = context.sessionId; // Provisional sessions have no SDK history yet. if (this._provisionalSessions.has(sessionId)) { return []; } - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + const entry = context.target ?? await this._resumeSession(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for message lookup`, err); return undefined; }); @@ -1644,7 +2178,7 @@ export class CopilotAgent extends Disposable implements IAgent { // (sendMessage) handles the very first turn when the session is fresh; // this path takes over on subsequent loads, where // _pendingFirstTurnAnnouncements is empty. - const worktreeMeta = await this._readWorktreeMetadata(session).catch(err => { + const worktreeMeta = await this._readWorktreeMetadata(context.session).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to read worktree branch metadata`, err); return undefined; }); @@ -1669,7 +2203,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._provisionalSessions.has(sessionId)) { return []; } - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(err => { + const entry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(err => { this._logService.warn(`[Copilot:${sessionId}] Failed to resume session for subagent lookup`, err); return undefined; }); @@ -1679,6 +2213,13 @@ export class CopilotAgent extends Disposable implements IAgent { async disposeSession(session: URI): Promise<void> { const sessionId = AgentSession.id(session); await this._sessionSequencer.queue(sessionId, async () => { + // Resolve the workspace-less scratch dir (if any) before deleting, so we + // can reap it afterwards. A provisional workspace-less chat carries its state + // in memory; a materialized/restored one persists `workspaceless` metadata. + const provisional = this._provisionalSessions.get(sessionId); + const isWorkspaceless = provisional + ? provisional.workspaceless === true + : (await this._readSessionMetadata(session).catch(() => undefined))?.workspaceless === true; // Remove the session from the SDK's on-disk store first so it doesn't reappear in `listSessions()` after a // restart, and so that any final persist triggered by in-memory teardown can't recreate it. Provisional // sessions were never persisted, so there is nothing to delete on the SDK side. @@ -1687,6 +2228,9 @@ export class CopilotAgent extends Disposable implements IAgent { await client.deleteSession(sessionId); } await this._destroyAndDisposeSession(sessionId); + if (isWorkspaceless) { + await this._cleanupWorkspacelessScratchDir(this._workspacelessScratchDir(sessionId), sessionId); + } }); } @@ -1774,43 +2318,51 @@ export class CopilotAgent extends Disposable implements IAgent { } } - async abortSession(session: URI, chat?: URI): Promise<void> { - if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.abort(); + private async _abortSession(chat: URI): Promise<void> { + const context = this._getChatContext(chat); + if (context.isPeerChat) { + await context.target?.abort(); return; } - const sessionId = AgentSession.id(session); - await this._sessionSequencer.queue(sessionId, async () => { - const entry = this._sessions.get(sessionId); - if (entry) { - await entry.abort(); - } + await this._sessionSequencer.queue(context.sessionId, async () => { + await this._getChatContext(chat).target?.abort(); }); } - async createChat(session: URI, chat: URI, options?: IAgentCreateChatOptions): Promise<void> { + private async _createChat(chat: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> { if (isDefaultChatUri(chat)) { return; } + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`[Copilot] createChat: malformed chat URI ${chat.toString()}`); + } + const session = URI.parse(parsed.session); const chatKey = chat.toString(); - if (this._chatSessions.has(chatKey)) { - return; + if (this._sessions.get(AgentSession.id(session))?.hasPeerChat(chatKey)) { + // Already live: hand back the existing backing so the orchestrator + // re-persists a consistent blob for an idempotent create. + const existing = this._chatBackings.get(chatKey); + return existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; } const sessionId = AgentSession.id(session); + let result: IAgentCreateChatResult | undefined; await this._sessionSequencer.queue(sessionId, async () => { // Re-check inside the per-session sequencer: the outer `has` check // above is only a fast early-out. If two `createChat` calls for the // same chat URI race, both can pass that outer check; the sequencer // serializes them, so the second task must re-check here to avoid - // overwriting (and disposing) the conversation the first one set. - if (this._chatSessions.has(chatKey)) { + // overwriting (and disposing) the chat the first one set. + if (this._sessions.get(sessionId)?.hasPeerChat(chatKey)) { + const existing = this._chatBackings.get(chatKey); + result = existing ? { providerData: encodeProviderData(existing), backingSession: AgentSession.uri(this.id, existing.sdkSessionId) } : undefined; return; } const model = options?.model; // Resolve the owning session so the new chat inherits its working // directory scope. The parent may be provisional (no SDK session // yet); in that case use its provisional working directory. - const parentEntry = this._sessions.get(sessionId); + const parentEntry = this._findAnySession(sessionId); const workingDirectory = parentEntry?.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; const client = await this._ensureClient(); @@ -1818,17 +2370,17 @@ export class CopilotAgent extends Disposable implements IAgent { // Peer chats share the owning session's ActiveClient so that // client tool / customization updates (which are keyed by the // session URI via the active-client handles) reach the additional - // chat's SDK conversation. Keying it by the chat URI instead would + // chat's SDK chat. Keying it by the chat URI instead would // snapshot empty/stale tools and never see subsequent updates, and // would also leak (nothing disposes a chat-keyed ActiveClient). const activeClient = this._getOrCreateActiveClient(session, workingDirectory); const snapshot = await activeClient.snapshot(); const shellManager = this._instantiationService.createInstance(ShellManager, chat, workingDirectory); - // Forking: mint the new chat's backing conversation by forking the + // Forking: mint the new chat's backing chat by forking the // source chat's SDK session at the requested turn (copying its // database into the new chat's data dir), then resume it. Otherwise - // spin up a fresh empty conversation. + // spin up a fresh empty chat. let launchPlan: CopilotSessionLaunchPlan; let sdkSessionId: string; if (options?.fork) { @@ -1839,7 +2391,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (!sourceEntry) { throw new Error(`[Copilot] createChat fork: source chat ${options.fork.source.toString()} not found`); } - sdkSessionId = await this._forkSdkConversation(client, sourceEntry, options.fork.turnId, this._sessionDataService.getSessionDataDir(chat)); + sdkSessionId = await this._forkSdkChat(client, sourceEntry, options.fork.turnId, this._sessionDataService.getSessionDataDir(chat)); launchPlan = { kind: 'resume', client, @@ -1876,19 +2428,20 @@ export class CopilotAgent extends Disposable implements IAgent { if (options?.fork?.turnIdMapping) { await agentSession.remapTurnIds(options.fork.turnIdMapping); } - this._chatSessions.set(chatKey, agentSession); - const parsed = parseChatUri(chat); - if (parsed) { - const persisted = await this._readPersistedChats(session); - persisted.set(parsed.chatId, { sdkSessionId, ...(model ? { model } : {}) }); - await this._writePersistedChats(session, persisted); - } + this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); + // Record the live backing and hand the opaque blob back to the + // orchestrator to persist. The agent no longer owns a durable + // peer-chat catalog (`copilot.chats` is no longer written). + const backing: IPersistedChat = { sdkSessionId, ...(model ? { model } : {}) }; + this._chatBackings.set(chatKey, backing); + result = { providerData: encodeProviderData(backing), backingSession: AgentSession.uri(this.id, sdkSessionId) }; this._logService.info(`[Copilot] Created additional chat ${chatKey} in session ${session.toString()}${options?.fork ? ' (forked)' : ''}`); } catch (error) { agentSession?.dispose(); throw error; } }); + return result; } /** @@ -1899,18 +2452,18 @@ export class CopilotAgent extends Disposable implements IAgent { private async _resolveChatEntry(session: URI, chatUri: URI): Promise<CopilotAgentSession | undefined> { const sessionId = AgentSession.id(session); if (isDefaultChatUri(chatUri) || isEqual(chatUri, session)) { - return this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + return this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); } return this._ensureChatSession(session, chatUri); } /** - * Forks {@link sourceEntry}'s SDK conversation at {@link turnId} via the + * Forks {@link sourceEntry}'s SDK chat at {@link turnId} via the * SDK `sessions.fork` RPC and copies its database into {@link targetDbDir} - * so the forked conversation inherits turn event IDs and file-edit + * so the forked chat inherits turn event IDs and file-edit * snapshots. Returns the new SDK session id. */ - private async _forkSdkConversation(client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI): Promise<string> { + private async _forkSdkChat(client: CopilotClient, sourceEntry: CopilotAgentSession, turnId: string, targetDbDir: URI): Promise<string> { // toEventId is exclusive — events before it are included. If there's no // next turn, omit it to include all events. const toEventId = await sourceEntry.getNextTurnEventId(turnId); @@ -1927,6 +2480,9 @@ export class CopilotAgent extends Disposable implements IAgent { if (sourceDbRef) { try { await fs.mkdir(targetDbDir.fsPath, { recursive: true }); + // VACUUM INTO fails if the target already exists; clear any + // stale DB left by a previous (e.g. crashed) attempt. + await fs.rm(targetDbPath.fsPath, { force: true }); await sourceDbRef.object.vacuumInto(targetDbPath.fsPath); } finally { sourceDbRef.dispose(); @@ -1938,26 +2494,29 @@ export class CopilotAgent extends Disposable implements IAgent { return newSessionId; } - async disposeChat(session: URI, chat: URI): Promise<void> { + private async _disposeChat(session: URI, chat: URI): Promise<void> { if (isDefaultChatUri(chat)) { return; } const chatKey = chat.toString(); - // Resolve the chat's backing SDK conversation id — from the in-memory - // session if present, otherwise from the persisted catalog — so we can - // delete it from the SDK's on-disk store. Without this a fresh process - // could re-resume an orphaned conversation that no longer has a catalog - // entry. Best-effort: a missing id still drops the catalog entry below. - const parsed = parseChatUri(chat); - let sdkSessionId = this._chatSessions.get(chatKey)?.sessionId; - if (parsed) { - const persisted = await this._readPersistedChats(session); - sdkSessionId ??= persisted.get(parsed.chatId)?.sdkSessionId; - if (persisted.delete(parsed.chatId)) { - await this._writePersistedChats(session, persisted); + // Resolve the chat's backing SDK chat id — from the in-memory + // session, the live backing map, or (for legacy sessions) a one-time + // read of the agent's pre-orchestrator catalog — so we can delete it + // from the SDK's on-disk store. Without this a fresh process could + // re-resume an orphaned chat. The durable peer-chat catalog is + // owned by the orchestrator now, so this no longer rewrites + // `copilot.chats`; it only drops the live backing and SDK chat. + let sdkSessionId = this._findPeerChat(session, chat)?.sessionId + ?? this._chatBackings.get(chatKey)?.sdkSessionId; + if (!sdkSessionId) { + const parsed = parseChatUri(chat); + if (parsed) { + const persisted = await this._readPersistedChats(session); + sdkSessionId = persisted.get(parsed.chatId)?.sdkSessionId; } } - this._chatSessions.deleteAndDispose(chatKey); + this._chatBackings.delete(chatKey); + this._sessions.get(AgentSession.id(session))?.disposePeerChat(chatKey); if (sdkSessionId) { try { const client = await this._ensureClient(); @@ -1969,29 +2528,93 @@ export class CopilotAgent extends Disposable implements IAgent { } /** - * Returns the catalog of additional (non-default) peer chats persisted for a - * session, as `ahp-chat` channel URIs. Used by the agent service to - * re-register peer chats (and seed their history) when a session is restored - * after a process restart. + * Re-attaches the in-memory backing for a peer chat on session restore, + * decoding the opaque `providerData` the orchestrator persisted at creation + * (or the latest {@link onDidChangeChatData}). After this resolves + * the chat's backing SDK chat can be resumed lazily via + * {@link _ensureChatSession}. When `providerData` is `undefined` (a legacy + * session persisted before the orchestrator owned the catalog) the agent + * falls back to a one-time read of its own `copilot.chats` blob. Best-effort + * — a corrupt/unknown blob is logged and dropped rather than thrown. + */ + async materializeChat(chat: URI, providerData: string | undefined): Promise<void> { + if (isDefaultChatUri(chat)) { + return; + } + const chatInfo = parseChatUri(chat); + if (!chatInfo) { + return; + } + const chatKey = chat.toString(); + let backing: IPersistedChat | undefined; + if (providerData !== undefined) { + backing = decodeProviderData(providerData); + if (!backing) { + this._logService.warn(`[Copilot] materializeChat: dropping corrupt providerData for ${chatKey}`); + return; + } + } else { + // Legacy fallback: consult the agent's own pre-orchestrator catalog + // once to recover the backing for sessions persisted before + // `providerData` existed. + const persisted = await this._readPersistedChats(URI.parse(chatInfo.session)); + backing = persisted.get(chatInfo.chatId); + if (!backing) { + return; + } + } + this._chatBackings.set(chatKey, backing); + } + + /** + * Migration-only enumeration of the session's peer chats from the agent's + * legacy `copilot.chats` catalog, mapping each entry to its channel URI and + * the same opaque `providerData` blob {@link materializeChat} + * decodes. The orchestrator calls this once to drain legacy chats into its + * own catalog. */ - async getChats(session: URI): Promise<readonly URI[]> { + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { const persisted = await this._readPersistedChats(session); - const result: URI[] = []; - for (const chatId of persisted.keys()) { - result.push(URI.parse(buildChatUri(session.toString(), chatId))); + const result: IAgentLegacyChat[] = []; + for (const [chatId, info] of persisted) { + result.push({ uri: URI.parse(buildChatUri(session, chatId)), providerData: encodeProviderData(info) }); } return result; } + /** + * Resolves the live backing for a peer chat from the in-memory + * {@link _chatBackings} map, falling back once to the agent's legacy + * `copilot.chats` catalog (seeding the live map) for sessions that have not + * been materialized via {@link materializeChat}. + */ + private async _resolveChatBacking(session: URI, chat: URI): Promise<IPersistedChat | undefined> { + const chatKey = chat.toString(); + const live = this._chatBackings.get(chatKey); + if (live) { + return live; + } + const parsed = parseChatUri(chat); + if (!parsed) { + return undefined; + } + const persisted = await this._readPersistedChats(session); + const info = persisted.get(parsed.chatId); + if (info) { + this._chatBackings.set(chatKey, info); + } + return info; + } + /** * Returns the SDK-backed {@link CopilotAgentSession} for an additional peer - * chat, resuming its persisted SDK conversation if it is not already in + * chat, resuming its backing SDK chat if it is not already in * memory (e.g. after a process restart). Returns `undefined` when the chat - * has no persisted backing conversation. + * has no known backing chat. */ private async _ensureChatSession(session: URI, chat: URI): Promise<CopilotAgentSession | undefined> { const chatKey = chat.toString(); - const existing = this._chatSessions.get(chatKey); + const existing = this._findPeerChat(session, chat); if (existing) { return existing; } @@ -2001,16 +2624,15 @@ export class CopilotAgent extends Disposable implements IAgent { } const sessionId = AgentSession.id(session); return this._sessionSequencer.queue(sessionId, async () => { - const again = this._chatSessions.get(chatKey); + const again = this._findPeerChat(session, chat); if (again) { return again; } - const persisted = await this._readPersistedChats(session); - const info = persisted.get(parsed.chatId); + const info = await this._resolveChatBacking(session, chat); if (!info) { return undefined; } - const parentEntry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); + const parentEntry = this._findAnySession(sessionId) ?? await this._resumeSession(sessionId).catch(() => undefined); const workingDirectory = parentEntry?.workingDirectory ?? this._provisionalSessions.get(sessionId)?.workingDirectory; if (!workingDirectory) { @@ -2037,7 +2659,7 @@ export class CopilotAgent extends Disposable implements IAgent { try { agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, chat); await agentSession.initializeSession(); - this._chatSessions.set(chatKey, agentSession); + this._ensureEntry(sessionId).registerPeerChat(chatKey, new CopilotSessionEntry(agentSession)); this._logService.info(`[Copilot] Resumed additional chat ${chatKey} in session ${session.toString()}`); return agentSession; } catch (error) { @@ -2048,16 +2670,25 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async truncateSession(session: URI, turnId?: string): Promise<void> { + async truncateSession(session: URI, turnId: string | undefined, chat: URI): Promise<void> { const sessionId = AgentSession.id(session); if (this._provisionalSessions.has(sessionId)) { return; } + const isPeerChat = !isDefaultChatUri(chat); await this._sessionSequencer.queue(sessionId, async () => { - this._logService.info(`[Copilot:${sessionId}] Truncating session${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); - - // Ensure the session is loaded so we can use the SDK RPC - const entry = this._sessions.get(sessionId) ?? await this._resumeSession(sessionId); + this._logService.info(`[Copilot:${sessionId}] Truncating ${isPeerChat ? `peer chat ${chat.toString()}` : 'session'}${turnId !== undefined ? ` at turnId=${turnId}` : ' (all turns)'}`); + + // Resolve the entry whose history is being truncated: a peer chat has + // its own backing SDK session, so route to it rather than the default + // chat. `_resolveChatEntry` resumes/materializes the chat if needed. + const entry = isPeerChat + ? await this._resolveChatEntry(session, chat) + : (this._findAnySession(sessionId) ?? await this._resumeSession(sessionId)); + if (!entry) { + this._logService.info(`[Copilot:${sessionId}] No chat entry resolved for truncation; nothing to truncate`); + return; + } // Look up the SDK event ID for the truncation boundary. // The protocol semantics: turnId is the last turn to KEEP. @@ -2081,59 +2712,59 @@ export class CopilotAgent extends Disposable implements IAgent { }); } - async changeModel(session: URI, model: ModelSelection, chat?: URI): Promise<void> { + private async _changeModel(chat: URI, model: ModelSelection): Promise<void> { const longContextWindow = this._longContextWindowFor(model.id); const freeLongContext = this._isFreeLongContext(model.id); - // Additional (non-default) chats are backed by their own SDK - // conversation tracked in `_chatSessions`; apply the change there and - // skip the session-level metadata store (peer chats are not persisted - // per-chat). - if (chat && !isDefaultChatUri(chat)) { - await this._chatSessions.get(chat.toString())?.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow, freeLongContext)); + const context = this._getChatContext(chat); + // Same override the launcher applies at create (validated + logged by + // resolveCopilotReasoningEffort); computed at the point of use so the + // provisional-session path doesn't resolve or log it prematurely. + if (context.isPeerChat) { + await context.target?.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); + const backing = this._chatBackings.get(context.chatKey); + if (backing) { + const updated: IPersistedChat = { sdkSessionId: backing.sdkSessionId, model }; + this._chatBackings.set(context.chatKey, updated); + this._onDidChangeChatData.fire({ chat: chat, providerData: encodeProviderData(updated) }); + } return; } - const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); + const provisional = this._provisionalSessions.get(context.sessionId); if (provisional) { provisional.model = model; return; } - const entry = this._sessions.get(sessionId); + const entry = context.target; if (entry) { - await entry.setModel(model.id, getCopilotReasoningEffort(model), getCopilotContextTier(model, longContextWindow, freeLongContext)); + await entry.setModel(model.id, resolveCopilotReasoningEffort(model, this._configurationService, this._logService, context.sessionId), getCopilotContextTier(model, longContextWindow, freeLongContext)); } - await this._storeSessionMetadata(session, model, undefined, undefined, undefined); + await this._storeSessionMetadata(context.session, model, undefined, undefined, undefined); } - async changeAgent(session: URI, agent: AgentSelection | undefined, chat?: URI): Promise<void> { - // Additional (non-default) chats own their SDK conversation in - // `_chatSessions`. Apply the agent to that conversation (resolving the - // URI → SDK name against its own applied snapshot) and skip the - // session-level metadata store. - if (chat && !isDefaultChatUri(chat)) { - const chatEntry = this._chatSessions.get(chat.toString()); - if (chatEntry) { - const resolvedAgentName = agent ? await this._resolveAgentName(session, chatEntry.appliedSnapshot, agent) : undefined; - await chatEntry.setAgent(resolvedAgentName); + private async _changeAgent(chat: URI, agent: AgentSelection | undefined): Promise<void> { + const context = this._getChatContext(chat); + if (context.isPeerChat) { + if (context.target) { + const resolvedAgentName = agent ? this._resolveAgentName(context.target.appliedSnapshot, agent) : undefined; + await context.target.setAgent(resolvedAgentName); } return; } - const sessionId = AgentSession.id(session); - const provisional = this._provisionalSessions.get(sessionId); + const provisional = this._provisionalSessions.get(context.sessionId); if (provisional) { provisional.agent = agent; return; } - const entry = this._sessions.get(sessionId); + const entry = context.target; if (entry) { // Resolve the URI → SDK name from the session's currently-applied // plugin snapshot. If the agent is no longer present (plugin // removed, never loaded), pass `undefined` so the SDK clears its // selection rather than silently keeping the previous one. - const resolvedAgentName = agent ? await this._resolveAgentName(session, entry.appliedSnapshot, agent) : undefined; + const resolvedAgentName = agent ? this._resolveAgentName(entry.appliedSnapshot, agent) : undefined; await entry.setAgent(resolvedAgentName); } - await this._storeSessionAgentMetadata(session, agent); + await this._storeSessionAgentMetadata(context.session, agent); } async shutdown(): Promise<void> { @@ -2148,32 +2779,29 @@ export class CopilotAgent extends Disposable implements IAgent { } await this._client?.stop(); this._client = undefined; + // Release the BYOK proxy handle only after the runtime subprocess is + // gone, mirroring `_stopClient` and the proxy ownership invariant. + await this._sessionLauncher.disposeByokProxyHandle(); })(); return this._shutdownPromise; } respondToPermissionRequest(requestId: string, approved: boolean): void { - for (const [, session] of this._sessions) { - if (session.respondToPermissionRequest(requestId, approved)) { - return; - } - } - for (const [, chat] of this._chatSessions) { - if (chat.respondToPermissionRequest(requestId, approved)) { - return; + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.respondToPermissionRequest(requestId, approved)) { + return; + } } } } respondToUserInputRequest(requestId: string, response: ChatInputResponseKind, answers?: Record<string, ChatInputAnswer>): void { - for (const [, session] of this._sessions) { - if (session.respondToUserInputRequest(requestId, response, answers)) { - return; - } - } - for (const [, chat] of this._chatSessions) { - if (chat.respondToUserInputRequest(requestId, response, answers)) { - return; + for (const entry of this._sessions.values()) { + for (const chat of entry.allChatSessions()) { + if (chat.respondToUserInputRequest(requestId, response, answers)) { + return; + } } } } @@ -2189,16 +2817,93 @@ export class CopilotAgent extends Disposable implements IAgent { // ---- helpers ------------------------------------------------------------ + private async _configureProxyEnv(env: Record<string, string | undefined>): Promise<void> { + const proxy = await this._resolveProxyForSdk(env); + this._appliedProxy = proxy; + if (proxy) { + for (const key of COPILOT_PROXY_SET_ENV_KEYS) { + env[key] = proxy; + } + this._logService.info('[Copilot] Resolved CAPI proxy and forwarded HTTP_PROXY/HTTPS_PROXY to Copilot SDK'); + } + } + + private async _resolveProxyForSdk(env: Record<string, string | undefined> = process.env): Promise<string | undefined> { + if (COPILOT_PROXY_ENV_KEYS.some(key => env[key])) { + this._logService.debug('[Copilot] Proxy env var already set; leaving Copilot SDK proxy configuration to the environment'); + return undefined; + } + + let capiUrl = env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL; + if (this._githubToken) { + try { + const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubToken); + if (discovered) { + capiUrl = discovered; + } + } catch (error) { + this._logService.debug(`[Copilot] CAPI endpoint discovery for proxy resolution failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + try { + return await this._proxyResolver.resolveProxy(capiUrl); + } catch (error) { + this._logService.warn(`[Copilot] Failed to resolve CAPI proxy for ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + /** + * When the GitHub token changes, the token-discovered CAPI endpoint (and so + * the resolved proxy) can change. The proxy is baked into the SDK subprocess + * env at client start, so if it would now differ we stop the running client + * here; the next `_ensureClient` re-resolves it against the new token. No-op + * when no client is running/starting or the proxy is unchanged. + */ + private async _restartClientIfProxyChanged(): Promise<void> { + if (!this._client && !this._clientStarting) { + return; + } + const oldProxy = this._appliedProxy; + const newProxy = await this._resolveProxyForSdk(); + if (newProxy === oldProxy) { + return; + } + // Let any in-flight start finish so we stop a live client rather than + // racing it (the start would otherwise come up with the stale proxy). + if (this._clientStarting) { + try { + await this._clientStarting; + } catch { + // Start failed; nothing running to restart. + } + } + if (!this._client) { + return; + } + this._logService.info(`[Copilot] CAPI proxy changed after token update (${oldProxy ?? '(none)'} -> ${newProxy ?? '(none)'}); restarting CopilotClient`); + this._sessions.clearAndDisposeAll(); + this._mcpNotificationSubs.clearAndDisposeAll(); + await this._stopClient(); + } + /** - * Disposes every peer chat (tracked in {@link _chatSessions}) whose - * owning session matches `sessionId`. The chat URI encodes its parent - * session, so we recover it via {@link parseChatUri}. + * Disposes every peer chat hosted on the owning session's entry and drops + * their live backings from {@link _chatBackings}. The chat URI encodes its + * parent session, so we recover it via {@link parseChatUri}. */ private _disposeChildChats(sessionId: string): void { - for (const chatKey of [...this._chatSessions.keys()]) { + const entry = this._sessions.get(sessionId); + if (entry) { + for (const chatKey of entry.peerChatKeys()) { + entry.disposePeerChat(chatKey); + } + } + for (const chatKey of [...this._chatBackings.keys()]) { const parsed = parseChatUri(URI.parse(chatKey)); if (parsed && AgentSession.id(parsed.session) === sessionId) { - this._chatSessions.deleteAndDispose(chatKey); + this._chatBackings.delete(chatKey); } } } @@ -2246,7 +2951,10 @@ export class CopilotAgent extends Disposable implements IAgent { }, ); - this._mcpNotificationSubs.set(launchPlan.sessionId, agentSession.onMcpNotification(n => this._onMcpNotification.fire(n))); + this._mcpNotificationSubs.set(launchPlan.sessionId, combinedDisposable( + agentSession.onMcpNotification(n => this._onMcpNotification.fire(n)), + autorun(r => activeClient.pluginController.mcpServerStates.set(agentSession.mcpServerStates.read(r), undefined)), + )); return agentSession; } @@ -2267,12 +2975,22 @@ export class CopilotAgent extends Disposable implements IAgent { agentSession.dispose(); throw new CancellationError(); } - this._sessions.set(sessionId, agentSession); + // Reuse an existing entry (which may already host peer chats created + // while the default chat was still provisional) rather than replacing + // it, which would dispose those peers. The default chat is seeded into + // the entry's uniform chat map keyed by its default-chat URI. + const defaultChatKey = buildDefaultChatUri(agentSession.sessionUri.toString()); + let entry = this._sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + this._sessions.set(sessionId, entry); + } + entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(agentSession)); } private async _destroyAndDisposeSession(sessionId: string): Promise<void> { // Tear down any peer chats owned by this session first so their SDK - // conversations don't leak when the parent is deleted/disposed + // chats don't leak when the parent is deleted/disposed // without each chat being individually disposed via `disposeChat`. this._disposeChildChats(sessionId); // Provisional sessions have no SDK session, no worktree, and no @@ -2282,11 +3000,14 @@ export class CopilotAgent extends Disposable implements IAgent { const provisional = this._provisionalSessions.get(sessionId); if (provisional) { this._provisionalSessions.delete(sessionId); + // Drop any peer-host entry created for this still-provisional + // session (its peers were disposed by `_disposeChildChats` above). + this._sessions.deleteAndDispose(sessionId); this._activeClients.get(provisional.sessionUri)?.dispose(); this._activeClients.delete(provisional.sessionUri); return; } - const entry = this._sessions.get(sessionId); + const entry = this._findAnySession(sessionId); const sessionUri = AgentSession.uri(this.id, sessionId); if (entry) { try { @@ -2332,6 +3053,12 @@ export class CopilotAgent extends Disposable implements IAgent { if (!workingDirectory) { throw new Error(`workingDirectory is required to resume Copilot session '${sessionId}'`); } + // A workspace-less chat's working directory is a stable per-session scratch dir + // that may have been reaped (OS temp cleanup, reboot) while the session + // persisted. Recreate it (mkdir -p) so shell/git/scratch ops don't fail. + if (storedMetadata.workspaceless) { + await this._ensureWorkspacelessScratchDir(workingDirectory, sessionId); + } // Anchor customization discovery to the working directory (the worktree for // worktree-isolated sessions), matching how the session was materialized. // Older sessions persisted `customizationDirectory` as the user-picked @@ -2345,7 +3072,10 @@ export class CopilotAgent extends Disposable implements IAgent { const snapshot = await activeClient.snapshot(); const shellManager = this._instantiationService.createInstance(ShellManager, sessionUri, workingDirectory); - const resolvedAgentName = storedMetadata.agent ? await this._resolveAgentName(sessionUri, snapshot, storedMetadata.agent) : undefined; + const resolvedAgentName = storedMetadata.agent ? this._resolveAgentName(snapshot, storedMetadata.agent) : undefined; + if (storedMetadata.agent && !resolvedAgentName) { + this._logService.info(`[Copilot:${sessionId}] Stored custom agent is not available in the current plugin snapshot; resuming without a custom agent`); + } const launchPlan: CopilotSessionLaunchPlan = { kind: 'resume', client, @@ -2356,6 +3086,7 @@ export class CopilotAgent extends Disposable implements IAgent { activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, + workspaceless: storedMetadata.workspaceless, fallback: { model: storedMetadata.model, longContextWindow: this._longContextWindowFor(storedMetadata.model?.id), @@ -2407,16 +3138,23 @@ export class CopilotAgent extends Disposable implements IAgent { } const worktreesRoot = getCopilotWorktreesRoot(repositoryRoot); + // Prefix (e.g. the user's `git.branchPrefix`) the client forwards for + // worktree-isolated sessions. Prepended ahead of the built-in `agents/` + // prefix when naming the branch and stripped from the worktree dir name. + const worktreeBranchPrefix = typeof config.config[SessionConfigKey.WorktreeBranchPrefix] === 'string' + ? config.config[SessionConfigKey.WorktreeBranchPrefix] as string + : undefined; const branchName = await this._branchNameGenerator.generateBranchName({ sessionId, message: prompt, githubToken: this._githubToken, + branchPrefix: worktreeBranchPrefix, // Treat a failed existence check as a collision so we fall back to a // suffixed branch name rather than risk `addWorktree` failing because // the branch already exists. branchExists: branchName => this._gitService.branchExists(repositoryRoot, branchName).catch(() => true), }); - const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeName(branchName)); + const worktree = URI.joinPath(worktreesRoot, getCopilotWorktreeDirectoryName(branchName, worktreeBranchPrefix)); await fs.mkdir(worktreesRoot.fsPath, { recursive: true }); const baseBranch = typeof config.config[SessionConfigKey.Branch] === 'string' ? config.config[SessionConfigKey.Branch] as string : undefined; // `addWorktree`'s signature requires a startPoint, but historically the @@ -2466,10 +3204,13 @@ export class CopilotAgent extends Disposable implements IAgent { private static readonly _META_CHATS = 'copilot.chats'; /** - * Reads the persisted peer-chat catalog for a session. Each entry maps a - * chatId (the `ahp-chat` authority) to the SDK conversation that backs it - * (and its optional model override), so the chat can be resumed after a - * restart even though {@link _chatSessions} is empty in a fresh process. + * Reads the agent's legacy peer-chat catalog (`copilot.chats`) for a + * session. Each entry maps a chatId (the `ahp-chat` authority) to the SDK + * chat that backs it (and its optional model override). The agent + * no longer *writes* this catalog — the orchestrator owns the durable + * peer-chat catalog via `providerData` — but the read is retained for one + * release to drain sessions persisted before that migration (see + * {@link getChats} and {@link materializeChat}). */ private async _readPersistedChats(session: URI): Promise<Map<string, IPersistedChat>> { const ref = await this._sessionDataService.tryOpenDatabase(session); @@ -2505,23 +3246,6 @@ export class CopilotAgent extends Disposable implements IAgent { } } - /** Writes the persisted peer-chat catalog for a session. */ - private async _writePersistedChats(session: URI, chats: Map<string, IPersistedChat>): Promise<void> { - const dbRef = this._sessionDataService.openDatabase(session); - try { - // Use a null-prototype object: chatIds derive from a client-chosen - // chat URI authority, so a value like `__proto__` would otherwise - // pollute the prototype / corrupt the serialized payload. - const obj: Record<string, IPersistedChat> = Object.create(null); - for (const [chatId, info] of chats) { - obj[chatId] = info; - } - await dbRef.object.setMetadata(CopilotAgent._META_CHATS, JSON.stringify(obj)); - } finally { - dbRef.dispose(); - } - } - private async _writeWorktreeMetadata(session: URI, metadata: { branchName: string; baseBranch: string | undefined; worktreePath: URI; repositoryRoot: URI }): Promise<void> { const dbRef = this._sessionDataService.openDatabase(session); @@ -2589,36 +3313,38 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI }> { + private async _readSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; workspaceless?: boolean }> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return {}; } try { - const [model, agent, cwd, customizationDirectory] = await Promise.all([ + const [model, agent, cwd, customizationDirectory, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), ref.object.getMetadata(CopilotAgent._META_CUSTOMIZATION_DIRECTORY), + ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), ]); return { model: this._parseModelSelection(model), agent: this._parseAgentSelection(agent), workingDirectory: cwd ? URI.parse(cwd) : undefined, customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, + workspaceless: workspaceless === 'true', }; } finally { ref.dispose(); } } - private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean } | undefined> { + private async _readStoredSessionMetadata(session: URI): Promise<{ model?: ModelSelection; agent?: AgentSelection; workingDirectory?: URI; customizationDirectory?: URI; project?: IAgentSessionProjectInfo; resolved: boolean; workspaceless?: boolean } | undefined> { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { return undefined; } try { - const [model, agent, cwd, customizationDirectory, resolved, uri, displayName] = await Promise.all([ + const [model, agent, cwd, customizationDirectory, resolved, uri, displayName, workspaceless] = await Promise.all([ ref.object.getMetadata(CopilotAgent._META_MODEL), ref.object.getMetadata(CopilotAgent._META_AGENT), ref.object.getMetadata(CopilotAgent._META_CWD), @@ -2626,6 +3352,7 @@ export class CopilotAgent extends Disposable implements IAgent { ref.object.getMetadata(CopilotAgent._META_PROJECT_RESOLVED), ref.object.getMetadata(CopilotAgent._META_PROJECT_URI), ref.object.getMetadata(CopilotAgent._META_PROJECT_DISPLAY_NAME), + ref.object.getMetadata(AH_META_WORKSPACELESS_DB_KEY), ]); const workingDirectory = cwd ? URI.parse(cwd) : undefined; const project = uri && displayName ? { uri: URI.parse(uri), displayName } : undefined; @@ -2636,6 +3363,7 @@ export class CopilotAgent extends Disposable implements IAgent { customizationDirectory: customizationDirectory ? URI.parse(customizationDirectory) : undefined, project, resolved: resolved === 'true' || project !== undefined, + workspaceless: workspaceless === 'true', }; } finally { ref.dispose(); @@ -3202,6 +3930,16 @@ class SessionPluginController extends Disposable { readonly onDidPublish = this._onDidPublish.event; private readonly _enablement = new Map<string, boolean>(); + /** + * Live runtime state (`state`/`channel`) per MCP server customization id, + * kept up to date by the owning session from its MCP controller. Overlaid + * onto published customizations by {@link _overlayMcpState} so a re-sync + * preserves the live state of otherwise-unchanged MCP servers instead of + * resetting them to the `Starting` default baked into + * `makeMcpServerCustomization`. Exposed (not injected) so the session can + * write to it once it holds this controller. + */ + public readonly mcpServerStates: ISettableObservable<ReadonlyMap<string, IMcpServerRuntimeState>> = observableValue(this, new Map()); /** * Per-client customization state, keyed by `clientId`. Each active client * contributing customizations to this session has one entry; the published @@ -3266,13 +4004,13 @@ class SessionPluginController extends Disposable { public getCustomizations(): readonly Customization[] { const result: Customization[] = [ - ...this._parent.hostCustomizations().map(item => this._applyEnablement(item.customization)), - ...this._flattenClientCustomizations().map(item => this._applyEnablement(item.customization)), + ...this._parent.hostCustomizations().map(item => this._projectForPublish(item.customization)), + ...this._flattenClientCustomizations().map(item => this._projectForPublish(item.customization)), ]; const entry = this._discoveredEntry(); const discovered = entry?.currentCustomizations() ?? []; for (const customization of discovered) { - result.push(this._applyEnablement(customization)); + result.push(this._projectForPublish(customization)); } return result; } @@ -3372,6 +4110,17 @@ class SessionPluginController extends Disposable { if (!client) { client = { revision: 0, customizations: [], sync: Promise.resolve([]), inputs: [] }; this._clients.set(clientId, client); + } else if (equals(client.inputs, customizations)) { + // No-op re-sync: a window re-subscribing (e.g. navigating away from + // and back to a session) re-publishes the same customizations. Skip + // the revision bump, the `SessionCustomizationsChanged` emit, and the + // redundant plugin-manager re-sync (which otherwise re-parses plugins + // from disk on every navigation). Genuine changes still publish, and + // `_projectForPublish` keeps live MCP state intact across those. + return client.sync.then(results => results.map(item => ({ + customization: this._projectForPublish(item.customization), + ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), + }))); } const revision = ++client.revision; client.inputs = customizations; @@ -3391,11 +4140,11 @@ class SessionPluginController extends Disposable { } const published = new Map<string, Customization>(); for (const customization of client.customizations) { - const enabled = this._applyEnablement(customization.customization); + const enabled = this._projectForPublish(customization.customization); published.set(enabled.uri, enabled); } const publishUpdate = (item: IResolvedCustomization) => { - const customization = this._applyEnablement(item.customization); + const customization = this._projectForPublish(item.customization); if (equals(published.get(customization.uri), customization)) { return; } @@ -3434,7 +4183,7 @@ class SessionPluginController extends Disposable { }); return promise.then(results => results.map(item => ({ - customization: this._applyEnablement(item.customization), + customization: this._overlayMcpState(this._applyEnablement(item.customization)), ...(item.pluginDir ? { pluginDir: item.pluginDir } : {}), }))); } @@ -3511,13 +4260,60 @@ class SessionPluginController extends Disposable { } private _isEnabled(customization: Customization): boolean { - return this._enablement.get(customization.uri) ?? customization.enabled; + return this._enablement.get(customization.uri) ?? customization.enabled !== false; } private _applyEnablement<T extends Customization>(customization: T): T { const enabled = this._isEnabled(customization); return customization.enabled === enabled ? customization : { ...customization, enabled }; } + + /** + * Projects a raw customization into its published form: applies the + * user's per-session enablement override, then overlays the latest + * known MCP runtime `state`/`channel` (see {@link mcpServerStates}). + * Every publish path runs customizations through this so enablement and + * live MCP state stay consistent. Object identity is preserved when + * neither step changes anything, keeping downstream equality checks + * stable. + */ + private _projectForPublish<T extends Customization>(customization: T): T { + return this._overlayMcpState(this._applyEnablement(customization)); + } + + /** + * Overlays the latest known MCP runtime `state`/`channel` (see + * {@link mcpServerStates}) onto a customization and its children, + * preserving object identity when nothing is overlaid so downstream + * equality checks stay stable. + */ + private _overlayMcpState<T extends Customization>(customization: T): T { + const overlays = this.mcpServerStates.get(); + if (overlays.size === 0) { + return customization; + } + if (customization.type === CustomizationType.McpServer) { + const overlay = overlays.get(customization.id); + return overlay ? { ...customization, state: overlay.state, channel: overlay.channel } : customization; + } + const children = customization.children; + if (!children || children.length === 0) { + return customization; + } + let changed = false; + const overlaidChildren = children.map(child => { + if (child.type !== CustomizationType.McpServer) { + return child; + } + const overlay = overlays.get(child.id); + if (!overlay) { + return child; + } + changed = true; + return { ...child, state: overlay.state, channel: overlay.channel }; + }); + return changed ? { ...customization, children: overlaidChildren } : customization; + } } /** diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 2bd5a276591c0b..9598053800eabf 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -11,6 +11,7 @@ import { CancellationError, getErrorMessage } from '../../../../base/common/erro import { escapeMarkdownSyntaxTokens } from '../../../../base/common/htmlContent.js'; import { Disposable, IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; +import { isAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { safeStringify } from '../../../../base/common/objects.js'; import { isAbsolute, join } from '../../../../base/common/path.js'; import { extUriBiasedIgnorePathCase, normalizePath } from '../../../../base/common/resources.js'; @@ -22,17 +23,16 @@ import { localize } from '../../../../nls.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; -import { ILogService } from '../../../log/common/log.js'; +import { ILogService, LogLevel } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReviewAction } from '../../common/agentHostPlanReview.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { AgentHostGlobalAutoApproveEnabledConfigKey, AgentHostAutoReplyEnabledConfigKey, platformRootSchema, platformSessionSchema } from '../../common/agentHostSchema.js'; -import { AgentSignal, IMcpNotification, IRestoredSubagentSession } from '../../common/agentService.js'; +import { AgentSignal, AuthenticateParams, IMcpNotification, IRestoredSubagentSession, subagentChatTitle } from '../../common/agentService.js'; import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; -import { toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; +import { readToolCallMeta, toToolCallMeta, type IToolCallMeta, type IToolCallUiMeta } from '../../common/meta/agentToolCallMeta.js'; import { OtelData, type OtelAttributeValue } from '../../common/otlp/otlpLogEmitter.js'; -import type { LanguageModelToolInvokedClassification, LanguageModelToolInvokedEvent } from '../../../telemetry/common/languageModelToolTelemetry.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { isAgentFeedbackAnnotationsAttachment, renderAgentFeedbackAnnotationsAttachment } from '../../common/meta/agentFeedbackAttachments.js'; import { ISessionDatabase, ISessionDataService, SESSION_ATTACHMENTS_DIRNAME } from '../../common/sessionDataService.js'; @@ -44,19 +44,21 @@ import type { IExitPlanModeResponse } from './copilotAgent.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; import { clientToolNamesFromSnapshot, type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from './copilotSessionLauncher.js'; import { ActiveClientToolSet } from '../activeClientState.js'; +import { AgentHostTelemetryReporter } from '../agentHostTelemetryReporter.js'; import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; import { parseLeadingSlashCommand } from './copilotSlashCommandCompletionProvider.js'; import type { IUnsandboxedCommandConfirmationRequest, ShellManager } from './copilotShellTools.js'; import { buildSandboxConfigForSdk, type ISdkSandboxConfig } from './sandboxConfigForSdk.js'; import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; -import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; +import { getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isAgentCoordinationTool, isEditTool, isHiddenTool, isShellTool, isTaskCompleteTool, synthesizeSkillToolCall, tryStringify, type ITypedPermissionRequest } from './copilotToolDisplay.js'; import { FileEditTracker } from '../shared/fileEditTracker.js'; import { stripProxyErrorMarker, tryBuildChatErrorMeta, tryBuildChatErrorMetaFromFields } from '../shared/forwardedChatError.js'; import { McpCustomizationController, type ISdkMcpServer } from '../shared/mcpCustomizationController.js'; -import { mapSessionEvents } from './mapSessionEvents.js'; +import { appendSdkToolResultContent, mapSessionEvents } from './mapSessionEvents.js'; import { buildPendingEditContentUri } from './pendingEditContentStore.js'; -import { McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +import { McpAuthRequiredReason, McpServerStatus, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; +import type { ProtectedResourceMetadata } from '../../common/state/protocol/common/state.js'; /** * The full set of agent modes the Copilot SDK accepts. AHP now exposes the @@ -68,11 +70,21 @@ export type CopilotSdkMode = 'interactive' | 'plan' | 'autopilot'; type CopilotSdkAttachment = Required<MessageOptions>['attachments'][number]; type CopilotCommandInvocationResult = Awaited<ReturnType<CopilotSession['rpc']['commands']['invoke']>>; type RuntimeSlashCommandInfo = Awaited<ReturnType<CopilotSession['rpc']['commands']['list']>>['commands'][number]; +type McpAuthHandler = NonNullable<SessionConfig['onMcpAuthRequest']>; +type McpAuthRequest = Parameters<McpAuthHandler>[0]; +type McpAuthResult = Awaited<ReturnType<McpAuthHandler>>; type RuntimeSlashCommandCatalog = { readonly commands: readonly RuntimeSlashCommandInfo[]; readonly byName: ReadonlyMap<string, RuntimeSlashCommandInfo>; readonly byAlias: ReadonlyMap<string, RuntimeSlashCommandInfo>; }; + +interface IPendingMcpAuthRequest { + readonly serverName: string; + readonly resource: ProtectedResourceMetadata; + readonly requiredScopes: readonly string[]; + readonly deferred: DeferredPromise<McpAuthResult | null | undefined>; +} type RuntimeSlashCommandCache = { value?: RuntimeSlashCommandCatalog; inFlight?: Promise<RuntimeSlashCommandCatalog>; @@ -400,7 +412,19 @@ class CopilotTurn { /** Current reasoning response part IDs for this turn, keyed by `parentToolCallId ?? ''`. */ readonly reasoningPartIds = new Map<string, string>(); - constructor(readonly id: string) { } + /** + * Per-turn tool-call aggregate accumulated across the turn's `assistant.message` rounds (main + * agent only), for the restricted `toolCallDetails` telemetry. `toolCounts` is keyed by tool name. + */ + readonly toolCounts = new Map<string, number>(); + toolCallRounds = 0; + totalToolCalls = 0; + parallelToolCallRounds = 0; + parallelToolCallsTotal = 0; + /** Model of the most recent round, reported as the turn's model. */ + lastModel: string | undefined; + + constructor(readonly id: string, readonly ordinal: number, readonly senderClientId: string | undefined) { } get state(): CopilotTurnState { return this._state; } get isPending(): boolean { return this._state === 'pending'; } @@ -433,8 +457,7 @@ export class CopilotAgentSession extends Disposable { get workingDirectory(): URI | undefined { return this._workingDirectory; } /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - /** Tracks active tool invocations so we can produce past-tense messages on completion. */ - private readonly _activeToolCalls = new Map<string, { toolName: string; displayName: string; parameters: Record<string, unknown> | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; startTimeMs: number; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); + private readonly _activeToolCalls = new Map<string, { toolName: string; displayName: string; parameters: Record<string, unknown> | undefined; content: ToolResultContent[]; parentToolCallId: string | undefined; mcpServerName: string | undefined; meta: IToolCallMeta | undefined }>(); /** * Maps a running subagent's `agentId` to its parent tool call id. Session- * scoped rather than per-turn: a subagent's lifetime is bounded by its @@ -443,6 +466,16 @@ export class CopilotAgentSession extends Disposable { * cleared on turn boundaries. */ private readonly _parentToolCallIdsByAgentId = new Map<string, string>(); + /** + * Maps a tool call id to the `agentId` carried on its `permission.requested` + * event, captured from the raw SDK event because the SDK's permission + * handler callback does not receive `agentId`. Used by + * {@link _ensureClientToolCallStarted} to resolve the parent tool call for a + * subagent client tool synthesized at permission time (SDK >= 1.0.6-preview + * fires `permission.requested` before `tool.execution_start`). Entries are + * cleared when the tool completes or the session is disposed. + */ + private readonly _permissionRequestAgentIds = new Map<string, string>(); /** Pending permission requests awaiting a renderer-side decision. */ private readonly _pendingPermissions = new Map<string, DeferredPromise<boolean>>(); /** Pending user input requests awaiting a renderer-side answer. */ @@ -486,11 +519,15 @@ export class CopilotAgentSession extends Disposable { * {@link resetTurnState}, finalized by {@link _completeActiveTurn}. */ private _currentTurn: CopilotTurn | undefined; + /** Monotonic 0-based ordinal assigned to each turn as it starts, for numeric `turnIndex` telemetry parity. */ + private _nextTurnOrdinal = 0; /** * Protocol turn ID of the active turn, or `''` when idle. Used by file * edit tracking and emitted on per-turn actions. */ private get _turnId(): string { return this._currentTurn?.id ?? ''; } + /** 0-based ordinal of the active turn within the session, or `0` when idle. */ + private get _turnOrdinal(): number { return this._currentTurn?.ordinal ?? 0; } /** * Last model id seen on the SDK's per-LLM-call `Usage` event (or a * direct {@link setModel} call). We rely on the @@ -533,6 +570,8 @@ export class CopilotAgentSession extends Disposable { private readonly _clientToolNames: ReadonlySet<string>; /** Deferred promises for pending client tool calls, keyed by toolCallId. */ private readonly _pendingClientToolCalls = new PendingRequestRegistry<ToolResultObject>(); + /** Pending SDK MCP auth handler promises, keyed by SDK auth request id. */ + private readonly _pendingMcpAuthRequests = new Map<string, IPendingMcpAuthRequest>(); /** `pending-edit-content:` URIs written during permission requests, keyed * by toolCallId. Cleaned up when the permission resolves or the session * is disposed. */ @@ -572,6 +611,13 @@ export class CopilotAgentSession extends Disposable { /** Platform used to compute the SDK sandbox policy (injectable for tests). */ private readonly _platform: NodeJS.Platform; + get mcpServerStates() { + return this._mcpCustomizations.runtimeStates; + } + + /** Stateless reporter used to emit restricted GH/MSFT telemetry for this session's model calls. */ + private readonly _telemetryReporter: AgentHostTelemetryReporter; + constructor( options: ICopilotAgentSessionOptions, @IInstantiationService private readonly _instantiationService: IInstantiationService, @@ -594,6 +640,7 @@ export class CopilotAgentSession extends Disposable { this._customizationDirectory = options.customizationDirectory; this._serverToolHost = options.serverToolHost; this._platform = options.platform ?? process.platform; + this._telemetryReporter = new AgentHostTelemetryReporter(this._telemetryService); this._appliedSnapshot = options.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} }; this._clientToolNames = clientToolNamesFromSnapshot(this._appliedSnapshot); @@ -623,6 +670,7 @@ export class CopilotAgentSession extends Disposable { this._register(toDisposable(() => this._cancelPendingPlanReviews())); this._register(toDisposable(() => this._drainPendingSteeringFlips())); this._register(toDisposable(() => this._cancelPendingMcpSamplings())); + this._register(toDisposable(() => this._cancelPendingMcpAuthRequests())); // When a shell tool associates a terminal with a tool call, fire a // tool_content_changed event so the UI can connect to the terminal @@ -649,6 +697,7 @@ export class CopilotAgentSession extends Disposable { })); } this._register(toDisposable(() => this._cancelPendingClientToolCalls())); + this._register(toDisposable(() => this._permissionRequestAgentIds.clear())); } // ---- AgentSignal helpers ------------------------------------------------ @@ -769,8 +818,8 @@ export class CopilotAgentSession extends Disposable { * from a previous turn so the next text/reasoning chunk allocates a new * response part. The turn becomes `running` on the first SDK event. */ - resetTurnState(turnId: string): void { - this._currentTurn = new CopilotTurn(turnId); + resetTurnState(turnId: string, senderClientId?: string): void { + this._currentTurn = new CopilotTurn(turnId, this._nextTurnOrdinal++, senderClientId); } private _completeActiveTurn(): void { @@ -779,6 +828,21 @@ export class CopilotAgentSession extends Disposable { return; } turn.markCompleted(); + // Emit the restricted per-turn tool-call aggregate before the turn is cleared. Main agent + // only: `_appliedSnapshot.tools` and this turn's accumulator describe the main session's turn. + // No-ops (in the reporter) when the turn made no tool calls. + this._telemetryReporter.toolCallDetails({ + session: this.sessionUri.toString(), + turnId: turn.id, + model: turn.lastModel, + responseType: 'success', + toolCounts: Object.fromEntries(turn.toolCounts), + availableTools: this._appliedSnapshot.tools.map(tool => tool.name), + numRequests: turn.toolCallRounds, + totalToolCalls: turn.totalToolCalls, + parallelToolCallRounds: turn.parallelToolCallRounds, + parallelToolCallsTotal: turn.parallelToolCallsTotal, + }); this._emitAction({ type: ActionType.ChatTurnComplete, turnId: turn.id, @@ -797,30 +861,6 @@ export class CopilotAgentSession extends Disposable { return join(this._workingDirectory.fsPath, path); } - private _sendToolInvokedTelemetry(success: boolean, errorCode: string | undefined, toolCall: { readonly toolName: string; readonly startTimeMs: number; readonly mcpServerName: string | undefined }): void { - let result: LanguageModelToolInvokedEvent['result']; - if (success) { - result = 'success'; - } else if (errorCode === 'rejected' || errorCode === 'denied' || errorCode === 'cancelled') { - result = 'userCancelled'; - } else { - result = 'error'; - } - - const isClientTool = this._clientToolNames.has(toolCall.toolName); - const toolSourceKind = toolCall.mcpServerName ? 'mcp' : isClientTool ? 'client' : 'agentHost'; - const invocationTimeMs = Date.now() - toolCall.startTimeMs; - - this._telemetryService.publicLog2<LanguageModelToolInvokedEvent, LanguageModelToolInvokedClassification>('languageModelToolInvoked', { - result, - chatSessionId: this.sessionUri.toString(), - toolId: toolCall.toolName, - toolExtensionId: undefined, - toolSourceKind, - invocationTimeMs, - }); - } - /** * Emits a synthetic markdown content block for the active turn and * makes it the current markdown response part so that subsequent SDK @@ -995,6 +1035,10 @@ export class CopilotAgentSession extends Disposable { binaryResultsForLlm: binaryResults?.length ? binaryResults : undefined, }); } + + // Still pending permission, so this call may have errored while getting permission. + // Go ahead and allow the call which will immediately see the buffered value. + this.respondToPermissionRequest(toolCallId, true); } /** @@ -1028,6 +1072,7 @@ export class CopilotAgentSession extends Disposable { handleExitPlanModeRequest: (request, invocation) => this._handleExitPlanModeRequest(request, invocation), handleUserInputRequest: (request, invocation) => this._handleUserInputRequest(request, invocation), handleElicitationRequest: context => this._handleElicitationRequest(context), + handleMcpAuthRequest: request => this._handleMcpAuthRequest(request), requestUnsandboxedCommandConfirmation: request => this._requestUnsandboxedCommandConfirmation(request), createClientSdkTools: () => this._createClientSdkTools(), createServerSdkTools: () => this._createServerSdkTools(), @@ -1036,14 +1081,108 @@ export class CopilotAgentSession extends Disposable { }; } + async resolveMcpAuthentication(params: AuthenticateParams): Promise<boolean> { + let resolved = false; + for (const [requestId, pending] of this._pendingMcpAuthRequests) { + if (pending.resource.resource !== params.resource || !this._scopesSatisfy(params.scopes, pending.requiredScopes)) { + continue; + } + this._pendingMcpAuthRequests.delete(requestId); + pending.deferred.complete({ kind: 'token', accessToken: params.token }); + resolved = true; + } + return resolved; + } + + private async _handleMcpAuthRequest(request: McpAuthRequest): Promise<McpAuthResult | null | undefined> { + const resource = this._protectedResourceFromMcpAuthRequest(request); + const requiredScopes = this._requiredScopesFromMcpAuthRequest(request, resource); + const deferred = new DeferredPromise<McpAuthResult | null | undefined>(); + this._pendingMcpAuthRequests.set(request.requestId, { + serverName: request.serverName, + resource, + requiredScopes, + deferred, + }); + this._mcpCustomizations.applyOne({ + name: request.serverName, + state: { + kind: McpServerStatus.AuthRequired, + reason: this._mcpAuthRequiredReason(request.reason), + resource, + requiredScopes: requiredScopes.length ? [...requiredScopes] : undefined, + description: request.wwwAuthenticateParams?.error, + }, + }); + this._logService.info(`[Copilot:${this.sessionId}] MCP server '${request.serverName}' requires authentication for ${resource.resource}`); + return deferred.p.finally(() => this._pendingMcpAuthRequests.delete(request.requestId)); + } + + private _protectedResourceFromMcpAuthRequest(request: McpAuthRequest): ProtectedResourceMetadata { + if (request.resourceMetadata) { + try { + const parsed = JSON.parse(request.resourceMetadata); + if (isAuthorizationProtectedResourceMetadata(parsed)) { + return parsed; + } + this._logService.warn(`[Copilot:${this.sessionId}] Ignoring invalid MCP protected-resource metadata for '${request.serverName}'`); + } catch (err) { + this._logService.warn(`[Copilot:${this.sessionId}] Failed to parse MCP protected-resource metadata for '${request.serverName}'`, err); + } + } + const scopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); + return { + resource: request.serverUrl, + resource_name: request.serverName, + scopes_supported: scopes.length ? scopes.slice() : undefined, + }; + } + + private _requiredScopesFromMcpAuthRequest(request: McpAuthRequest, resource: ProtectedResourceMetadata): readonly string[] { + const challengeScopes = this._scopesFromChallenge(request.wwwAuthenticateParams?.scope); + return challengeScopes.length ? challengeScopes : resource.scopes_supported ?? []; + } + + private _scopesFromChallenge(scope: string | undefined): readonly string[] { + return scope?.split(/\s+/).map(s => s.trim()).filter(s => s.length > 0) ?? []; + } + + private _mcpAuthRequiredReason(reason: McpAuthRequest['reason']): McpAuthRequiredReason { + switch (reason) { + case 'refresh': + case 'reauth': + return McpAuthRequiredReason.Expired; + case 'upscope': + return McpAuthRequiredReason.InsufficientScope; + case 'initial': + default: + return McpAuthRequiredReason.Required; + } + } + + private _scopesSatisfy(provided: readonly string[] | undefined, required: readonly string[]): boolean { + if (required.length === 0 || provided === undefined) { + return true; + } + const providedSet = new Set(provided); + return required.every(scope => providedSet.has(scope)); + } + + private _cancelPendingMcpAuthRequests(): void { + for (const pending of this._pendingMcpAuthRequests.values()) { + pending.deferred.complete({ kind: 'cancelled' }); + } + this._pendingMcpAuthRequests.clear(); + } + // ---- session operations ------------------------------------------------- - async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode): Promise<void> { + async send(prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, mode?: CopilotSdkMode, senderClientId?: string): Promise<void> { if (turnId && this._currentTurn?.id !== turnId) { // Establish the `pending` turn for this message. Callers normally // call `resetTurnState` just before `send()`; this covers the // direct-send path and is a no-op when the turn already exists. - this.resetTurnState(turnId); + this.resetTurnState(turnId, senderClientId); } this._logService.info(`[Copilot:${this.sessionId}] sendMessage called: "${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}" (${attachments?.length ?? 0} attachments)`); @@ -1083,7 +1222,7 @@ export class CopilotAgentSession extends Disposable { mode = 'plan'; prompt = slashCommand.rest; } else if (slashCommand?.command === 'rubber-duck') { - if (this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.RubberDuck) !== true) { + if (this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.RubberDuck) !== true) { // Feature not enabled — pass the remaining text through as a plain // message rather than injecting agent instructions for an unavailable agent. prompt = slashCommand.rest; @@ -1095,7 +1234,8 @@ export class CopilotAgentSession extends Disposable { } } else if (slashCommand) { const runtimeSlashCommand = await this._resolveRuntimeSlashCommand(slashCommand.command); - if (runtimeSlashCommand) { + // Skills can be passed as is to the runtime. + if (runtimeSlashCommand && runtimeSlashCommand.kind !== 'skill') { let result: CopilotCommandInvocationResult; try { result = await this._wrapper.session.rpc.commands.invoke({ @@ -1219,7 +1359,7 @@ export class CopilotAgentSession extends Disposable { return cache.inFlight; } - const inFlight = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: false, includeClientCommands: true }) + const inFlight = this._wrapper.session.rpc.commands.list({ includeBuiltins: true, includeSkills: true, includeClientCommands: true }) .then(result => this._toRuntimeSlashCommandCatalog(result.commands)); cache.inFlight = inFlight; inFlight.then(catalog => { @@ -1281,23 +1421,19 @@ export class CopilotAgentSession extends Disposable { } /** - * Translate a protocol {@link MessageAttachment} into the Copilot CLI - * SDK's `attachments` payload shape. Resource attachments map to the - * SDK's reference-style `file`/`directory`/`selection` variants (the - * {@link MessageAttachmentBase.displayKind} advisory hint controls - * which one). Embedded resources (e.g. inline image bytes) map to the - * SDK's `blob` variant. - * Simple attachments with a model representation map to `text/plain` - * blob attachments. + * Translate a protocol {@link MessageAttachment} into the Copilot CLI SDK's `attachments` payload shape. Resource + * attachments map to the SDK's reference-style `file`/`directory`/`selection` variants (the + * {@link MessageAttachmentBase.displayKind} advisory hint controls which one). Embedded resources (e.g. inline + * image bytes, or unsaved editor content) map to the SDK's `blob` variant, and simple attachments with a model + * representation map to `text/plain` blob attachments. * * Any Resource attachment carrying a {@link TextSelection} (e.g. `displayKind === 'selection'` or `'symbol'`) is * mapped to the SDK's `selection` variant so the range survives the round-trip — keying off the `selection` field - * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). - * - * For selections we read the resource content from disk and slice it - * by the carried range (the protocol's {@link TextSelection} only - * carries the range, not the inline text). On read failure the - * selection downgrades to a plain file reference. + * rather than just `displayKind` avoids symbol attachments degrading to a plain file reference (#315193). For those + * we read the resource content from disk and slice it by the carried range (the protocol's {@link TextSelection} + * only carries the range, not the inline text); on read failure the selection downgrades to a plain file reference. + * A textual embedded resource already carries the exact inline text to send (the whole live buffer for a document, + * or just the selected text for a selection), so it is forwarded as-is without further slicing. */ private async _toSdkAttachment(attachment: MessageAttachment): Promise<CopilotSdkAttachment | undefined> { if (isAgentFeedbackAnnotationsAttachment(attachment)) { @@ -1459,10 +1595,15 @@ export class CopilotAgentSession extends Disposable { } const content = (tc as { content?: readonly ToolResultContent[] }).content; const subagentContent = content ? getToolSubagentContent({ content }) : undefined; + // Prefer the spawning Task tool's short `description` (captured on + // the parent tool call's `_meta`) so restored peer tabs match the + // live path's concise, per-task naming; fall back to the agent + // type's display name. + const taskDescription = readToolCallMeta(tc).subagentDescription; out.push({ resource: URI.parse(buildSubagentSessionUri(parentSessionStr, tc.toolCallId)), toolCallId: tc.toolCallId, - title: subagentContent?.title ?? 'Subagent', + title: subagentChatTitle(taskDescription, subagentContent?.title), turns: childTurns, }); } @@ -1592,6 +1733,29 @@ export class CopilotAgentSession extends Disposable { } } + async startMcpServer(id: string): Promise<void> { + const serverName = this._mcpCustomizations.serverNameForCustomizationId(id); + if (!serverName) { + this._logService.warn(`[Copilot:${this.sessionId}] Cannot start unknown MCP server customization ${id}`); + return; + } + // stopServer leaves inline session MCP servers not_configured; disable->enable is the validated restart path. + await this._wrapper.session.rpc.mcp.disable({ serverName }); + await this._wrapper.session.rpc.mcp.enable({ serverName }); + await this._wrapper.session.rpc.mcp.listTools({ serverName }); + this._seedMcpServersFromRpc(); + } + + async stopMcpServer(id: string): Promise<void> { + const serverName = this._mcpCustomizations.serverNameForCustomizationId(id); + if (!serverName) { + this._logService.warn(`[Copilot:${this.sessionId}] Cannot stop unknown MCP server customization ${id}`); + return; + } + await this._wrapper.session.rpc.mcp.stopServer({ serverName }); + this._mcpCustomizations.applyOne({ name: serverName, state: { kind: McpServerStatus.Stopped } }); + } + /** * Forwards an App→host `sampling/createMessage` request received * over the AHP `mcp://` channel to `rpc.mcp.executeSampling`. The @@ -1655,6 +1819,78 @@ export class CopilotAgentSession extends Disposable { // ---- permission handling ------------------------------------------------ + /** + * Emits a `ChatToolCallStart` for a client tool that is about to have a + * permission-derived `ChatToolCallReady` dispatched for it, when the real + * `tool.execution_start` has not been observed yet. + * + * The Copilot SDK (>= 1.0.6-preview) reordered client-tool events so that + * `permission.requested` now fires *before* `tool.execution_start`. The host + * derives `ChatToolCallReady` from the permission request, but the tool-call + * part is only created by `ChatToolCallStart` (emitted from + * `tool.execution_start`). Without this, the ready would arrive before the + * part exists, be dropped by the reducer, and leave the client tool stuck in + * `Streaming` — the workbench never invokes it, the parked SDK handler never + * resolves, and the turn hangs. Synthesizing the start here restores the + * start-before-ready ordering the downstream pipeline assumes. + * + * No-ops for: non-client tools (server/shell tools execute in-process and + * don't depend on this), tools already started (the real + * `tool.execution_start`, or a previous call here), and client tools with no + * connected owning client (the no-client failure path in `onToolStart` + * handles those). + */ + private _ensureClientToolCallStarted(request: ITypedPermissionRequest): void { + const toolCallId = request.toolCallId; + const toolName = request.toolName; + if (!toolCallId || !toolName) { + return; + } + if (this._activeToolCalls.has(toolCallId)) { + return; + } + if (!this._clientToolNames.has(toolName)) { + return; + } + const ownerClientId = this._activeClientToolSet.ownerOf(toolName, this._currentTurn?.senderClientId); + if (!ownerClientId) { + return; + } + // Resolve the parent tool call for subagent client tools. The SDK's + // permission handler callback drops the event's `agentId`, so we recover + // it from the raw `permission.requested` event captured in + // `_permissionRequestAgentIds`. Routing the synthesized start (and, via + // `_activeToolCalls`, the subsequent permission `pending_confirmation`) + // to the subagent session keeps the tool rendering in the right chat. + const agentId = this._permissionRequestAgentIds.get(toolCallId); + const parentToolCallId = agentId ? this._parentToolCallIdsByAgentId.get(agentId) : undefined; + const parameters = request.args; + const toolArgs = parameters !== undefined ? tryStringify(parameters) : undefined; + const displayName = getToolDisplayName(toolName); + const meta: Mutable<IToolCallMeta> = { toolKind: getToolKind(toolName) }; + if (toolArgs !== undefined) { + meta.toolArguments = toolArgs; + } + this._logService.info(`[Copilot:${this.sessionId}] Synthesizing tool start for client tool ahead of permission ready: ${toolName} (toolCallId=${toolCallId}, parentToolCallId=${parentToolCallId ?? 'none'})`); + this._activeToolCalls.set(toolCallId, { toolName, displayName, parameters, content: [], parentToolCallId, mcpServerName: undefined, meta }); + + // A new tool call invalidates the current markdown and reasoning parts + // so the next delta starts a fresh part (mirrors `onToolStart`). + this._currentTurn?.markdownPartIds.delete(parentToolCallId ?? ''); + this._currentTurn?.reasoningPartIds.delete(parentToolCallId ?? ''); + + this._emitAction({ + type: ActionType.ChatToolCallStart, + turnId: this._turnId, + toolCallId, + toolName, + displayName, + intention: getShellIntention(toolName, parameters), + contributor: { kind: ToolCallContributorKind.Client, clientId: ownerClientId }, + _meta: toToolCallMeta(meta), + }, parentToolCallId); + } + /** * Handles a permission request from the SDK by firing a `tool_ready` event * (which transitions the tool to PendingConfirmation) and waiting for the @@ -1763,6 +1999,16 @@ export class CopilotAgentSession extends Disposable { // Fire a pending_confirmation signal to transition the tool to PendingConfirmation const toolName = request.toolName ?? request.kind; + + // The Copilot SDK (>= 1.0.6-preview) fires `permission.requested` + // *before* `tool.execution_start`, so for client tools the + // `ChatToolCallStart` that creates the tool-call part has not been + // emitted yet. Synthesize it here so the permission-derived + // `ChatToolCallReady` below lands on an existing part rather than + // being dropped (which would leave the client tool stuck streaming + // and hang the turn). No-op for non-client / already-started tools. + this._ensureClientToolCallStarted(request); + // Forward the tool's parentToolCallId (if any) so the host can // route the resulting ChatToolCallReady to the correct // subagent session — without it the action would land on the @@ -1864,7 +2110,7 @@ export class CopilotAgentSession extends Disposable { * mode the SDK sandbox config is unused, so we neither forward nor toggle it. */ private _isCustomTerminalToolEnabled(): boolean { - return this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; + return this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; } /** @@ -2374,7 +2620,7 @@ export class CopilotAgentSession extends Disposable { turnId: this._turnId, part: { kind: ResponsePartKind.SystemNotification, - content: notification.content, + content: notification.messageText, }, }); return; @@ -2440,6 +2686,38 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onMessage(e => { this._logService.info(`[Copilot:${sessionId}] Full message received: ${e.data.content.length} chars`); + // Report the enhanced GH `request.options.tools` event for this model call — parity with + // the Copilot extension, which emits it per LLM request. `assistant.message` is the + // agent-host's per-model-call boundary; we correlate on its `x-copilot-service-request-id`. + // Main agent only: `_appliedSnapshot.tools` is the session's tool set, which does not + // describe a subagent's model call, so subagent messages (mapped or dropped) are skipped. + if (!e.agentId) { + this._telemetryReporter.assistantMessageReceived(this.sessionUri.toString(), e.data.serviceRequestId, this._appliedSnapshot.tools); + // Restricted `conversation.messageText` (source=model): the model's raw response text. + this._telemetryReporter.modelMessageText(this.sessionUri.toString(), e.data.content, this._turnOrdinal, e.data.serviceRequestId); + // Accumulate the per-turn tool-call aggregate for the restricted `toolCallDetails` event. + // Every main-agent `assistant.message` is one model-call round (matches the extension's + // `numRequests = toolCallRounds.length`, which counts the final tool-free response round + // too); the tool-count stats only apply to rounds that carried tool requests. + const turn = this._currentTurn; + if (turn) { + turn.toolCallRounds++; + if (e.data.model) { + turn.lastModel = e.data.model; + } + const toolRequests = e.data.toolRequests; + if (toolRequests?.length) { + turn.totalToolCalls += toolRequests.length; + if (toolRequests.length > 1) { + turn.parallelToolCallRounds++; + turn.parallelToolCallsTotal += toolRequests.length; + } + for (const req of toolRequests) { + turn.toolCounts.set(req.name, (turn.toolCounts.get(req.name) ?? 0) + 1); + } + } + } + } // The SDK fires a `message` event with the full assembled content after // streaming deltas. If deltas already created a markdown part for this // turn, the live state is up to date and we skip. Only emit a fresh @@ -2447,8 +2725,8 @@ export class CopilotAgentSession extends Disposable { // where the SDK delivered the full message at once). // // Other fields (toolRequests, reasoningText, encryptedContent) are - // only used for history reconstruction and live tool calls fire - // their own tool_start events, so we can safely drop them here. + // only used for history reconstruction and live tool calls fire their + // own tool_start events, so we can safely drop them here. if (!e.data.content) { return; } @@ -2469,11 +2747,35 @@ export class CopilotAgentSession extends Disposable { }, parentToolCallId); })); + this._register(wrapper.onPermissionRequested(e => { + // Capture the raw event's `agentId` (dropped by the SDK's permission + // handler callback) so `_ensureClientToolCallStarted` can route a + // subagent client tool's synthesized start to the right session. + // This fires synchronously during SDK event dispatch, before the + // permission handler resumes past its first await, so the mapping is + // available by synthesis time. + const toolCallId = e.data.permissionRequest.toolCallId; + if (toolCallId && e.agentId) { + this._permissionRequestAgentIds.set(toolCallId, e.agentId); + } + })); + this._register(wrapper.onToolStart(e => { if (isHiddenTool(e.data.toolName)) { this._logService.trace(`[Copilot:${sessionId}] Tool started (hidden): ${e.data.toolName}`); return; } + // The client-tool start may already have been synthesized at + // permission time (SDK >= 1.0.6-preview fires `permission.requested` + // before `tool.execution_start`; see `_ensureClientToolCallStarted`). + // In that case the part already exists with the correct client + // contributor, so skip emitting a duplicate `ChatToolCallStart`. + // Client tools emit no `ChatToolCallReady` from here anyway, so + // there is nothing else to do. + if (this._activeToolCalls.has(e.data.toolCallId)) { + this._logService.trace(`[Copilot:${sessionId}] Tool start already emitted for ${e.data.toolCallId}; skipping duplicate.`); + return; + } this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`); let toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined; let parameters: Record<string, unknown> | undefined; @@ -2491,7 +2793,7 @@ export class CopilotAgentSession extends Disposable { return; } const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); - this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, startTimeMs: Date.now(), mcpServerName: e.data.mcpServerName, meta: undefined }); + this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [], parentToolCallId, mcpServerName: e.data.mcpServerName, meta: undefined }); if (isTaskCompleteTool(e.data.toolName)) { const scope = parentToolCallId ?? ''; this._currentTurn?.markdownPartIds.delete(scope); @@ -2503,7 +2805,7 @@ export class CopilotAgentSession extends Disposable { let contributor: { readonly kind: ToolCallContributorKind.Client; readonly clientId: string } | { readonly kind: ToolCallContributorKind.MCP; readonly customizationId: string } | undefined; const isClientTool = this._clientToolNames.has(e.data.toolName); - const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(e.data.toolName) : undefined; + const ownerClientId = isClientTool ? this._activeClientToolSet.ownerOf(e.data.toolName, this._currentTurn?.senderClientId) : undefined; if (ownerClientId) { contributor = { kind: ToolCallContributorKind.Client, clientId: ownerClientId }; } else if (e.data.mcpServerName) { @@ -2559,6 +2861,7 @@ export class CopilotAgentSession extends Disposable { toolCallId: e.data.toolCallId, toolName: e.data.toolName, displayName, + intention: getShellIntention(e.data.toolName, parameters), contributor, _meta: toToolCallMeta(meta), }, parentToolCallId); @@ -2597,11 +2900,8 @@ export class CopilotAgentSession extends Disposable { return; } - // For client tools, do NOT auto-ready — the tool handler will fire - // a separate tool_ready signal once the deferred is in place (or - // the permission flow fires it first). MCP tools have no such - // handler and are auto-readied below alongside built-in tools. - if (contributor?.kind === ToolCallContributorKind.Client) { + const shouldWaitForClientToolReady = contributor?.kind === ToolCallContributorKind.Client && !isAgentCoordinationTool(e.data.toolName); + if (shouldWaitForClientToolReady) { return; } @@ -2627,11 +2927,11 @@ export class CopilotAgentSession extends Disposable { } this._logService.info(`[Copilot:${sessionId}] Tool completed: ${e.data.toolCallId}`); this._activeToolCalls.delete(e.data.toolCallId); + this._permissionRequestAgentIds.delete(e.data.toolCallId); const displayName = tracked.displayName; const toolOutput = e.data.error?.message ?? e.data.result?.content; if (isTaskCompleteTool(tracked.toolName)) { - this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); const summary = getTaskCompleteMarkdown(tracked.parameters, toolOutput); if (summary) { this._emitAction({ @@ -2647,6 +2947,7 @@ export class CopilotAgentSession extends Disposable { if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } + appendSdkToolResultContent(content, e.data.result?.contents); const command = isString(tracked.parameters?.command) ? tracked.parameters.command : undefined; const filePaths = isEditTool(tracked.toolName, command) ? this._getEditFilePaths(tracked.parameters) : []; @@ -2674,7 +2975,6 @@ export class CopilotAgentSession extends Disposable { } } - this._sendToolInvokedTelemetry(e.data.success, e.data.error?.code, tracked); // eslint-disable-next-line local/code-no-untyped-meta-access -- Copilot SDK's own typed `_meta`, not the AHP protocol bag. const resourceUri = e.data.toolDescription?._meta?.ui?.resourceUri; let completeMeta: IToolCallMeta | undefined = tracked.meta; @@ -2754,6 +3054,10 @@ export class CopilotAgentSession extends Disposable { // clickable file link, matching the `view`-tool display style. this._register(wrapper.onSkillInvoked(e => { this._logService.info(`[Copilot:${sessionId}] Skill invoked: ${e.data.name} (${e.data.path})`); + if (this._shouldDropUnmappedSubagentEvent(e, 'skill.invoked')) { + return; + } + const parentToolCallId = this._parentToolCallIdForSubagentEvent(e); const synth = synthesizeSkillToolCall(e.data, e.id); this._emitAction({ type: ActionType.ChatToolCallStart, @@ -2761,14 +3065,14 @@ export class CopilotAgentSession extends Disposable { toolCallId: synth.toolCallId, toolName: synth.toolName, displayName: synth.displayName, - }); + }, parentToolCallId); this._emitAction({ type: ActionType.ChatToolCallReady, turnId: this._turnId, toolCallId: synth.toolCallId, invocationMessage: synth.invocationMessage, confirmed: ToolCallConfirmationReason.NotNeeded, - }); + }, parentToolCallId); this._emitAction({ type: ActionType.ChatToolCallComplete, turnId: this._turnId, @@ -2777,7 +3081,7 @@ export class CopilotAgentSession extends Disposable { success: true, pastTenseMessage: synth.pastTenseMessage, }, - }); + }, parentToolCallId); })); this._register(wrapper.onSubagentStarted(e => { @@ -2785,6 +3089,7 @@ export class CopilotAgentSession extends Disposable { this._parentToolCallIdsByAgentId.set(e.agentId, e.data.toolCallId); } this._logService.info(`[Copilot:${sessionId}] Subagent started: toolCallId=${e.data.toolCallId}, agent=${e.data.agentName}`); + const tracked = this._activeToolCalls.get(e.data.toolCallId); this._onDidSessionProgress.fire({ kind: 'subagent_started', chat: this._chatChannelUri, @@ -2792,6 +3097,17 @@ export class CopilotAgentSession extends Disposable { agentName: e.data.agentName, agentDisplayName: e.data.agentDisplayName, agentDescription: e.data.agentDescription, + // The spawning Task tool's short `description` input (captured on + // tool start) is the concise per-task tab title for the subagent's + // read-only peer chat — distinct even for same-type subagents. + taskDescription: tracked?.meta?.subagentDescription, + // When the spawning tool call is itself an inner tool of + // another subagent, its recorded parent is the tool call one + // level up — the tool call in whose (subagent) chat this + // spawning tool lives. The host uses it to route the + // discovery content block to that immediate parent chat, at + // any nesting depth. + parentToolCallId: tracked?.parentToolCallId, }); })); @@ -2812,6 +3128,10 @@ export class CopilotAgentSession extends Disposable { }); })); + // Tracks the last parent-scope usage so the async attribution enrichment + // can re-emit a complete action (with accumulated credits, quota, etc.). + let lastParentUsage: UsageInfo | undefined; + this._register(wrapper.onUsage(e => { // Usage events for a subagent's model calls carry the subagent's // `agentId`. Such an event is reported twice: @@ -2865,10 +3185,12 @@ export class CopilotAgentSession extends Disposable { }; // Parent turn aggregate (scope `''`): every model call contributes. + const parentUsage = buildUsage(''); + lastParentUsage = parentUsage; this._emitAction({ type: ActionType.ChatUsage, turnId: this._turnId, - usage: buildUsage(''), + usage: parentUsage, }); // Subagent component: additionally report the subagent's own running @@ -2882,6 +3204,64 @@ export class CopilotAgentSession extends Disposable { } })); + // After each usage event, asynchronously fetch the per-source context- + // window attribution from the SDK and re-emit the usage action enriched + // with the attribution data. The reducer replaces `activeTurn.usage` so + // the widget picks up the detailed breakdown on the next render cycle. + this._register(wrapper.onUsage(async e => { + // Only enrich the parent-turn aggregate (not subagent scopes). + if (this._parentToolCallIdForSubagentEvent(e)) { + return; + } + const turnId = this._turnId; + // Capture the base usage before the await boundary so concurrent + // usage events don't overwrite what we merge into. + const baseUsage = lastParentUsage ?? { + inputTokens: e.data.inputTokens, + outputTokens: e.data.outputTokens, + model: e.data.model, + cacheReadTokens: e.data.cacheReadTokens, + }; + try { + const result = await this._wrapper.session.rpc.metadata.getContextAttribution(); + const attribution = result?.contextAttribution; + if (!attribution || !turnId) { + this._logService.trace(`[Copilot:${sessionId}] contextAttribution: null/empty (turnId=${turnId})`); + return; + } + // If the turn changed while we were awaiting, don't pollute the + // new turn's state with stale attribution data. + if (turnId !== this._turnId) { + return; + } + // Guard against a newer usage event having arrived while we + // were awaiting — only enrich if baseUsage is still current. + if (baseUsage !== lastParentUsage) { + return; + } + if (this._logService.getLevel() <= LogLevel.Trace) { + this._logService.trace(`[Copilot:${sessionId}] contextAttribution: totalTokens=${attribution.totalTokens}, entries=${JSON.stringify(attribution.entries.map(e => ({ kind: e.kind, id: e.id, label: e.label, tokens: e.tokens, parentId: e.parentId })))}`); + } + // Re-emit the usage action preserving the captured parent-scope + // usage (with accumulated credits) but adding the attribution. + const enriched: UsageInfo = { + ...baseUsage, + _meta: { + ...(baseUsage._meta ?? {}), + contextAttribution: attribution, + }, + }; + lastParentUsage = enriched; + this._emitAction({ + type: ActionType.ChatUsage, + turnId, + usage: enriched, + }); + } catch (err) { + this._logService.trace(`[Copilot:${sessionId}] contextAttribution RPC failed: ${(err as Error)?.message ?? err}`); + } + })); + this._register(wrapper.onReasoningDelta(e => { this._logService.trace(`[Copilot:${sessionId}] Reasoning delta: ${e.data.deltaContent.length} chars`); if (this._shouldDropUnmappedSubagentEvent(e, 'assistant.reasoning_delta')) { @@ -2967,8 +3347,7 @@ export class CopilotAgentSession extends Disposable { private _applyMcpServerList(servers: readonly { readonly name: string; readonly status: SdkMcpServerStatus; readonly error?: string }[]): void { const sdkServers = servers - .map(s => this._toSdkMcpServer(s.name, s.status, s.error)) - .filter(isDefined); + .map(s => this._toSdkMcpServer(s.name, s.status, s.error)); this._mcpCustomizations.applyAll(sdkServers); } @@ -2995,23 +3374,13 @@ export class CopilotAgentSession extends Disposable { /** * Translates the SDK's flat MCP status string into AHP's discriminated - * {@link McpServerState} union. Returns `undefined` for - * `not_configured`, which has no AHP equivalent — the server is - * dropped from the inventory. - * - * V1 maps `needs-auth` to {@link McpServerStatus.Starting}: OAuth - * handling is intentionally out of scope, so authRequired transitions - * are masked as "still connecting" until the auth pipeline lands. + * {@link McpServerState} union. */ - private _toSdkMcpServer(name: string, status: SdkMcpServerStatus, error?: string): ISdkMcpServer | undefined { - const state = this._translateSdkMcpStatus(status, error); - if (!state) { - return undefined; - } - return { name, state }; + private _toSdkMcpServer(name: string, status: SdkMcpServerStatus, error?: string): ISdkMcpServer { + return { name, state: this._translateSdkMcpStatus(name, status, error) }; } - private _translateSdkMcpStatus(status: SdkMcpServerStatus, error?: string): McpServerState | undefined { + private _translateSdkMcpStatus(name: string, status: SdkMcpServerStatus, error?: string): McpServerState { switch (status) { case 'connected': return { kind: McpServerStatus.Ready }; @@ -3024,16 +3393,18 @@ export class CopilotAgentSession extends Disposable { }, }; case 'pending': - case 'needs-auth': - // TODO: surface `needs-auth` as McpServerStatus.AuthRequired - // once OAuth wiring is in place. + case 'needs-auth': { + const previous = this._mcpCustomizations.stateForServer(name); + if (previous?.kind === McpServerStatus.AuthRequired) { + return previous; + } return { kind: McpServerStatus.Starting }; + } case 'disabled': - return { kind: McpServerStatus.Stopped }; case 'not_configured': - return undefined; + return { kind: McpServerStatus.Stopped }; default: - return undefined; + return { kind: McpServerStatus.Stopped }; } } @@ -3249,6 +3620,13 @@ export class CopilotAgentSession extends Disposable { this._register(wrapper.onUserMessage(e => { this._logService.trace(`[Copilot:${sessionId}] User message: ${e.data.content.length} chars, ${e.data.attachments?.length ?? 0} attachments`); + // Restricted `conversation.messageText` (source=user): the raw user prompt text. Emit only + // for genuine human prompts on the main agent — skip subagent turns (driven by the parent) + // and SDK-injected synthetic messages (skill/harness injections carry a non-`user` source, + // matching `isSyntheticUserMessage`) so injected content is not reported as the user's prompt. + if (!e.agentId && (!e.data.source || e.data.source.toLowerCase() === 'user')) { + this._telemetryReporter.userMessageText(this.sessionUri.toString(), e.data.content, this._turnOrdinal); + } })); this._register(wrapper.onPendingMessagesModified(() => { diff --git a/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts b/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts index 1d1960c3726840..153975e4370d4a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotBranchNameGenerator.ts @@ -17,6 +17,13 @@ export interface ICopilotBranchNameGeneratorRequest { readonly message?: string; readonly githubToken?: string; readonly signal?: AbortSignal; + /** + * Optional prefix prepended before the built-in {@link COPILOT_BRANCH_PREFIX} + * when constructing the branch name (e.g. the user's `git.branchPrefix` + * setting). An empty or omitted value preserves the historical + * `agents/<hint>` naming. + */ + readonly branchPrefix?: string; /** * Optional predicate used to check whether a candidate branch name already * exists. When it reports a collision, a short suffix derived from the @@ -105,14 +112,19 @@ export class CopilotBranchNameGenerator implements ICopilotBranchNameGenerator { } private async _buildBranchName(request: ICopilotBranchNameGeneratorRequest, branchNameHint: string | undefined): Promise<string> { + // Prepend the caller-supplied prefix (e.g. `git.branchPrefix`) ahead of + // the built-in `agents/` prefix. An empty/omitted value keeps the + // historical `agents/<hint>` shape. + const prefix = `${request.branchPrefix ?? ''}${COPILOT_BRANCH_PREFIX}`; + if (!branchNameHint) { // No usable hint - fall back to the (already unique) session id. - return `${COPILOT_BRANCH_PREFIX}${request.sessionId}`; + return `${prefix}${request.sessionId}`; } // Prefer the bare hint and only append a short session-id suffix when // the branch name would collide with an existing branch. - const branchName = `${COPILOT_BRANCH_PREFIX}${branchNameHint}`; + const branchName = `${prefix}${branchNameHint}`; if (request.branchExists && await request.branchExists(branchName)) { return `${branchName}-${request.sessionId.substring(0, COPILOT_BRANCH_SESSION_ID_SUFFIX_LENGTH)}`; } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts index d2e97dabe4fbd0..ad4b83cae31db5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionLauncher.ts @@ -3,17 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, PermissionRequestResult, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; +import type { CopilotClient, ExitPlanModeRequest, ExitPlanModeResult, NamedProviderConfig, PermissionRequestResult, ProviderModelConfig, ResumeSessionConfig, SessionConfig, Tool } from '@github/copilot-sdk'; import { coalesce } from '../../../../base/common/arrays.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService, LogLevel } from '../../../log/common/log.js'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import { AgentHostSessionSyncEnabledConfigKey, platformRootSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { AgentHostSandboxConfigKey, sandboxConfigSchema } from '../../common/sandboxConfigSchema.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js'; +import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js'; +import type { IByokLmModelInfo } from '../../common/agentHostByokLm.js'; import type { ModelSelection, ToolDefinition } from '../../common/state/protocol/state.js'; import type { ActiveClientToolSet } from '../activeClientState.js'; import { CopilotSessionWrapper } from './copilotSessionWrapper.js'; @@ -25,6 +28,7 @@ import type { ICopilotPluginInfo } from './copilotAgent.js'; import { agentHostPromptRegistry, type IAgentHostPromptContext } from './prompts/promptRegistry.js'; import { describeSystemMessageConfig } from './prompts/systemMessage.js'; import './prompts/allPrompts.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; export const ThinkingLevelConfigKey = 'thinkingLevel'; /** @@ -52,6 +56,10 @@ type UserInputResponse = Awaited<ReturnType<UserInputHandler>>; type ElicitationHandler = NonNullable<SessionConfig['onElicitationRequest']>; type ElicitationContext = Parameters<ElicitationHandler>[0]; type ElicitationResult = Awaited<ReturnType<ElicitationHandler>>; +type McpAuthHandler = NonNullable<SessionConfig['onMcpAuthRequest']>; +type McpAuthRequest = Parameters<McpAuthHandler>[0]; +type McpAuthContext = Parameters<McpAuthHandler>[1]; +type McpAuthResponse = Awaited<ReturnType<McpAuthHandler>>; type SessionHooks = NonNullable<SessionConfig['hooks']>; type PreToolUseHookInput = Parameters<NonNullable<SessionHooks['onPreToolUse']>>[0]; type PostToolUseHookInput = Parameters<NonNullable<SessionHooks['onPostToolUse']>>[0]; @@ -90,6 +98,7 @@ export interface ICopilotSessionRuntime { handleExitPlanModeRequest(request: ExitPlanModeRequest, invocation: { sessionId: string }): Promise<ExitPlanModeResult>; handleUserInputRequest(request: UserInputRequest, invocation: UserInputInvocation): Promise<UserInputResponse>; handleElicitationRequest(context: ElicitationContext): Promise<ElicitationResult>; + handleMcpAuthRequest(request: McpAuthRequest, context: McpAuthContext): Promise<McpAuthResponse>; requestUnsandboxedCommandConfirmation(request: IUnsandboxedCommandConfirmationRequest): Promise<boolean>; handlePreToolUse(input: PreToolUseHookInput): Promise<void>; handlePostToolUse(input: PostToolUseHookInput): Promise<void>; @@ -125,6 +134,14 @@ interface ICopilotSessionLaunchBase { readonly activeClientToolSet: ActiveClientToolSet; readonly shellManager: ShellManager | undefined; readonly githubToken: string | undefined; + + /** + * Whether this is a workspace-less session. Threaded into the + * prompt context so the resolved system message gets the scratch/repoless + * variant. Named to match the `workspaceless` marker used throughout the AH + * layer (session `_meta`, stored metadata) that this value flows from. + */ + readonly workspaceless?: boolean; } export interface ICopilotCreateSessionLaunchPlan extends ICopilotSessionLaunchBase { @@ -198,11 +215,43 @@ function shouldCreateEmptySessionAfterResumeError(err: unknown): boolean { return !/\b(corrupt|corrupted|invalid|validation|schema|must be|parse|malformed|unexpected token)\b/i.test(message); } -export function getCopilotReasoningEffort(model: ModelSelection | undefined): SessionConfig['reasoningEffort'] { +function isCustomAgentNotFoundError(err: unknown): boolean { + return getCopilotSdkErrorCode(err) === -32603 && /\bCustom agent '.+' not found\b/i.test(getErrorMessage(err)); +} + +/** + * Resolves the reasoning effort: a recognized override level wins over the + * model picker's thinking level; an unrecognized override is ignored (degrades + * to the picker). Validation is against the known effort levels only — the + * caller/operator is responsible for choosing a level the model supports. + */ +export function getCopilotReasoningEffort(model: ModelSelection | undefined, effortOverride?: string): SessionConfig['reasoningEffort'] { + if (isReasoningEffort(effortOverride)) { + return effortOverride; + } const thinkingLevel = model?.config?.[ThinkingLevelConfigKey]; return isReasoningEffort(thinkingLevel) ? thinkingLevel : undefined; } +/** + * Resolves the reasoning effort, applying the host-level override and logging + * whether it applied. Shared by the launcher (create) and + * `CopilotAgent._changeModel` (mid-session model change) for consistency. + */ +export function resolveCopilotReasoningEffort(model: ModelSelection | undefined, configurationService: IAgentConfigurationService, logService: ILogService, sessionId: string): SessionConfig['reasoningEffort'] { + const rawOverride = configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ReasoningEffortOverride); + // '' is the schema's unset marker, so an unset override reads as `undefined`. + const override = rawOverride ? rawOverride : undefined; + if (override !== undefined) { + if (isReasoningEffort(override)) { + logService.info(`[Copilot:${sessionId}] Applying reasoning-effort override '${override}'`); + } else { + logService.warn(`[Copilot:${sessionId}] Ignoring invalid reasoning-effort override '${override}'; expected one of [${ReasoningEfforts.join(', ')}]`); + } + } + return getCopilotReasoningEffort(model, override); +} + export function getCopilotContextTier(model: ModelSelection | undefined, longContextWindow?: number, freeLongContext?: boolean): SessionConfig['contextTier'] { // Legacy persisted selections stored the resolved tier string directly under the deprecated key. const legacyTier = model?.config?.[ContextTierConfigKey]; @@ -228,13 +277,105 @@ export function getCopilotContextTier(model: ModelSelection | undefined, longCon return selectedWindow >= longContextWindow ? 'long_context' : 'default'; } +/** + * Resolve the BYOK provider/model session config for `sessionId` from the + * renderer's active bridge. Returns empty — the session launches without BYOK + * models — when BYOK is gated off (no active bridge), when the renderer reports + * no BYOK models, or when enumeration fails; `startProxy` is invoked only once + * at least one model is present. + * + * Each vendor maps to one `type: 'openai'` / `wireApi: 'completions'` provider + * whose `baseUrl` points at the proxy and authenticates with the session-scoped + * `Bearer <nonce>.<sessionId>`; each model is surfaced under the + * provider-qualified selection id `vendor/id`, matching what the renderer's + * `AgentHostByokLmHandler` resolves. + * + * Extracted from {@link CopilotSessionLauncher} so the synthesis and gating are + * unit-testable without instantiating the launcher; the launcher passes a + * `startProxy` thunk that memoizes the single shared proxy handle. + */ +export async function resolveByokSessionConfig( + sessionId: string, + bridgeRegistry: IByokLmBridgeRegistry, + startProxy: () => Promise<IByokLmProxyHandle>, + logService: ILogService, +): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + // Surface the serving window's BYOK models. The registry tracks every + // connected renderer but does not union their model sets — a window's BYOK + // models come from its installed extensions, so all serving windows expose + // the same set and the registry picks one serving window (see + // `IByokLmBridgeRegistry`). Inbound inference is routed to that same serving + // connection by the proxy (`getServingConnection`). + let byokModels: IByokLmModelInfo[]; + try { + byokModels = await bridgeRegistry.listModels(); + } catch (err) { + logService.warn(`[Copilot:${sessionId}] Failed to enumerate BYOK models from renderer bridges`, err); + return {}; + } + if (byokModels.length === 0) { + return {}; + } + // Deduplicate by selection id (`vendor/id`). The same BYOK model can be + // reported more than once — e.g. when two renderer bridges are transiently + // serving during a window hand-off (continuing a chat into a new session) — + // and the runtime rejects a session config with duplicate BYOK model + // selection ids ("Duplicate BYOK model selection id ..."). + const seenSelectionIds = new Set<string>(); + byokModels = byokModels.filter(m => { + const selectionId = `${m.vendor}/${m.id}`; + if (seenSelectionIds.has(selectionId)) { + return false; + } + seenSelectionIds.add(selectionId); + return true; + }); + // `startProxy` binds a local loopback listener — unlikely to fail, but it + // must never break session materialization (which fires the cross-window + // `sessionAdded` broadcast). Degrade to no BYOK config on failure. + let handle: IByokLmProxyHandle; + try { + handle = await startProxy(); + } catch (err) { + logService.warn(`[Copilot:${sessionId}] Failed to start BYOK loopback proxy`, err); + return {}; + } + const providers: NamedProviderConfig[] = [...new Set(byokModels.map(m => m.vendor))].map(vendor => ({ + name: vendor, + type: 'openai', + wireApi: 'completions', + baseUrl: handle.providerBaseUrl(vendor), + bearerToken: `${handle.nonce}.${sessionId}`, + })); + const models: ProviderModelConfig[] = byokModels.map(m => ({ + id: m.id, + provider: m.vendor, + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.maxContextWindowTokens !== undefined ? { maxContextWindowTokens: m.maxContextWindowTokens } : {}), + })); + logService.info(`[Copilot:${sessionId}] Wired ${models.length} BYOK model(s) across ${providers.length} provider(s) via loopback proxy ${handle.baseUrl}`); + return { providers, models }; +} + export class CopilotSessionLauncher implements ICopilotSessionLauncher { + /** + * Memoized handle for the single shared BYOK loopback proxy, started lazily + * on the first session launch that surfaces BYOK models (see + * {@link _resolveByokSessionConfig}). Held as a promise so concurrent + * launches share one bind. Released and cleared by + * {@link disposeByokProxyHandle} when the owning Copilot client/runtime is + * stopped, so the next start mints a fresh nonce. + */ + private _byokProxyHandle: Promise<IByokLmProxyHandle> | undefined; + constructor( @IAgentConfigurationService private readonly _configurationService: IAgentConfigurationService, @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, @ILogService private readonly _logService: ILogService, @IFileService private readonly _fileService: IFileService, + @IByokLmProxyService private readonly _byokLmProxyService: IByokLmProxyService, + @IByokLmBridgeRegistry private readonly _byokLmBridgeRegistry: IByokLmBridgeRegistry, ) { } async launch(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise<CopilotSessionWrapper> { @@ -244,35 +385,48 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { return this._createSession(plan, config, sandboxConfig); } + let fallbackPlan = plan; + let fallbackConfig = config; try { - this._logService.info(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); - const raw = await plan.client.resumeSession(plan.sessionId, { - ...config, - workingDirectory: plan.workingDirectory.fsPath, - ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), - }); - this._logService.info(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded`); + const stopWatch = new StopWatch(); + this._logService.trace(`[Copilot:${plan.sessionId}] Calling SDK resumeSession...`); + const raw = await plan.client.resumeSession(plan.sessionId, config); + this._logService.trace(`[Copilot:${plan.sessionId}] SDK resumeSession succeeded after ${stopWatch.elapsed()}ms`); await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); return new CopilotSessionWrapper(raw); } catch (err) { - const errCode = getCopilotSdkErrorCode(err); - const errMsg = getErrorMessage(err); + let resumeError = err; + const errCode = getCopilotSdkErrorCode(resumeError); + const errMsg = getErrorMessage(resumeError); this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession failed: code=${errCode}, message=${errMsg}`); + if (plan.resolvedAgentName && isCustomAgentNotFoundError(resumeError)) { + fallbackPlan = { ...plan, resolvedAgentName: undefined }; + fallbackConfig = { ...config, agent: undefined }; + this._logService.warn(`[Copilot:${plan.sessionId}] Stored custom agent '${plan.resolvedAgentName}' was not found; retrying resume without a custom agent`); + try { + const raw = await fallbackPlan.client.resumeSession(fallbackPlan.sessionId, fallbackConfig); + await this._applySandboxConfig(raw, sandboxConfig, plan.sessionId); + return new CopilotSessionWrapper(raw); + } catch (retryErr) { + resumeError = retryErr; + this._logService.warn(`[Copilot:${plan.sessionId}] SDK resumeSession without custom agent failed: code=${getCopilotSdkErrorCode(retryErr)}, message=${getErrorMessage(retryErr)}`); + } + } // The SDK fails to resume sessions that have no messages. // Fall back to creating a new session with the same ID, // seeding model & working directory from stored metadata. - if (!shouldCreateEmptySessionAfterResumeError(err)) { - throw err; + if (!shouldCreateEmptySessionAfterResumeError(resumeError)) { + throw resumeError; } this._logService.warn(`[Copilot:${plan.sessionId}] Resume failed (code=-32603), falling back to createSession with same ID`); const wrapper = await this._createSession({ - ...plan, + ...fallbackPlan, kind: 'create', - model: plan.fallback.model, - longContextWindow: plan.fallback.longContextWindow, - freeLongContext: plan.fallback.freeLongContext, - }, config, sandboxConfig); + model: fallbackPlan.fallback.model, + longContextWindow: fallbackPlan.fallback.longContextWindow, + freeLongContext: fallbackPlan.fallback.freeLongContext, + }, fallbackConfig, sandboxConfig); this._logService.info(`[Copilot:${plan.sessionId}] Fallback createSession succeeded`); return wrapper; } @@ -284,7 +438,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { sessionId: plan.sessionId, streaming: true, model: plan.model?.id, - reasoningEffort: getCopilotReasoningEffort(plan.model), + reasoningEffort: resolveCopilotReasoningEffort(plan.model, this._configurationService, this._logService, plan.sessionId), contextTier: getCopilotContextTier(plan.model, plan.longContextWindow, plan.freeLongContext), ...(plan.resolvedAgentName ? { agent: plan.resolvedAgentName } : {}), workingDirectory: plan.workingDirectory?.fsPath, @@ -297,7 +451,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { * Compute the SDK-shaped sandbox policy to push to the runtime for the * SDK's built-in shell tool. * - * Returns `undefined` when {@link AgentHostConfigKey.EnableCustomTerminalTool} + * Returns `undefined` when {@link CopilotCliConfigKey.EnableCustomTerminalTool} * is ON — in that case the AgentHost provides its own shell tools, which * wrap commands via the host terminal sandbox engine, so no SDK-side * sandbox policy is needed. Otherwise the policy is derived from the @@ -306,7 +460,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { * `buildSandboxConfigForCLI` does for the Copilot extension's CLI path. */ private _computeSandboxConfig(): ISdkSandboxConfig | undefined { - const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; + const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; if (enableCustomTerminalTool) { return undefined; } @@ -335,9 +489,51 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { } } + /** + * Launcher-bound wrapper over {@link resolveByokSessionConfig}: supplies the + * active bridge registry and a `startProxy` thunk that memoizes the single + * shared proxy handle for this launcher (started lazily on first use). + */ + private _resolveByokSessionConfig(sessionId: string): Promise<{ providers?: NamedProviderConfig[]; models?: ProviderModelConfig[] }> { + return resolveByokSessionConfig(sessionId, this._byokLmBridgeRegistry, () => { + if (!this._byokProxyHandle) { + this._byokProxyHandle = this._byokLmProxyService.start(); + } + return this._byokProxyHandle; + }, this._logService); + } + + /** + * Release the memoized BYOK loopback proxy handle (if any) and clear it so + * the next session launch mints a fresh nonce. Idempotent. + * + * **Ownership invariant.** The caller MUST stop the Copilot client/runtime + * subprocess before invoking this: disposing the handle drops the proxy's + * refcount and may rebind it on a different port/nonce, so a still-running + * subprocess would silently lose its endpoint — see {@link IByokLmProxyHandle}. + * Invoked from `CopilotAgent._stopClient` / `CopilotAgent.shutdown` after the + * client has stopped. + */ + async disposeByokProxyHandle(): Promise<void> { + const handle = this._byokProxyHandle; + this._byokProxyHandle = undefined; + if (!handle) { + return; + } + try { + (await handle).dispose(); + } catch { + // The lazy `start()` rejected; there is nothing to release. + } + } + private async _buildSessionConfig(plan: CopilotSessionLaunchPlan, runtime: ICopilotSessionRuntime): Promise<CopilotSessionLaunchConfig> { const plugins = plan.snapshot.plugins; - const enableCustomTerminalTool = this._configurationService.getRootValue(agentHostCustomizationConfigSchema, AgentHostConfigKey.EnableCustomTerminalTool) === true; + // Synthesize BYOK provider/model config (empty when BYOK is gated off or the + // renderer reports no BYOK models). Merged into the returned config so both + // createSession and resumeSession advertise the models to the runtime. + const byok = await this._resolveByokSessionConfig(plan.sessionId); + const enableCustomTerminalTool = this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.EnableCustomTerminalTool) === true; let shellTools: Awaited<ReturnType<typeof createShellTools>> = []; if (enableCustomTerminalTool) { if (!plan.shellManager) { @@ -358,13 +554,20 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { // agent sees them under; used to gate tool-specific prompt sections. const clientToolNames = clientToolNamesFromSnapshot(plan.snapshot); const promptContext: IAgentHostPromptContext = { - getSetting: key => this._configurationService.getRootValue(agentHostCustomizationConfigSchema, key), + getSetting: key => this._configurationService.getRootValue(copilotCliConfigSchema, key), hasClientTool: name => clientToolNames.has(name), + workspaceless: plan.workspaceless === true, }; + // Prompt routing uses the family-aliased selection; the wire model id in + // _createSession comes from plan.model and is unaffected. + const effectiveModel = applyModelFamilyAlias(model, this._configurationService.getRootValue(copilotCliConfigSchema, CopilotCliConfigKey.ModelCapabilityOverrides)); + if (model && effectiveModel !== model) { + this._logService.info(`[Copilot:${plan.sessionId}] Model capability override: routing prompt for '${model.id}' as family '${effectiveModel?.id}'`); + } // Resolved once per (re)launch — the SDK has no mid-session system-message // update, so this reflects the model/tools/settings at launch time. Log a // summary at info for prompt observability; the full config at trace. - const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(model, promptContext); + const systemMessage = agentHostPromptRegistry.resolveSystemMessageConfig(effectiveModel, promptContext); this._logService.info(`[Copilot:${plan.sessionId}] Resolved system message: ${describeSystemMessageConfig(systemMessage)}`); if (this._logService.getLevel() <= LogLevel.Trace) { // Guarded: a `replace`-mode prompt's content can be multiple KB, so only @@ -372,11 +575,15 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { this._logService.trace(`[Copilot:${plan.sessionId}] System message config: ${JSON.stringify(systemMessage, (_key, value) => typeof value === 'function' ? '[transform fn]' : value)}`); } return { + ...byok, clientName: 'vscode', enableMcpApps: true, + enableFileHooks: true, + enableConfigDiscovery: true, onPermissionRequest: request => runtime.handlePermissionRequest(request), onUserInputRequest: (request, invocation) => runtime.handleUserInputRequest(request, invocation), onElicitationRequest: context => runtime.handleElicitationRequest(context), + onMcpAuthRequest: (request, context) => runtime.handleMcpAuthRequest(request, context), hooks: toSdkHooks(pluginsWithoutDirs.flatMap(p => p.hooks), { onPreToolUse: input => runtime.handlePreToolUse(input), onPostToolUse: input => runtime.handlePostToolUse(input), @@ -385,6 +592,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher { onExitPlanModeRequest: (request, invocation) => runtime.handleExitPlanModeRequest(request, invocation), workingDirectory: plan.workingDirectory?.fsPath, customAgents, + agent: plan.resolvedAgentName, skillDirectories, instructionDirectories, systemMessage, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts index a9240fb2470574..c89f7f23c2109e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSessionWrapper.ts @@ -53,6 +53,11 @@ export class CopilotSessionWrapper extends Disposable { return this._onToolComplete ??= this._sdkEvent('tool.execution_complete'); } + private _onPermissionRequested: Event<SessionEventPayload<'permission.requested'>> | undefined; + get onPermissionRequested(): Event<SessionEventPayload<'permission.requested'>> { + return this._onPermissionRequested ??= this._sdkEvent('permission.requested'); + } + private _onIdle: Event<SessionEventPayload<'session.idle'>> | undefined; get onIdle(): Event<SessionEventPayload<'session.idle'>> { return this._onIdle ??= this._sdkEvent('session.idle'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index d0db4dea996a79..9b409c4aa251c0 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -6,8 +6,6 @@ import type { Tool, ToolResultObject } from '@github/copilot-sdk'; import { generateUuid } from '../../../../base/common/uuid.js'; import { URI } from '../../../../base/common/uri.js'; -import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; -import * as platform from '../../../../base/common/platform.js'; import { Disposable, DisposableStore, type IReference, toDisposable } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -22,22 +20,18 @@ import { isZsh } from '../agentHostShellUtils.js'; import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; import { createAgentHostSandboxEngine } from './agentHostSandboxEngine.js'; import { IAgentConfigurationService } from '../agentConfigurationService.js'; +import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, isMultilineCommand, prefixForHistorySuppression, prepareOutputForModel, shellTypeForExecutable, type IShellCommandResult, type ShellType } from '../shared/shellCommandExecution.js'; -/** - * Maximum scrollback content (in bytes) returned to the model in tool results. - */ -const MAX_OUTPUT_BYTES = 80_000; +// Re-exported for consumers (and tests) that historically imported these +// shell helpers from this module. Their canonical home is the shared, +// agent-agnostic shellCommandExecution module. +export { isMultilineCommand, prefixForHistorySuppression, shellTypeForExecutable }; +export type { ShellType }; /** - * Default command timeout in milliseconds (120 seconds). + * Message returned to the model when a command switches to the terminal's + * alternate buffer (typically an interactive full-screen UI). */ -const DEFAULT_TIMEOUT_MS = 120_000; - -/** - * The sentinel prefix used to detect command completion in terminal output. - * The full sentinel format is: `<<<COPILOT_SENTINEL_<uuid>_EXIT_<code>>>`. - */ -const SENTINEL_PREFIX = '<<<COPILOT_SENTINEL_'; const ALT_BUFFER_MESSAGE = 'The command opened the alternate buffer and is still running in the terminal. It likely launched an interactive terminal UI. Use write_bash/write_powershell to interact with it, or shutdown the shell to stop it.'; /** @@ -50,48 +44,6 @@ interface IManagedShell { readonly executable: string; } -export type ShellType = 'bash' | 'powershell'; - -/** - * Routes a resolved shell executable to one of the Copilot SDK's two - * built-in shell tools (`bash` / `powershell`). Falls back to the platform - * default for unknown shells. - */ -export function shellTypeForExecutable(shellPath: string): ShellType { - // Strip path on either separator and the .exe suffix. - const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); - const base = shellPath.slice(lastSep + 1).toLowerCase().replace(/\.exe$/, ''); - switch (base) { - // PowerShell - case 'pwsh': - case 'powershell': - case 'pwsh-preview': - return 'powershell'; - // POSIX shells - case 'bash': - case 'sh': - case 'zsh': - case 'fish': - case 'csh': - case 'ksh': - case 'nu': - case 'xonsh': - // Git for Windows bash entry points - case 'git-cmd': - // WSL launchers — bash inside, but invoked via these stubs - case 'wsl': - case 'ubuntu': - case 'ubuntu1804': - case 'kali': - case 'debian': - case 'opensuse-42': - case 'sles-12': - return 'bash'; - default: - return platform.isWindows ? 'powershell' : 'bash'; - } -} - // --------------------------------------------------------------------------- // ShellManager // --------------------------------------------------------------------------- @@ -322,69 +274,6 @@ export class ShellManager extends Disposable { } } -// --------------------------------------------------------------------------- -// Sentinel helpers -// --------------------------------------------------------------------------- - -function makeSentinelId(): string { - return generateUuid().replace(/-/g, ''); -} - -function buildSentinelCommand(sentinelId: string, shellType: ShellType): string { - if (shellType === 'powershell') { - return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`; - } - return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; -} - -/** - * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` / - * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from - * being recorded in shell history. The shell integration scripts opt these - * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the - * terminal is created with `preventShellHistory: true`). PowerShell - * suppresses history through PSReadLine instead, so no prefix is needed. - */ -export function prefixForHistorySuppression(shellType: ShellType): string { - return shellType === 'powershell' ? '' : ' '; -} - -export function isMultilineCommand(command: string): boolean { - const normalized = command.replace(/\r\n|\r/g, '\n'); - return /(?<!\\)\n/.test(normalized); -} - -function shouldUseBracketedPasteMode(command: string): boolean { - return platform.isMacintosh || isMultilineCommand(command); -} - -function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { - const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`; - const idx = content.indexOf(marker); - if (idx === -1) { - return { found: false, exitCode: -1, outputBeforeSentinel: content }; - } - - const outputBeforeSentinel = content.substring(0, idx); - const afterMarker = content.substring(idx + marker.length); - const endIdx = afterMarker.indexOf('>>>'); - const exitCodeStr = endIdx >= 0 ? afterMarker.substring(0, endIdx) : afterMarker.trim(); - const exitCode = parseInt(exitCodeStr, 10); - return { - found: true, - exitCode: isNaN(exitCode) ? -1 : exitCode, - outputBeforeSentinel, - }; -} - -function prepareOutputForModel(rawOutput: string): string { - let text = removeAnsiEscapeCodes(rawOutput).trim(); - if (text.length > MAX_OUTPUT_BYTES) { - text = text.substring(text.length - MAX_OUTPUT_BYTES); - } - return text; -} - // --------------------------------------------------------------------------- // Tool implementations // --------------------------------------------------------------------------- @@ -406,25 +295,33 @@ function makeExecutionResult(toolResult: ToolResultObject, options?: { keepShell return { toolResult, keepShellBusy: options?.keepShellBusy }; } -function makeBackgroundExecutionResult(text: string): IShellExecutionResult { - return makeExecutionResult(makeSuccessResult(text), { keepShellBusy: true }); -} - -function makeAltBufferExecutionResult(): IShellExecutionResult { - return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); -} - -function registerAltBufferHandler( - shell: IManagedShell, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, - disposables: DisposableStore, - finish: (result: IShellExecutionResult) => void, -): void { - void terminalManager.createAltBufferPromise(shell.terminalUri, disposables).then(() => { - logService.info('[ShellTool] Command entered alternate buffer'); - finish(makeAltBufferExecutionResult()); - }); +/** + * Maps the neutral {@link IShellCommandResult} produced by the shared shell + * executor to the Copilot SDK {@link ToolResultObject} shape expected by the + * shell tools. + */ +function shellCommandResultToExecutionResult(result: IShellCommandResult, timeoutMs: number): IShellExecutionResult { + switch (result.status) { + case 'completed': { + const exitCode = result.exitCode ?? 0; + const text = `Exit code: ${exitCode}\n${result.output}`; + return makeExecutionResult(exitCode === 0 ? makeSuccessResult(text) : makeFailureResult(text)); + } + case 'shellExited': + return makeExecutionResult(makeFailureResult(`Shell exited with code ${result.exitCode}\n${result.output}`)); + case 'timeout': + return makeExecutionResult(makeFailureResult( + `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${result.output}`, + 'timeout', + )); + case 'background': + return makeExecutionResult( + makeSuccessResult('The user chose to continue this command in the background. The terminal is still running.'), + { keepShellBusy: true }, + ); + case 'altBuffer': + return makeExecutionResult(makeFailureResult(ALT_BUFFER_MESSAGE, 'alternateBuffer'), { keepShellBusy: true }); + } } async function executeCommandInShell( @@ -434,9 +331,10 @@ async function executeCommandInShell( terminalManager: IAgentHostTerminalManager, logService: ILogService, ): Promise<IShellExecutionResult> { - const result = terminalManager.supportsCommandDetection(shell.terminalUri) - ? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService) - : await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); + const result = shellCommandResultToExecutionResult( + await executeShellCommand(shell, command, timeoutMs, terminalManager, logService), + timeoutMs, + ); return { ...result, toolResult: { @@ -446,182 +344,6 @@ async function executeCommandInShell( }; } -/** - * Execute a command using shell integration (OSC 633) for completion detection. - * No sentinel echo is injected — the shell's own command-finished signal - * provides the exit code and cleanly delineated output. - */ -async function executeCommandWithShellIntegration( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise<IShellExecutionResult> { - const disposables = new DisposableStore(); - - const result = new Promise<IShellExecutionResult>(resolve => { - let resolved = false; - const finish = (result: IShellExecutionResult) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - disposables.add(terminalManager.onCommandFinished(shell.terminalUri, event => { - const output = prepareOutputForModel(event.output); - const exitCode = event.exitCode ?? 0; - logService.info(`[ShellTool] Command completed (shell integration) with exit code ${exitCode}`); - if (exitCode === 0) { - finish(makeExecutionResult(makeSuccessResult(`Exit code: ${exitCode}\n${output}`))); - } else { - finish(makeExecutionResult(makeFailureResult(`Exit code: ${exitCode}\n${output}`))); - } - })); - - registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const output = prepareOutputForModel(fullContent); - finish(makeExecutionResult(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - ))); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - - }); - - try { - await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { - shouldExecute: true, - bracketedPasteMode: shouldUseBracketedPasteMode(command), - }); - } catch (err) { - disposables.dispose(); - throw err; - } - - return result; -} - -/** - * Fallback: execute a command using a sentinel echo to detect completion. - * Used when shell integration is not available. - */ -async function executeCommandWithSentinel( - shell: IManagedShell, - command: string, - timeoutMs: number, - terminalManager: IAgentHostTerminalManager, - logService: ILogService, -): Promise<IShellExecutionResult> { - const sentinelId = makeSentinelId(); - const sentinelCmd = buildSentinelCommand(sentinelId, shell.shellType); - const disposables = new DisposableStore(); - - const contentBefore = terminalManager.getContent(shell.terminalUri) ?? ''; - const offsetBefore = contentBefore.length; - - const result = new Promise<IShellExecutionResult>(resolve => { - let resolved = false; - const finish = (result: IShellExecutionResult) => { - if (resolved) { - return; - } - resolved = true; - disposables.dispose(); - resolve(result); - }; - - const checkForSentinel = () => { - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - // Clamp offset: the terminal manager trims content when it exceeds - // 100k chars (slices to last 80k). If trimming happened after we - // captured offsetBefore, scan from the start of the current buffer. - const clampedOffset = Math.min(offsetBefore, fullContent.length); - const newContent = fullContent.substring(clampedOffset); - const parsed = parseSentinel(newContent, sentinelId); - if (parsed.found) { - const output = prepareOutputForModel(parsed.outputBeforeSentinel); - logService.info(`[ShellTool] Command completed with exit code ${parsed.exitCode}`); - if (parsed.exitCode === 0) { - finish(makeExecutionResult(makeSuccessResult(`Exit code: ${parsed.exitCode}\n${output}`))); - } else { - finish(makeExecutionResult(makeFailureResult(`Exit code: ${parsed.exitCode}\n${output}`))); - } - } - }; - - disposables.add(terminalManager.onData(shell.terminalUri, () => { - checkForSentinel(); - })); - - registerAltBufferHandler(shell, terminalManager, logService, disposables, finish); - - disposables.add(terminalManager.onExit(shell.terminalUri, (exitCode: number) => { - logService.info(`[ShellTool] Shell exited unexpectedly with code ${exitCode}`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeExecutionResult(makeFailureResult(`Shell exited with code ${exitCode}\n${output}`))); - })); - - disposables.add(terminalManager.onClaimChanged(shell.terminalUri, (claim) => { - if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { - logService.info(`[ShellTool] Continuing in background (claim narrowed)`); - finish(makeBackgroundExecutionResult('The user chose to continue this command in the background. The terminal is still running.')); - } - })); - - const timer = setTimeout(() => { - logService.warn(`[ShellTool] Command timed out after ${timeoutMs}ms`); - const fullContent = terminalManager.getContent(shell.terminalUri) ?? ''; - const newContent = fullContent.substring(offsetBefore); - const output = prepareOutputForModel(newContent); - finish(makeExecutionResult(makeFailureResult( - `Command timed out after ${Math.round(timeoutMs / 1000)}s. Partial output:\n${output}`, - 'timeout', - ))); - }, timeoutMs); - disposables.add(toDisposable(() => clearTimeout(timer))); - - checkForSentinel(); - }); - - try { - await terminalManager.sendText(shell.terminalUri, `${prefixForHistorySuppression(shell.shellType)}${command}`, { - shouldExecute: true, - bracketedPasteMode: shouldUseBracketedPasteMode(command), - }); - await terminalManager.sendText(shell.terminalUri, sentinelCmd, { shouldExecute: true }); - } catch (err) { - disposables.dispose(); - throw err; - } - - return result; -} - // --------------------------------------------------------------------------- // Public factory // --------------------------------------------------------------------------- @@ -699,7 +421,7 @@ export async function createShellTools( }, overridesBuiltInTool: true, handler: async (args, invocation) => { - const timeoutMs = args.timeout ?? DEFAULT_TIMEOUT_MS; + const timeoutMs = args.timeout ?? DEFAULT_SHELL_COMMAND_TIMEOUT_MS; const ref = await shellManager.getOrCreateShell( shellType, invocation.toolCallId, diff --git a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts index 095360116ed289..e0ca22079f371a 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSlashCommandCompletionProvider.ts @@ -6,41 +6,17 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { AgentSession } from '../../common/agentService.js'; import { CompletionItem, CompletionItemKind, CompletionsParams } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; +import { Customization, CustomizationType, DirectoryCustomization, MessageAttachmentKind, PluginCustomization, SkillCustomization } from '../../common/state/protocol/state.js'; import { toCommandCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CompletionTriggerCharacter, IAgentHostCompletionItemProvider } from '../agentHostCompletions.js'; -import { extractLeadingSlashToken } from '../agentHostSlashCompletion.js'; -import { localize } from '../../../../nls.js'; +import { extractLeadingSlashToken, extractWhitespaceDelimitedSlashToken } from '../agentHostSlashCompletion.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; +import type { CopilotSession } from '@github/copilot-sdk'; -const HIDDEN_RUNTIME_COMMANDS = new Set<string>(['agent', 'app', 'changelog', 'context', 'copy', 'cwd', 'exit', 'extensions', 'feedback', 'help', 'ide', 'instructions', 'login', 'logout', 'mcp', 'model', 'new', 'plugin', 'rename', 'restart', 'resume', 'sandbox', 'session', 'settings', 'skills', 'statusline', 'streamer-mode', 'subagents', 'tasks', 'terminal-setup', 'theme', 'undo', 'update', 'user', 'voice', 'worktree', 'autopilot', 'yolo']); +const HIDDEN_RUNTIME_COMMANDS = new Set<string>(['agent', 'app', 'changelog', 'context', 'copy', 'exit', 'extensions', 'feedback', 'help', 'ide', 'instructions', 'login', 'logout', 'mcp', 'model', 'new', 'plugin', 'rename', 'restart', 'resume', 'sandbox', 'session', 'settings', 'skills', 'statusline', 'streamer-mode', 'subagents', 'tasks', 'terminal-setup', 'theme', 'undo', 'update', 'user', 'voice', 'worktree', 'autopilot', 'yolo']); export const DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS = 300; -const CommandOptionDescriptions: Record<string, string> = { - 'chronicle:cost-tips': localize('copilot.command.chronicle.cost.tips', "Get personalized tips to reduce token usage and Copilot cost"), - 'chronicle:improve': localize('copilot.command.chronicle.improve', "Get personalized tips to improve your chat session usage"), - 'chronicle:reindex': localize('copilot.command.chronicle.reindex', "Rebuild the local session index and sync to cloud"), - 'chronicle:search': localize('copilot.command.chronicle.search', "Search recent chat sessions by keyword, file path, or PR/issue ref"), - 'chronicle:standup': localize('copilot.command.chronicle.standup', "Generate a standup report from recent chat sessions"), - 'chronicle:tips': localize('copilot.command.chronicle.tips', "Get personalized tips based on your chat session usage patterns"), -}; - -// Some hints like `prompt` or `directory` are not useful to show in the completion list, so we ignore them. -// They are not useful as completion items. -const CommandOptionsToIgnore = new Set([ - 'add-dir:directory', - 'after:<delay> <prompt>', - 'compact:focus instructions', - 'directory', - 'every:<interval> <prompt>', - 'fleet:prompt', - 'loop:<interval> <prompt>', - 'plan:prompt', - 'research:topic', - 'review:additional instructions', - 'security-review:additional instructions' -]); - /** * Lookup hooks used by {@link CopilotSlashCommandCompletionProvider} to * retrieve runtime slash command metadata and apply feature gating. @@ -53,6 +29,7 @@ export interface ICopilotSlashCommandSessionInfo { isRubberDuckEnabled?(): boolean; /** Runtime slash commands discovered from the SDK session. */ getRuntimeSlashCommands?(sessionId: string, options?: ICopilotRuntimeSlashCommandQueryOptions): Promise<readonly ICopilotRuntimeSlashCommandInfo[]>; + getSessionCustomizations: (session: string) => Promise<readonly Customization[]>; } export interface ICopilotRuntimeSlashCommandQueryOptions { @@ -107,7 +84,7 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti constructor( private readonly copilotcliId: string, - private readonly _sessionInfo?: ICopilotSlashCommandSessionInfo, + private readonly _sessionInfo: ICopilotSlashCommandSessionInfo, private readonly _runtimeSlashCommandCompletionWaitMs: number = DEFAULT_RUNTIME_SLASH_COMMAND_COMPLETION_WAIT_MS, ) { } @@ -115,7 +92,10 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti if (AgentSession.provider(params.channel) !== this.copilotcliId) { return []; } - const leading = extractLeadingSlashToken(params.text, params.offset); + const leadingTokenForSkills = extractWhitespaceDelimitedSlashToken(params.text, params.offset); + const leadingTokenForCommands = extractLeadingSlashToken(params.text, params.offset); + const leading = leadingTokenForCommands ?? leadingTokenForSkills; + const returnJustSkills = !leadingTokenForCommands && !!leadingTokenForSkills; if (!leading) { return []; } @@ -124,11 +104,39 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti const sessionId = AgentSession.id(params.channel); // `/abc` → typed = 'abc'; empty after just '/' → typed = ''. const typed = leading.typed; - return await this._getRuntimeSlashCommandCompletionInfo(sessionId, typed, leading); + return await this._getRuntimeSlashCommandCompletionInfo(sessionId, typed, leading, returnJustSkills); + } + + private async _getKnownSkills(sessionId: string) { + const knownCommands = new Set<string>(); + const customizations = await this._sessionInfo.getSessionCustomizations(sessionId) ?? []; + for (const c of customizations) { + if (c.type === CustomizationType.McpServer || !c.enabled || !c.children) { + continue; + } + for (const child of c.children) { + if (child.type === CustomizationType.Skill) { + knownCommands.add(this._toSlashCommandCandidate(c, child)); + } + } + } + return knownCommands; + } + + private _toSlashCommandCandidate(container: PluginCustomization | DirectoryCustomization, skill: SkillCustomization): string { + // see getCanonicalPluginCommandId + let slashCommandName = skill.name; + if (container.type === CustomizationType.Plugin && !isSyncedCustomization(container) && skill.name !== container.name) { + slashCommandName = `${container.name}:${skill.name}`; + } + return slashCommandName; } - private async _getRuntimeSlashCommandCompletionInfo(sessionId: string, typed: string, { rangeStart, rangeEnd }: { rangeStart: number; rangeEnd: number }): Promise<CompletionItem[]> { - const runtimeCommands = await this._sessionInfo?.getRuntimeSlashCommands?.(sessionId, { maxWaitMs: this._runtimeSlashCommandCompletionWaitMs }) ?? []; + private async _getRuntimeSlashCommandCompletionInfo(sessionId: string, typed: string, { rangeStart, rangeEnd }: { rangeStart: number; rangeEnd: number }, returnJustSkills: boolean): Promise<CompletionItem[]> { + const [runtimeCommands, knownSkills] = await Promise.all([ + this._sessionInfo.getRuntimeSlashCommands?.(sessionId, { maxWaitMs: this._runtimeSlashCommandCompletionWaitMs }) ?? [], + this._getKnownSkills(sessionId) + ]); const typedLower = typed.toLowerCase(); const rubberDuckEnabled = this._sessionInfo?.isRubberDuckEnabled?.() ?? true; const completionItems: CompletionItem[] = []; @@ -138,8 +146,11 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti if (!command.name) { continue; } - if (command.kind === 'skill') { - // we have a separate completion provider for skills. + if (returnJustSkills && command.kind !== 'skill') { + continue; + } + if (command.kind === 'skill' && knownSkills.has(command.name)) { + // This is a known skill, so we don't want to show it in the runtime command completion list. continue; } if (HIDDEN_RUNTIME_COMMANDS.has(command.name) || command.aliases?.some(alias => HIDDEN_RUNTIME_COMMANDS.has(alias))) { @@ -151,15 +162,17 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti if (typed.length > 0 && !command.name.toLowerCase().startsWith(typedLower) && !command.aliases?.some(alias => alias.toLowerCase().startsWith(typedLower))) { continue; } - // Hints contain sub commands like [on|off] or `on|off` - // First remove the brackets and then split by pipe to get the options - const options = (command.input?.hint ?? '').replace(/[\[\]]/g, '').split('|'); - if (options.length && !command.input?.required) { - // If we have options but they are optional, - // then make sure we add an empty option so that the user can select just the command without any options. - options.unshift(''); - } + // Use structured input choices as options; if there are none, emit a single item for the command and surface any free-text hint as a prompt. + const options: (NonNullable<NonNullable<ICopilotRuntimeSlashCommandInfo['input']>['choices']>[number] & { argumentHint?: string })[] = []; + // If we have a hint, then this means we have a structured command with sub commands or options. + // I.e. the standalone command is also valie. + if (command.input?.hint || !command.input?.choices?.length) { + options.push({ name: '', description: command.description, argumentHint: command.input?.hint }); + } + if (command.input?.choices?.length) { + options.push(...command.input.choices); + } // Generate completion items for each alias and option combination. // If there are no options, generate a single completion item for the alias. @@ -167,15 +180,13 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti aliases .filter(alias => !addedAliases.has(alias)) .forEach(alias => { - (Array.from(new Set(options.length ? options : ['']))) - .filter(option => !CommandOptionsToIgnore.has(`${command.name}:${option}`)) + options .forEach(option => { // Add a trailing space after the command (and sub command/option if present). // This is so user can continue to type additional arguments after the command and option. - const insertText = `/${alias}${option ? ' ' + option : ''} `; - const optionDescription = option ? CommandOptionDescriptions[`${command.name}:${option}`] : command.description; - const description = optionDescription ?? command.description; - + const insertText = `/${alias}${option.name ? ' ' + option.name : ''} `; + const description = option.description ?? command.description; + const argumentHint = option.argumentHint; addedAliases.add(alias); completionItems.push({ @@ -187,7 +198,8 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti label: insertText, _meta: toCommandCompletionAttachmentMeta({ command: command.name, - ...(description !== undefined ? { description } : {}) + ...(description !== undefined ? { description } : {}), + ...(argumentHint !== undefined ? { argumentHint } : {}) }), }, }); @@ -199,17 +211,8 @@ export class CopilotSlashCommandCompletionProvider implements IAgentHostCompleti } } -export interface ICopilotRuntimeSlashCommandInfo { - readonly name: string; - readonly aliases?: readonly string[]; - readonly description: string; - readonly kind: 'builtin' | 'skill' | 'client'; - readonly input?: { - readonly hint: string; - readonly required?: boolean; - readonly preserveMultilineInput?: boolean; - }; - readonly allowDuringAgentExecution: boolean; - readonly experimental?: boolean; - readonly schedulable?: boolean; +export type ICopilotRuntimeSlashCommandInfo = Awaited<ReturnType<CopilotSession['rpc']['commands']['list']>>['commands'][number]; + +function isSyncedCustomization(container: PluginCustomization): boolean { + return container.uri.startsWith(SYNCED_CUSTOMIZATION_SCHEME + ':'); } diff --git a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts index c6cf007143d9cd..43adbcb4782b63 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotSystemNotification.ts @@ -8,8 +8,6 @@ import { softAssertNever } from '../../../../base/common/assert.js'; import { localize } from '../../../../nls.js'; export interface ICopilotSystemNotification { - /** Body shown inside an active turn; cleaned from SDK `system.notification.data.content`. */ - readonly content: string; /** Text for a new system-origin AHP turn; derived from SDK `data.kind` metadata, e.g. shell completion `description`. */ readonly messageText: string; /** Whether the runtime notification wakes the agent loop when it arrives while idle. */ @@ -30,7 +28,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste const description = kind.description; const shellId = kind.shellId; return { - content, messageText: description ? localize('agentHost.copilot.systemNotification.shellDescriptionCompleted', "`{0}` completed", description) : shellId @@ -41,7 +38,6 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste } case 'agent_completed': return { - content, messageText: kind.status === 'failed' ? localize('agentHost.copilot.systemNotification.agentFailed', "Background agent {0} failed", kind.agentId) : localize('agentHost.copilot.systemNotification.agentCompleted', "Background agent {0} completed", kind.agentId), @@ -49,19 +45,16 @@ export function buildCopilotSystemNotification(event: SessionEventPayload<'syste }; case 'agent_idle': return { - content, - messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is idle", kind.agentId), + messageText: localize('agentHost.copilot.systemNotification.agentIdle', "Background agent {0} is complete", kind.agentId), startsTurn: true, }; case 'new_inbox_message': return { - content, messageText: localize('agentHost.copilot.systemNotification.newInboxMessage', "New inbox message from {0}", kind.senderName), startsTurn: false, }; case 'instruction_discovered': return { - content, messageText: localize('agentHost.copilot.systemNotification.instructionDiscovered', "Instruction discovered: {0}", kind.description ?? kind.sourcePath), startsTurn: false, }; diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index fbc6cdb19e487c..c43d590a84d2ef 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -193,6 +193,26 @@ interface ICopilotWebFetchToolArgs { url: string; } +/** + * Parameters shared by the agent-coordination tools (`read_agent`, + * `write_agent`). The Copilot CLI identifies the target agent by its + * human-readable `agent_id` (e.g. `math-helper`). + */ +interface ICopilotAgentToolArgs { + agent_id?: string; +} + +/** + * Reads a well-formed `agent_id` from untrusted tool parameters. Since these are + * parsed from JSON they may not match the expected shape, so the id is returned + * only when it is a non-empty string and is therefore safe to render as inline + * markdown code. + */ +function getAgentId(parameters: Record<string, unknown> | undefined): string | undefined { + const agentId = (parameters as ICopilotAgentToolArgs | undefined)?.agent_id; + return typeof agentId === 'string' && agentId.length > 0 ? agentId : undefined; +} + /** * Parameters for the `apply_patch` / `git_apply_patch` tools. The patch text * itself lives in `input` using the V4A diff format (file headers like @@ -380,6 +400,18 @@ export function isHiddenTool(toolName: string): boolean { return HIDDEN_TOOL_NAMES.has(toolName); } +/** + * Returns true for the auto-approved agent-coordination tools (list/read/write + * agents). These are client-contributed tools that never go through the + * permission flow, so the agent host auto-readies them at start to surface a + * tailored invocation message instead of the generic fallback. + */ +export function isAgentCoordinationTool(toolName: string): boolean { + return toolName === CopilotToolName.ListAgents + || toolName === CopilotToolName.ReadAgent + || toolName === CopilotToolName.WriteAgent; +} + /** * Returns true when the tool is Copilot's internal Autopilot completion signal. */ @@ -441,6 +473,20 @@ export function isShellTool(toolName: string): boolean { return SHELL_TOOL_NAMES.has(toolName); } +/** + * Extracts the intention for a shell tool call from its `description` + * argument. The Copilot shell tools (`bash`/`powershell`) carry a short + * human-readable description of what the command does, which matches the + * model's intention summary. Non-shell tools have no such argument, so this + * returns `undefined` for them. + */ +export function getShellIntention(toolName: string, parameters: Record<string, unknown> | undefined): string | undefined { + if (isShellTool(toolName) && typeof parameters?.description === 'string' && parameters.description.length > 0) { + return parameters.description; + } + return undefined; +} + // ============================================================================= // Display helpers // @@ -644,8 +690,17 @@ export function getInvocationMessage(toolName: string, displayName: string, para } case CopilotToolName.ExitPlanMode: return localize('toolInvoke.exitPlanMode', "Presenting plan"); + case CopilotToolName.Task: + return localize('toolInvoke.task', "Delegating task"); + // The agent-coordination tools (list/read/write agents) are fast, so + // they use a single message for both the running and completed states: + // the past-tense phrasing. See getPastTenseMessage. + case CopilotToolName.ListAgents: + case CopilotToolName.ReadAgent: + case CopilotToolName.WriteAgent: + return getPastTenseMessage(toolName, displayName, parameters, true); default: - return localize('toolInvoke.generic', "Using \"{0}\"", displayName); + return displayName; } } @@ -759,8 +814,26 @@ export function getPastTenseMessage(toolName: string, displayName: string, param } case CopilotToolName.ExitPlanMode: return localize('toolComplete.exitPlanMode', "Exited plan mode"); + case CopilotToolName.Task: + return localize('toolComplete.task', "Delegated task"); + case CopilotToolName.ListAgents: + return localize('toolComplete.listAgents', "Listed agents"); + case CopilotToolName.ReadAgent: { + const agentId = getAgentId(parameters); + if (agentId) { + return md(localize('toolComplete.readAgent', "Read agent {0}", appendEscapedMarkdownInlineCode(agentId))); + } + return localize('toolComplete.readAgentGeneric', "Read agent"); + } + case CopilotToolName.WriteAgent: { + const agentId = getAgentId(parameters); + if (agentId) { + return md(localize('toolComplete.writeAgent', "Wrote to agent {0}", appendEscapedMarkdownInlineCode(agentId))); + } + return localize('toolComplete.writeAgentGeneric', "Wrote to agent"); + } default: - return localize('toolComplete.generic', "Used \"{0}\"", displayName); + return displayName; } } diff --git a/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts b/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts new file mode 100644 index 00000000000000..e60d036501aacb --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/diskSessionFsProvider.ts @@ -0,0 +1,110 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { promises as fs } from 'fs'; +import type { SessionFsFileInfo, SessionFsProvider } from '@github/copilot-sdk'; +import { dirname, join } from '../../../../base/common/path.js'; + +/** + * Directory entry shape returned by {@link SessionFsProvider.readdirWithTypes}. + * Mirrors the SDK's `SessionFsReaddirWithTypesEntry`, which is not re-exported + * from the package root. + */ +type SessionFsReaddirWithTypesEntry = { name: string; type: 'file' | 'directory' }; + +/** + * A {@link SessionFsProvider} that transparently maps the Copilot runtime's + * session-filesystem operations onto a real directory on disk (the + * {@link _baseDir}). Every path the SDK supplies is sandboxed under the base + * directory so the runtime's session-scoped state (chiefly `events.jsonl`, + * plus workspace metadata, checkpoints, etc.) lives in one place we control. + * + * This is the seam used to *import* a translated conversation: the caller + * pre-writes a synthesized `events.jsonl` under the base directory at the + * runtime's `sessionStatePath`, then resumes the session — the SDK reads that + * log through this provider and reconstitutes editable turns. + * + * Paths from the runtime may be absolute (per the configured path conventions); + * they are treated as relative to {@link _baseDir} so nothing escapes it. + */ +export class DiskSessionFsProvider implements SessionFsProvider { + + constructor(private readonly _baseDir: string) { } + + /** Maps a runtime-supplied SessionFs path onto a real path under the base dir. */ + private _resolve(sessionFsPath: string): string { + // Normalize separators and strip any leading root (`/`, `\`, or a drive + // like `C:`) so the path is always resolved *within* the base directory. + const normalized = sessionFsPath.replace(/\\/g, '/').replace(/^[a-zA-Z]:/, '').replace(/^\/+/, ''); + // Defensively reject parent-traversal segments so a runtime-supplied path + // can never resolve outside the base directory. + if (normalized.split('/').some(segment => segment === '..')) { + throw new Error(`Invalid SessionFs path '${sessionFsPath}'`); + } + return join(this._baseDir, normalized); + } + + async readFile(path: string): Promise<string> { + return fs.readFile(this._resolve(path), 'utf8'); + } + + async writeFile(path: string, content: string, mode?: number): Promise<void> { + const target = this._resolve(path); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.writeFile(target, content, mode !== undefined ? { mode } : undefined); + } + + async appendFile(path: string, content: string, mode?: number): Promise<void> { + const target = this._resolve(path); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.appendFile(target, content, mode !== undefined ? { mode } : undefined); + } + + async exists(path: string): Promise<boolean> { + try { + await fs.stat(this._resolve(path)); + return true; + } catch { + return false; + } + } + + async stat(path: string): Promise<SessionFsFileInfo> { + const stats = await fs.stat(this._resolve(path)); + return { + isFile: stats.isFile(), + isDirectory: stats.isDirectory(), + size: stats.size, + mtime: stats.mtime.toISOString(), + birthtime: stats.birthtime.toISOString(), + }; + } + + async mkdir(path: string, recursive: boolean, mode?: number): Promise<void> { + await fs.mkdir(this._resolve(path), { recursive, ...(mode !== undefined ? { mode } : {}) }); + } + + async readdir(path: string): Promise<string[]> { + return fs.readdir(this._resolve(path)); + } + + async readdirWithTypes(path: string): Promise<SessionFsReaddirWithTypesEntry[]> { + const entries = await fs.readdir(this._resolve(path), { withFileTypes: true }); + return entries.map(entry => ({ + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file', + } satisfies SessionFsReaddirWithTypesEntry)); + } + + async rm(path: string, recursive: boolean, force: boolean): Promise<void> { + await fs.rm(this._resolve(path), { recursive, force }); + } + + async rename(src: string, dest: string): Promise<void> { + const target = this._resolve(dest); + await fs.mkdir(dirname(target), { recursive: true }); + await fs.rename(this._resolve(src), target); + } +} diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index bbe8e3359da83c..66f16937394f00 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteData } from '@github/copilot-sdk'; +import type { AssistantMessageToolRequest, Attachment, SessionEvent, ToolExecutionCompleteContent, ToolExecutionCompleteData } from '@github/copilot-sdk'; import { decodeBase64 } from '../../../../base/common/buffer.js'; import { basename } from '../../../../base/common/path.js'; import { isString } from '../../../../base/common/types.js'; @@ -14,9 +14,10 @@ import { toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { MessageAttachmentKind, type MessageAttachment } from '../../common/state/protocol/state.js'; import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildSubagentSessionUri, type AgentSelection, type Message, type ModelSelection, type ResponsePart, type StringOrMarkdown, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../common/state/sessionState.js'; -import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getShellIntention, getShellLanguage, getSubagentMetadata, getTaskCompleteMarkdown, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool, isTaskCompleteTool, synthesizeSkillToolCall } from './copilotToolDisplay.js'; import { buildSessionDbUri } from '../shared/fileEditTracker.js'; import { getMediaMime } from '../../../../base/common/mime.js'; +import { buildCopilotSystemNotification } from './copilotSystemNotification.js'; function tryStringify(value: unknown): string | undefined { try { @@ -42,6 +43,22 @@ function isSyntheticUserMessage(event: SessionEvent): boolean { return !!source && source.toLowerCase() !== 'user'; } +export function appendSdkToolResultContent(content: ToolResultContent[], sdkContents: readonly ToolExecutionCompleteContent[] | undefined): void { + for (const sdkContent of sdkContents ?? []) { + switch (sdkContent.type) { + case 'shell_exit': + content.push({ + type: ToolResultContentType.TerminalComplete, + exitCode: sdkContent.exitCode, + ...(sdkContent.cwd !== undefined ? { cwd: URI.file(sdkContent.cwd).toString() } : {}), + ...(sdkContent.outputPreview !== undefined ? { preview: sdkContent.outputPreview } : {}), + ...(sdkContent.outputTruncated !== undefined ? { truncated: sdkContent.outputTruncated } : {}), + }); + break; + } + } +} + // ============================================================================= // Single-pass turn builder // ============================================================================= @@ -54,6 +71,8 @@ interface IToolStartInfo { readonly toolInput?: string; readonly toolKind?: 'terminal' | 'subagent' | 'search'; readonly language?: string; + /** Intention (why the command runs) for shell tools, from their `description` argument. */ + readonly intention?: string; readonly subagentAgentName?: string; readonly subagentDescription?: string; readonly parameters: Record<string, unknown> | undefined; @@ -86,10 +105,10 @@ export interface IMapSessionEventsOptions { readonly agent?: AgentSelection; } -function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection }): ITurnBuilder { +function newTurnBuilder(id: string, text: string, options?: { attachments?: MessageAttachment[]; model?: ModelSelection; agent?: AgentSelection; origin?: MessageKind }): ITurnBuilder { const message: Message = { text, - origin: { kind: MessageKind.User }, + origin: { kind: options?.origin ?? MessageKind.User }, ...(options?.attachments?.length ? { attachments: options.attachments } : {}), ...(options?.model ? { model: options.model } : {}), ...(options?.agent ? { agent: options.agent } : {}), @@ -121,6 +140,7 @@ function makeToolStartInfo(toolName: string, rawArguments: unknown, parentToolCa toolInput: getToolInputString(toolName, parameters, toolArgs), toolKind, language: toolKind === 'terminal' ? getShellLanguage(toolName) : undefined, + intention: getShellIntention(toolName, parameters), subagentAgentName: subagentMeta?.agentName, subagentDescription: subagentMeta?.description, parameters, @@ -241,6 +261,7 @@ export async function mapSessionEvents( let parentBuilder: ITurnBuilder | undefined; let parentTurnState = TurnState.Cancelled; let parentTurnAborted = false; + let rootAssistantTurnActive = false; const flushParent = (): void => { if (!parentBuilder) { @@ -290,6 +311,16 @@ export async function mapSessionEvents( for (const e of events) { switch (e.type) { + case 'assistant.turn_start': + if (!e.agentId) { + rootAssistantTurnActive = true; + } + break; + case 'assistant.turn_end': + if (!e.agentId) { + rootAssistantTurnActive = false; + } + break; case 'session.model_change': { currentModel = { id: e.data.newModel }; break; @@ -382,6 +413,22 @@ export async function mapSessionEvents( } break; } + case 'system.notification': { + const notification = buildCopilotSystemNotification(e); + if (!notification) { + break; + } + if (rootAssistantTurnActive && parentBuilder) { + parentBuilder.responseParts.push({ + kind: ResponsePartKind.SystemNotification, + content: notification.messageText, + }); + } else if (notification.startsTurn) { + flushParent(); + parentBuilder = newTurnBuilder(e.id, notification.messageText, { origin: MessageKind.SystemNotification }); + } + break; + } case 'subagent.started': { const d = e.data; subagentInfoByToolCallId.set(d.toolCallId, { @@ -466,9 +513,12 @@ export async function mapSessionEvents( const parentToolCallId = resolveParentToolCallId(e.agentId, undefined); if (parentToolCallId) { subagentTurnStates.set(parentToolCallId, TurnState.Cancelled); - } else if (parentBuilder) { - parentTurnState = TurnState.Cancelled; - parentTurnAborted = true; + } else { + rootAssistantTurnActive = false; + if (parentBuilder) { + parentTurnState = TurnState.Cancelled; + parentTurnAborted = true; + } } break; } @@ -619,6 +669,7 @@ function makeCompletedToolCallPart( if (toolOutput !== undefined) { content.push({ type: ToolResultContentType.Text, text: toolOutput }); } + appendSdkToolResultContent(content, d.result?.contents); // Restore file edit content references from the database. const edits = storedEdits?.get(d.toolCallId); @@ -662,6 +713,7 @@ function makeCompletedToolCallPart( toolCallId: d.toolCallId, toolName: info.toolName, displayName: info.displayName, + intention: info.intention, invocationMessage: info.invocationMessage, toolInput: info.toolInput, success: d.success, diff --git a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md index d503c7922a5cde..bee8132900dc80 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md +++ b/src/vs/platform/agentHost/node/copilot/prompts/AGENTS.md @@ -94,7 +94,7 @@ class MyModelPrompt implements IAgentHostPrompt { static readonly familyPrefixes = ['my-model']; // or implement static matchesModel(model) resolveSectionOverrides(model: ModelSelection, context: IAgentHostPromptContext) { // Gate on host settings; return undefined to fall back to the default message. - return context.getSetting(AgentHostConfigKey.SomeFlag) === true + return context.getSetting(CopilotCliConfigKey.SomeFlag) === true ? { tool_instructions: { action: 'append', content: '\nFor this model, batch independent tool calls.' } } : undefined; } diff --git a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts b/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts index befa934827c758..9308a0f5813e57 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/anthropicPrompt.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import type { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; -import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey } from '../../../common/copilotCliConfig.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; import { agentHostPromptRegistry, type IAgentHostPrompt, type IAgentHostPromptContext } from './promptRegistry.js'; import { COPILOT_AGENT_HOST_IDENTITY } from './systemMessage.js'; @@ -47,23 +47,15 @@ function opus48SectionOverrides(): Partial<Record<SystemMessageSection, SectionO }; } -/** - * Returns whether `model` is a Claude Opus 4.8 model. Mirrors the Copilot - * extension's version-specific `isOpus47`, matching both the SDK dashed id - * (`claude-opus-4-8`) and the CAPI dotted id (`claude-opus-4.8`). - */ +/** Whether `model` is Claude Opus 4.8 — matches the SDK dashed id and the CAPI dotted id. */ function isOpus48(model: ModelSelection): boolean { return model.id.startsWith('claude-opus-4-8') || model.id.startsWith('claude-opus-4.8'); } /** - * Resolves the Opus 4.8 agent prompt for Claude Opus 4.8 Copilot SDK sessions. - * - * Mirrors the Copilot extension's version-specific opus resolver - * (`Claude47OpusPrompt`, gated by `isOpus47` + a setting): it matches only Opus - * 4.8 via {@link isOpus48}, and is opt-in via - * {@link AgentHostConfigKey.Opus48Prompt}. When the setting is off it returns - * `undefined` and the registry falls back to the default system message. + * Opus 4.8 agent prompt for Claude Opus 4.8 sessions: matches only Opus 4.8 and + * is opt-in via {@link CopilotCliConfigKey.Opus48Prompt}. Off → falls back to the + * default system message. */ class Claude48OpusPromptResolver implements IAgentHostPrompt { static readonly familyPrefixes: readonly string[] = []; @@ -73,7 +65,7 @@ class Claude48OpusPromptResolver implements IAgentHostPrompt { } resolveSectionOverrides(_model: ModelSelection, context: IAgentHostPromptContext): Partial<Record<SystemMessageSection, SectionOverride>> | undefined { - return context.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? opus48SectionOverrides() : undefined; + return context.getSetting(CopilotCliConfigKey.Opus48Prompt) === true ? opus48SectionOverrides() : undefined; } } diff --git a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts index 321a8c052b3754..97d27edc8a6c00 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/promptRegistry.ts @@ -4,30 +4,30 @@ *--------------------------------------------------------------------------------------------*/ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; -import { agentHostCustomizationConfigSchema } from '../../../common/agentHostCustomizationConfig.js'; +import { copilotCliConfigSchema } from '../../../common/copilotCliConfig.js'; import type { SchemaValue } from '../../../common/agentHostSchema.js'; import type { ModelSelection } from '../../../common/state/protocol/state.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; +import { appendSystemMessageContent, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE, fullSystemPrompt, sectionOverrides } from './systemMessage.js'; import { resolveToolInstructionsOverride } from './toolInstructions.js'; -type CustomizationConfigDefinition = typeof agentHostCustomizationConfigSchema.definition; +type CopilotCliConfigDefinition = typeof copilotCliConfigSchema.definition; /** * Read-time context handed to prompt contributors so they can gate behavior on * host configuration — the agent-host equivalent of the Copilot extension * injecting `IConfigurationService` into a resolver. * - * Scoped to the host customization schema so contributors (and tests) read + * Scoped to the Copilot CLI config schema so contributors (and tests) read * settings in a fully-typed way without depending on the whole configuration * service. */ export interface IAgentHostPromptContext { /** - * Returns the host-level value for a customization setting, or `undefined` + * Returns the host-level value for a Copilot CLI setting, or `undefined` * when unset. Mirrors `IAgentConfigurationService.getRootValue` bound to - * {@link agentHostCustomizationConfigSchema}. + * {@link copilotCliConfigSchema}. */ - getSetting<K extends keyof CustomizationConfigDefinition & string>(key: K): SchemaValue<CustomizationConfigDefinition[K]> | undefined; + getSetting<K extends keyof CopilotCliConfigDefinition & string>(key: K): SchemaValue<CopilotCliConfigDefinition[K]> | undefined; /** * Returns whether a *client* tool is available in the session, addressed by @@ -43,6 +43,15 @@ export interface IAgentHostPromptContext { * is the context-enrichment follow-up. */ hasClientTool(name: string): boolean; + + /** + * Whether this is a workspace-less session. When `true`, the + * resolved system message gets a scratch/repoless section (see + * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) telling the agent its + * working directory is a scratch dir, not a code repo. Set by the launcher + * from the session's `workspaceless` marker. + */ + workspaceless: boolean; } /** @@ -138,7 +147,9 @@ export class AgentHostPromptRegistry { * turn keeps the prompt it launched with. */ resolveSystemMessageConfig(model: ModelSelection | undefined, context: IAgentHostPromptContext): SystemMessageConfig { - return this._withUniversalSections(this._resolveModelConfig(model, context), context); + const config = this._withUniversalSections(this._resolveModelConfig(model, context), context); + const withWorkspacelessScratch = this._withWorkspacelessScratch(config, context); + return appendSystemMessageContent(withWorkspacelessScratch, COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS); } /** @@ -200,6 +211,24 @@ export class AgentHostPromptRegistry { } return { ...config, sections: { ...config.sections, tool_instructions: toolInstructions } }; } + + /** + * Appends the scratch/repoless workspace-less guidance (see + * {@link COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}) as customize-mode + * `content` when {@link IAgentHostPromptContext.workspaceless} is set, so it + * composes on top of whatever sections the per-model (or default) config + * carries while keeping the SDK foundation intact. + * + * No-op for workspace-bound sessions and for a full `replace` prompt (which + * owns the entire system message and intentionally drops the SDK foundation). + */ + private _withWorkspacelessScratch(config: SystemMessageConfig, context: IAgentHostPromptContext): SystemMessageConfig { + if (!context.workspaceless || config.mode !== 'customize') { + return config; + } + const content = config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}` : COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS; + return { ...config, content }; + } } /** diff --git a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts index ecd3fe02b673b1..46b974075ff761 100644 --- a/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts +++ b/src/vs/platform/agentHost/node/copilot/prompts/systemMessage.ts @@ -12,6 +12,21 @@ import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from */ export const COPILOT_AGENT_HOST_IDENTITY = 'You are an AI assistant using Copilot CLI runtime in VS Code. You help users with software engineering tasks. When asked about your identity, you must state that you are an AI assistant using Copilot CLI runtime in VS Code.'; +/** Response-formatting contract for workspace links emitted by Agent Host models. */ +export const COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS = [ + '<file_folder_and_symbol_links>', + 'Always use Markdown links when referring to existing files, folders, or symbols in the workspace. This is very important for helping the user understand your responses.', + '- File: use the file name as the link text and the absolute filesystem path as the target, for example [foo.ts](/path/to/foo.ts).', + '- Folder: links to folders are also supported, with an absolute path to the folder as the target, for example [src/](/path/to/src).', + '- Symbol: link to symbols by using the containing file path with a 1-based line number as the target, for example [myMethod](/path/to/foo.ts:42).', + '- Use `/` path separators in link targets, including on Windows (`C:/path/to/foo.ts`).', + '- If a file path has spaces, wrap the target in angle brackets: [foo bar.ts](</path/to/foo bar.ts>).', + '- Use absolute filesystem paths rather than `file://` URIs.', + '- Do not provide line ranges.', + '- Use a markdown link format every time you refer to a file, folder, or symbol, not just the first time.', + '</file_folder_and_symbol_links>', +].join('\n'); + /** * Default system-message customization applied to every Copilot CLI agent-host * session that has no per-model override registered in the @@ -30,6 +45,23 @@ export const COPILOT_AGENT_HOST_SYSTEM_MESSAGE = { }, } satisfies SystemMessageConfig; +/** + * Scratch/repoless guidance appended to a workspace-less chat's system message. + * A workspace-less chat's working directory is a throwaway SCRATCH dir, not a + * code repository — so this tells the agent not to treat it like a project, to + * stay read-only on real repos, and to delegate code changes to a dedicated + * session. Modeled on the GitHub app's `build_general_chat_system_message`. + */ +export const COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS = [ + '<workspaceless_chat>', + 'This is a lightweight workspace-less chat, not tied to any project or workspace. The user opens it for quick questions, navigation, and triage.', + '', + '- Your working directory is a SCRATCH directory for running commands and saving throwaway artifacts — it is NOT a code repository. Do not treat it as a project to build, test, or commit.', + '- If the user points you at a real repository, prefer read-only operations: read files, search code, and inspect git metadata (branch, log, diff, status) to answer questions. Avoid modifying files or running builds, tests, linters, or installs in their working copies.', + '- When the user wants code changes, test runs, or any work that modifies or executes against a real project, delegate it to a dedicated session rather than doing it here.', + '</workspaceless_chat>', +].join('\n'); + /** * Builds a {@link SystemMessageConfig} that fully replaces the CLI/SDK system * prompt with `content`. @@ -50,6 +82,15 @@ export function sectionOverrides(sections: Partial<Record<SystemMessageSection, return { mode: 'customize', sections }; } +/** Appends universal content without changing a full-prompt replacement. */ +export function appendSystemMessageContent(config: SystemMessageConfig, content: string): SystemMessageConfig { + if (config.mode === 'replace') { + return config; + } + const existing = config.content; + return { ...config, content: existing ? `${existing}\n\n${content}` : content }; +} + /** * One-line, log-friendly summary of a resolved {@link SystemMessageConfig} — * the mode plus, for `customize`, which sections are overridden and with what diff --git a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts index 12246665f72a0b..78dbfd7158d707 100644 --- a/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts +++ b/src/vs/platform/agentHost/node/copilot/sandboxConfigForSdk.ts @@ -59,7 +59,7 @@ export interface ISdkSandboxConfig { * opaque `sandboxConfig` shape the Copilot SDK forwards to the runtime * via `session.options.update`. * - * Used when {@link AgentHostConfigKey.EnableCustomTerminalTool} is OFF — the + * Used when {@link CopilotCliConfigKey.EnableCustomTerminalTool} is OFF — the * SDK's built-in shell tool runs the user's commands, so we have to push the * sandbox policy down into the SDK itself. When the custom terminal tool is * ON, the AgentHost's own {@link TerminalSandboxEngine} wraps commands and diff --git a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts index 1c1b2e14508e11..3a72bd50d71fc1 100644 --- a/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/copilot/sessionCustomizationDiscovery.ts @@ -19,6 +19,7 @@ import type { AgentsDiscoverRequest } from './copilotRCP.js'; import { AgentCustomization, ChildCustomization, CustomizationLoadStatus, CustomizationType, DirectoryCustomization, RuleCustomization, SkillCustomization, customizationId } from '../../common/state/sessionState.js'; import { ChildCustomizationType } from '../../common/state/protocol/state.js'; import { toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; +import { raceCancellationError } from '../../../../base/common/async.js'; /** * The kinds of customizations the agent host discovers from disk. @@ -132,21 +133,22 @@ interface IFixedDiscoveryFile { */ const searchRoots: { workspace: ISearchRoot[]; user: ISearchRoot[] } = { workspace: [ - { path: ['.github', 'agents'], type: DiscoveredType.Agent, name: '.github/agents' }, - { path: ['.agents', 'agents'], type: DiscoveredType.Agent, name: '.agents/agents' }, - { path: ['.claude', 'agents'], type: DiscoveredType.Agent, name: '.claude/agents' }, - { path: ['.github', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.github/skills' }, - { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.agents/skills' }, - { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.claude/skills' }, - { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '.github/instructions' }, - { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '.github/hooks' }, + { path: ['.github', 'agents'], type: DiscoveredType.Agent, name: '.github' }, + { path: ['.agents', 'agents'], type: DiscoveredType.Agent, name: '.agents' }, + { path: ['.claude', 'agents'], type: DiscoveredType.Agent, name: '.claude' }, + { path: ['.github', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.github' }, + { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.agents' }, + { path: ['.claude', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '.claude' }, + { path: ['.github', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '.github' }, + { path: ['.github', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '.github' }, ], user: [ - { path: ['.copilot', 'agents'], type: DiscoveredType.Agent, name: '~/.copilot/agents' }, - { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.agents/skills' }, - { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '~/.copilot/instructions' }, - { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '~/.copilot/hooks' }, + { path: ['.copilot', 'agents'], type: DiscoveredType.Agent, name: '~/.copilot' }, + { path: ['.agents', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.agents' }, + { path: ['.copilot', 'skills'], recursive: true, type: DiscoveredType.Skill, name: '~/.copilot' }, + { path: ['.copilot', 'instructions'], recursive: true, type: DiscoveredType.Instruction, name: '~/.copilot' }, + { path: ['.copilot', 'hooks'], recursive: true, type: DiscoveredType.Hook, name: '~/.copilot' }, ], }; @@ -248,38 +250,12 @@ export class SessionCustomizationDiscovery extends Disposable { const p: AgentsDiscoverRequest = { projectPaths: [this._workingDirectory.fsPath] }; try { - const agents: AgentCustomization[] = []; - - const agentDiscovery = await client.rpc.agents.discover(p); - for (const agent of agentDiscovery.agents) { - if (agent.path) { - const uri = URI.file(agent.path); - agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); - } - } - - const rules: RuleCustomization[] = []; - - const instructionDiscovery = await client.rpc.instructions.discover(p); - for (const instruction of instructionDiscovery.sources) { - let uri: URI; - if (isAbsolute(instruction.sourcePath)) { - uri = URI.file(instruction.sourcePath); - } else { - uri = joinPath(this._workingDirectory, instruction.sourcePath); - } - rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); - } - - const skills: SkillCustomization[] = []; - - const skillDiscovery = await client.rpc.skills.discover(p); - for (const skill of skillDiscovery.skills) { - if (skill.path) { - const uri = URI.file(skill.path); - skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); - } - } + const [agents, rules, skills] = await Promise.all([ + this.discoverAgents(p, client, token), + this.discoverRules(p, client, token), + this.discoverSkills(p, client, token) + ]); + throwIfCancelled(token); const result: DirectoryCustomization[] = []; this.toDirectoryCustomizations(CustomizationType.Agent, agents, result); @@ -292,6 +268,48 @@ export class SessionCustomizationDiscovery extends Disposable { } } + private async discoverAgents(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<AgentCustomization[]> { + const agents: AgentCustomization[] = []; + + const agentDiscovery = await raceCancellationError(client.rpc.agents.discover(discoveryRequest), token); + for (const agent of agentDiscovery.agents) { + if (agent.path) { + const uri = URI.file(agent.path); + agents.push({ type: CustomizationType.Agent, uri: uri.toString(), id: agent.id, name: agent.name, description: agent.description, _meta: toAgentCustomizationMeta({ userInvocable: agent.userInvocable }) }); + } + } + return agents; + } + + private async discoverRules(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<RuleCustomization[]> { + const rules: RuleCustomization[] = []; + + const instructionDiscovery = await raceCancellationError(client.rpc.instructions.discover(discoveryRequest), token); + for (const instruction of instructionDiscovery.sources) { + let uri: URI; + if (isAbsolute(instruction.sourcePath)) { + uri = URI.file(instruction.sourcePath); + } else { + uri = joinPath(this._workingDirectory, instruction.sourcePath); + } + rules.push({ type: CustomizationType.Rule, uri: uri.toString(), id: instruction.id, name: instruction.label, description: instruction.description, globs: instruction.applyTo, alwaysApply: false }); + } + return rules; + } + + private async discoverSkills(discoveryRequest: AgentsDiscoverRequest, client: CopilotClient, token: CancellationToken): Promise<SkillCustomization[]> { + const skills: SkillCustomization[] = []; + + const skillDiscovery = await raceCancellationError(client.rpc.skills.discover(discoveryRequest), token); + for (const skill of skillDiscovery.skills) { + if (skill.path) { + const uri = URI.file(skill.path); + skills.push({ type: CustomizationType.Skill, uri: uri.toString(), id: skill.path, name: skill.name, description: skill.description }); + } + } + return skills; + } + private toDirectoryCustomizations(type: ChildCustomizationType, customizations: readonly ChildCustomization[], result: DirectoryCustomization[]): void { const byParent = new ResourceMap<{ readonly uri: URI; readonly children: ChildCustomization[] }>(); for (const customization of customizations) { @@ -426,11 +444,11 @@ export class SessionCustomizationDiscovery extends Disposable { */ private async _scanFixedDiscoveryFiles(base: URI, roots: IFixedDiscoveryFile[], seen: ResourceSet, result: IDiscoveredDirectory[], watchRootUris: ResourceMap<IWatchSpec>, token: CancellationToken): Promise<void> { const filesByType = new Map<DiscoveredType, IDiscoveredFile[]>(); - for (const root of roots) { + await Promise.all(roots.map(async root => { throwIfCancelled(token); if (!await this._watchAncestors(base, root.path, watchRootUris, token)) { - continue; + return; } const rootUri = joinPath(base, ...root.path); @@ -439,10 +457,10 @@ export class SessionCustomizationDiscovery extends Disposable { stat = await this._fileService.resolve(rootUri, { resolveMetadata: true }); } catch { // Root does not exist (or is unreadable) — nothing to discover or watch. - continue; + return; } if (!stat.isDirectory || !stat.children) { - continue; + return; } // Trigger refresh only for the specific filenames this root cares about @@ -463,7 +481,7 @@ export class SessionCustomizationDiscovery extends Disposable { } } } - } + })); for (const [type, files] of filesByType.entries()) { if (files.length > 0) { @@ -492,7 +510,7 @@ export class SessionCustomizationDiscovery extends Disposable { if (root.type === DiscoveredType.Skill) { const files: IDiscoveredFile[] = []; - for (const child of children) { + await Promise.all(children.map(async child => { throwIfCancelled(token); if (child.isDirectory) { @@ -507,7 +525,7 @@ export class SessionCustomizationDiscovery extends Disposable { // SKILL.md missing — skip this skill directory. } } - } + })); result.push({ uri: rootUri, type: root.type, files: files.sort(compareDiscoveredFile), name: root.name, writable: true }); } else if (root.type === DiscoveredType.Agent) { const files: IDiscoveredFile[] = []; diff --git a/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts new file mode 100644 index 00000000000000..14662accbd0823 --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/bangLocalCommand.ts @@ -0,0 +1,158 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; +import { TerminalClaimKind, type TerminalSessionClaim } from '../../common/state/protocol/state.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ToolCallConfirmationReason, ToolResultContentType, type ToolResultContent, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { parseBangCommand } from '../agentHostBangCommand.js'; +import { DEFAULT_SHELL_COMMAND_TIMEOUT_MS, executeShellCommand, shellTypeForExecutable, type IShellCommandResult } from '../shared/shellCommandExecution.js'; +import { ILocalChatCommand, ILocalChatCommandContext, ILocalChatCommandRequest, LocalChatCommandRegistry } from './localChatCommand.js'; + +/** + * The generic `!command` command: runs the message as a terminal command via + * the {@link IAgentHostTerminalManager} shell integration and surfaces it as a + * terminal tool call in the transcript, instead of forwarding it to the agent + * SDK. Runs immediately (the user typed it explicitly — no confirmation). + */ +export class BangLocalCommand extends Disposable implements ILocalChatCommand { + + readonly name = 'bang'; + readonly recordsLocalTurn = true; + + /** Terminals kept alive for transcript output; disposed with this command. */ + private readonly _terminals = new Set<string>(); + + constructor(private readonly _context: ILocalChatCommandContext) { + super(); + this._register(toDisposable(() => { + for (const terminalUri of this._terminals) { + this._context.terminalManager.disposeTerminal(terminalUri); + } + this._terminals.clear(); + })); + } + + tryHandle(request: ILocalChatCommandRequest): (() => Promise<void>) | undefined { + const command = parseBangCommand(request.text); + if (command === undefined) { + return undefined; + } + return () => this._run(request.turnChannel, request.turnId, command); + } + + private async _run(turnChannel: ProtocolURI, turnId: string, command: string): Promise<void> { + const ctx = this._context; + const sessionChannel = isAhpChatChannel(turnChannel) ? parseRequiredSessionUriFromChatUri(turnChannel) : turnChannel; + const toolCallId = generateUuid(); + const terminalUri = `agenthost-terminal://bang/${generateUuid()}`; + const displayName = localize('agentHostBang.terminal', "Terminal"); + let terminalCreated = false; + try { + const workingDirStr = ctx.getState(sessionChannel)?.workingDirectory; + const cwd = workingDirStr ? URI.parse(workingDirStr).fsPath : undefined; + const shellPath = await ctx.terminalManager.getDefaultShell(); + const shellType = shellTypeForExecutable(shellPath); + + // Surface the command as a tool call and transition it straight to + // running — the user typed it explicitly, so no confirmation. + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallStart, + turnId, + toolCallId, + toolName: 'terminal', + displayName, + intention: command, + }); + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallReady, + turnId, + toolCallId, + invocationMessage: command, + toolInput: command, + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const claim: TerminalSessionClaim = { + kind: TerminalClaimKind.Session, + session: sessionChannel, + turnId, + toolCallId, + }; + const params: CreateTerminalParams = { channel: terminalUri, claim, name: displayName, cwd }; + await ctx.terminalManager.createTerminal(params, { shell: shellPath, preventShellHistory: true, nonInteractive: true }); + terminalCreated = true; + this._terminals.add(terminalUri); + + // Reference the terminal so the client can stream live output while + // the command runs. + const terminalContent: ToolResultContent = { type: ToolResultContentType.Terminal, resource: terminalUri, title: displayName }; + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallContentChanged, + turnId, + toolCallId, + content: [terminalContent], + }); + + const result = await executeShellCommand({ terminalUri, shellType }, command, DEFAULT_SHELL_COMMAND_TIMEOUT_MS, ctx.terminalManager, ctx.logService); + const { success, pastTenseMessage } = this._summarizeResult(result); + const content: ToolResultContent[] = [terminalContent]; + if (result.output) { + content.push({ type: ToolResultContentType.Text, text: result.output }); + } + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallComplete, + turnId, + toolCallId, + result: { success, pastTenseMessage, content }, + }); + } catch (err) { + ctx.logService.error(`[BangLocalCommand] Command failed for session=${sessionChannel}: ${err instanceof Error ? err.message : String(err)}`, err); + if (terminalCreated) { + ctx.terminalManager.disposeTerminal(terminalUri); + this._terminals.delete(terminalUri); + } + ctx.dispatch(turnChannel, { + type: ActionType.ChatToolCallComplete, + turnId, + toolCallId, + result: { + success: false, + pastTenseMessage: localize('agentHostBang.failed', "Failed to run command"), + error: { message: err instanceof Error ? err.message : String(err) }, + }, + }); + } + } + + /** + * Maps a shell command result to a success flag and past-tense summary for + * the completed tool call. + */ + private _summarizeResult(result: IShellCommandResult): { success: boolean; pastTenseMessage: string } { + switch (result.status) { + case 'completed': { + const exitCode = result.exitCode ?? 0; + return exitCode === 0 + ? { success: true, pastTenseMessage: localize('agentHostBang.ran', "Ran command") } + : { success: false, pastTenseMessage: localize('agentHostBang.exited', "Command exited with code {0}", exitCode) }; + } + case 'timeout': + return { success: false, pastTenseMessage: localize('agentHostBang.timedOut', "Command timed out") }; + case 'shellExited': + return { success: false, pastTenseMessage: localize('agentHostBang.shellExited', "Shell exited unexpectedly") }; + case 'background': + return { success: true, pastTenseMessage: localize('agentHostBang.background', "Command is running in the background") }; + case 'altBuffer': + return { success: true, pastTenseMessage: localize('agentHostBang.interactive', "Command opened an interactive terminal") }; + } + } +} + +LocalChatCommandRegistry.register(BangLocalCommand); diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts new file mode 100644 index 00000000000000..b4ae872ba44383 --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommand.ts @@ -0,0 +1,228 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../log/common/log.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { ActionType, StateAction } from '../../common/state/sessionActions.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri, ResponsePartKind, ToolCallStatus, ToolResultContentType, type ISessionWithDefaultChat, type Turn, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { AgentHostLocalTurns } from '../agentHostLocalTurns.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; +import { AgentHostStateManager } from '../agentHostStateManager.js'; +import { persistSessionMetadata } from '../shared/persistSessionMetadata.js'; + +/** + * A just-started chat turn offered to the local-command dispatcher before it is + * forwarded to the agent SDK. + */ +export interface ILocalChatCommandRequest { + /** The chat channel the turn was started on (default or peer chat). */ + readonly turnChannel: ProtocolURI; + /** The turn identifier opened by the reducer for this message. */ + readonly turnId: string; + /** The raw user message text. */ + readonly text: string; +} + +/** + * The narrow set of agent-host capabilities a {@link ILocalChatCommand} may use + * to fulfil a request. Keeps commands decoupled from `AgentSideEffects` + * internals — they emit response content by dispatching server actions and read + * conversation state, plus the few extra capabilities specific commands need + * (terminal execution, chat rename/persist). + */ +export interface ILocalChatCommandContext { + readonly logService: ILogService; + readonly terminalManager: IAgentHostTerminalManager; + /** Dispatch a server-originated action on a channel. */ + dispatch(channel: ProtocolURI, action: StateAction): void; + /** Read the merged session/chat state for a session or chat channel. */ + getState(channel: ProtocolURI): ISessionWithDefaultChat | undefined; + /** Rename a single chat (independently of the session title). */ + updateChatTitle(session: ProtocolURI, chat: ProtocolURI, title: string): void; + /** Persist a session-metadata key/value pair (e.g. a custom title). */ + persistSessionFlag(session: ProtocolURI, key: string, value: string): void; +} + +/** + * A generic, agent-agnostic chat command handled entirely by the agent host + * (never forwarded to the agent SDK) — for example `/rename` or `!command`. + * + * A command decides synchronously whether it applies (so the caller knows + * immediately not to forward the message), then performs its work — emitting + * response parts/tool calls via {@link ILocalChatCommandContext}. The + * {@link AgentHostLocalCommands} dispatcher owns the common tail: completing the + * turn, optionally persisting it as a local turn (so it survives reload and + * anchors fork/truncate), and draining the message queue. + */ +export interface ILocalChatCommand extends IDisposable { + /** Stable identifier for logging/telemetry. */ + readonly name: string; + /** + * Whether the completed turn should be persisted as a host-injected local + * turn (survives reload; anchors fork/truncate to the preceding concrete + * turn). Most user-visible commands want `true`. + */ + readonly recordsLocalTurn: boolean; + /** + * Synchronously decide whether this command handles `request`. Returns a + * thunk that performs the (possibly async) work when it does, or `undefined` + * to decline so the dispatcher tries the next command (and ultimately + * forwards the message to the agent). + */ + tryHandle(request: ILocalChatCommandRequest): (() => Promise<void>) | undefined; +} + +/** Constructs a {@link ILocalChatCommand} bound to a context. */ +export interface ILocalChatCommandCtor { + new(context: ILocalChatCommandContext): ILocalChatCommand; +} + +/** + * Global registry of {@link ILocalChatCommand} constructors. Command modules + * register themselves at load time; {@link AgentHostLocalCommands} instantiates + * all registered commands per session-effects instance with its context. + */ +class LocalChatCommandRegistryImpl { + private readonly _ctors: ILocalChatCommandCtor[] = []; + + register(ctor: ILocalChatCommandCtor): void { + this._ctors.push(ctor); + } + + createAll(context: ILocalChatCommandContext): ILocalChatCommand[] { + return this._ctors.map(ctor => new ctor(context)); + } +} + +export const LocalChatCommandRegistry = new LocalChatCommandRegistryImpl(); + +/** + * Dispatches just-started turns to the registered {@link ILocalChatCommand}s + * and owns everything a host-handled command needs end-to-end: it builds the + * {@link ILocalChatCommandContext} from the state manager and injected services, + * runs the first accepting command, then performs the common tail — completing + * the turn, persisting it as a local turn (so it survives reload and anchors + * fork/truncate), and asking the owner to drain the message queue. + */ +export class AgentHostLocalCommands extends Disposable { + + private readonly _commands: readonly ILocalChatCommand[]; + + constructor( + private readonly _stateManager: AgentHostStateManager, + private readonly _localTurns: AgentHostLocalTurns, + /** + * Invoked after a handled turn is completed so the owner can start the + * next queued message. Draining re-enters the agent-send pipeline, which + * is the owner's concern — not the dispatcher's. + */ + private readonly _notifyTurnConsumable: (turnChannel: ProtocolURI) => void, + @ILogService private readonly _logService: ILogService, + @IAgentHostTerminalManager private readonly _terminalManager: IAgentHostTerminalManager, + @ISessionDataService private readonly _sessionDataService: ISessionDataService, + ) { + super(); + const context: ILocalChatCommandContext = { + logService: this._logService, + terminalManager: this._terminalManager, + dispatch: (channel, action) => this._stateManager.dispatchServerAction(channel, action), + getState: channel => this._stateManager.getSessionState(channel), + updateChatTitle: (session, chat, title) => this._stateManager.updateChatTitle(session, chat, title), + persistSessionFlag: (session, key, value) => persistSessionMetadata(this._sessionDataService, this._logService, session, key, value), + }; + this._commands = LocalChatCommandRegistry.createAll(context).map(command => this._register(command)); + } + + /** + * Offers `request` to each command. Returns `true` when one handled it (the + * caller MUST NOT forward the message to the agent), `false` otherwise. + */ + tryHandle(request: ILocalChatCommandRequest): boolean { + for (const command of this._commands) { + const work = command.tryHandle(request); + if (work) { + void this._run(command, work, request); + return true; + } + } + return false; + } + + private async _run(command: ILocalChatCommand, work: () => Promise<void>, request: ILocalChatCommandRequest): Promise<void> { + try { + await work(); + } catch (err) { + this._logService.error(`[AgentHostLocalCommands] Command '${command.name}' failed: ${err instanceof Error ? err.message : String(err)}`, err); + } finally { + // Common tail for every host-handled command: close out the turn the + // reducer opened, optionally persist it as a local turn (so it + // survives reload and anchors fork/truncate), then let the owner + // drain any messages queued behind it. + this._stateManager.dispatchServerAction(request.turnChannel, { type: ActionType.ChatTurnComplete, turnId: request.turnId }); + if (command.recordsLocalTurn) { + this._recordLocalTurn(request.turnChannel, request.turnId); + } + this._notifyTurnConsumable(request.turnChannel); + } + } + + /** + * Records the just-completed turn `turnId` as a host-injected local turn so + * it survives reload and fork/truncate can resolve it to the preceding + * concrete turn. Works uniformly for the default chat and any peer chat — + * the turn is keyed by its chat channel. Live terminal references are + * stripped from the payload (the PTY does not survive a reload). + */ + private _recordLocalTurn(turnChannel: ProtocolURI, turnId: string): void { + const chat = turnChannel; + const session = isAhpChatChannel(turnChannel) ? parseRequiredSessionUriFromChatUri(turnChannel) : turnChannel; + const turns = this._stateManager.getSessionState(turnChannel)?.turns; + if (!turns) { + return; + } + const index = turns.findIndex(t => t.id === turnId); + if (index < 0) { + return; + } + // Anchor = the nearest preceding turn in this chat that is not itself a + // local turn. + let anchorTurnId: string | undefined; + for (let i = index - 1; i >= 0; i--) { + if (!this._localTurns.isLocal(chat, turns[i].id)) { + anchorTurnId = turns[i].id; + break; + } + } + this._localTurns.record(session, chat, sanitizeLocalTurnForPersistence(turns[index]), anchorTurnId); + } +} + +/** + * Prepares a host-injected local turn for persistence by dropping live + * {@link ToolResultContentType.Terminal} references from its tool calls — the + * PTY does not survive a reload, so only the captured output (text) is kept. + */ +function sanitizeLocalTurnForPersistence(turn: Turn): Turn { + const responseParts = turn.responseParts.map(part => { + if (part.kind !== ResponsePartKind.ToolCall) { + return part; + } + const tc = part.toolCall; + // Only these tool-call states carry `content` (a live terminal ref lives here). + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed && tc.status !== ToolCallStatus.PendingResultConfirmation) { + return part; + } + if (!tc.content) { + return part; + } + const content = tc.content.filter(c => c.type !== ToolResultContentType.Terminal); + if (content.length === tc.content.length) { + return part; + } + return { ...part, toolCall: { ...tc, content } }; + }); + return { ...turn, responseParts }; +} diff --git a/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts b/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts new file mode 100644 index 00000000000000..d21f87ee1ec5d5 --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/localChatCommands.contribution.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Importing this module registers all built-in local chat commands with the +// LocalChatCommandRegistry (via each command module's bottom-of-file +// `register(...)` side effect). Import it wherever the registry must be +// populated (e.g. AgentSideEffects) so adding a new command is just a new file +// plus an import here. +import './renameLocalCommand.js'; +import './bangLocalCommand.js'; diff --git a/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.ts new file mode 100644 index 00000000000000..91b156f987ce00 --- /dev/null +++ b/src/vs/platform/agentHost/node/localCommands/renameLocalCommand.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 { Disposable } from '../../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { localize } from '../../../../nls.js'; +import { ActionType } from '../../common/state/sessionActions.js'; +import { isAhpChatChannel, isDefaultChatUri, parseRequiredSessionUriFromChatUri, ResponsePartKind, type URI as ProtocolURI } from '../../common/state/sessionState.js'; +import { parseRenameCommand } from '../agentHostRenameCommand.js'; +import { ILocalChatCommand, ILocalChatCommandContext, ILocalChatCommandRequest, LocalChatCommandRegistry } from './localChatCommand.js'; + +/** + * The generic `/rename [title]` command: renames the session (or an individual + * peer chat) instead of forwarding the message to the agent SDK. Intercepted + * for every agent-host session type. + */ +export class RenameLocalCommand extends Disposable implements ILocalChatCommand { + + readonly name = 'rename'; + readonly recordsLocalTurn = true; + + constructor(private readonly _context: ILocalChatCommandContext) { + super(); + } + + tryHandle(request: ILocalChatCommandRequest): (() => Promise<void>) | undefined { + const title = parseRenameCommand(request.text); + if (title === undefined) { + return undefined; + } + return async () => this._run(request.turnChannel, request.turnId, title); + } + + private _run(channel: ProtocolURI, turnId: string, title: string): void { + if (title.length === 0) { + // `/rename` with no title: nothing to change; the dispatcher still + // completes the turn. + return; + } + const isAdditional = (uri: ProtocolURI): boolean => isAhpChatChannel(uri) && !isDefaultChatUri(uri); + const chatTarget = isAdditional(channel) ? channel : undefined; + const sessionChannel = isAhpChatChannel(channel) ? parseRequiredSessionUriFromChatUri(channel) : channel; + if (chatTarget) { + // Rename only this chat, independently of the session title. + this._context.updateChatTitle(sessionChannel, chatTarget, title); + this._context.persistSessionFlag(sessionChannel, `customChatTitle:${chatTarget}`, title); + } else { + this._context.dispatch(sessionChannel, { type: ActionType.SessionTitleChanged, title }); + // Server-dispatched actions bypass `handleAction`, so persist the + // new title here directly (the client-dispatched rename path relies + // on the `SessionTitleChanged` case in `handleAction` instead). + this._context.persistSessionFlag(sessionChannel, 'customTitle', title); + } + // Acknowledge the rename with a brief response so the turn has visible + // content in the transcript. + this._context.dispatch(channel, { + type: ActionType.ChatResponsePart, + turnId, + part: { + kind: ResponsePartKind.Markdown, + id: generateUuid(), + content: localize('agentHostRename.renamed', "Renamed: {0}", title), + }, + }); + } +} + +LocalChatCommandRegistry.register(RenameLocalCommand); diff --git a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts index a145f36451b5a0..1df8ea128a2dcd 100644 --- a/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts +++ b/src/vs/platform/agentHost/node/nodeAgentHostStarter.ts @@ -13,7 +13,7 @@ import { parseAgentHostDebugPort } from '../../environment/node/environmentServi import { ILogService } from '../../log/common/log.js'; import { getResolvedShellEnv } from '../../shell/node/shellEnv.js'; import { IAgentHostConnection, IAgentHostStarter } from '../common/agent.js'; -import { AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; +import { AgentHostByokModelsEnabledSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostCodexAgentBinaryArgsSettingId, AgentHostCodexAgentEnabledSettingId, AgentHostCodexAgentSdkRootSettingId, AgentHostCodexAgentCodexHomeSettingId, AgentHostOTelCaptureContentSettingId, AgentHostOTelDbSpanExporterEnabledSettingId, AgentHostOTelEnabledSettingId, AgentHostOTelExporterTypeSettingId, AgentHostOTelOtlpEndpointSettingId, AgentHostOTelOtlpProtocolSettingId, AgentHostOTelOutfileSettingId, AgentHostOTelResourceAttributesSettingId, AgentHostOTelServiceNameSettingId, buildAgentHostOTelEnv, buildAgentSdkEnv } from '../common/agentService.js'; import '../common/agentHostStarter.config.contribution.js'; /** @@ -83,6 +83,7 @@ export class NodeAgentHostStarter extends Disposable implements IAgentHostStarte codexBinaryArgs: this._configurationService.getValue<readonly string[]>(AgentHostCodexAgentBinaryArgsSettingId), claudeAgentEnabled: this._configurationService.getValue<boolean>(AgentHostClaudeAgentEnabledSettingId), codexAgentEnabled: this._configurationService.getValue<boolean>(AgentHostCodexAgentEnabledSettingId), + byokModelsEnabled: this._configurationService.getValue<boolean>(AgentHostByokModelsEnabledSettingId), }, process.env); Object.assign(env, sdkEnv); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index fbaf5e59928eaf..7b45ac68a0efd4 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1140,6 +1140,7 @@ export class ProtocolServerHandler extends Disposable { fork, config: params.config, activeClient: params.activeClient, + progressToken: params.progressToken, }); } catch (err) { if (err instanceof ProtocolError) { @@ -1239,26 +1240,18 @@ export class ProtocolServerHandler extends Disposable { return this._agentService.completions(params); }, fetchTurns: async (_client, params) => { - const state = this._stateManager.getSessionState(params.channel); + const state = this._stateManager.getChatState(params.channel); if (!state) { throw new ProtocolError(AHP_SESSION_NOT_FOUND, `Session not found: ${params.channel}`); } - const turns = state.turns; - const limit = Math.min(params.limit ?? 50, 100); - - let endIndex = turns.length; - if (params.before) { - const idx = turns.findIndex(t => t.id === params.before); - if (idx !== -1) { - endIndex = idx; - } + if (params.cursor && params.cursor !== state.turnsNextCursor) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Unrecognized fetchTurns cursor`); } - - const startIndex = Math.max(0, endIndex - limit); - return { - turns: turns.slice(startIndex, endIndex), - hasMore: startIndex > 0, - }; + this._stateManager.dispatchServerAction(params.channel, { + type: ActionType.ChatTurnsLoaded, + turns: [], + }); + return {}; }, resourceList: async (_client, params) => { return this._agentService.resourceList(URI.parse(params.uri)); diff --git a/src/vs/platform/agentHost/node/sessionDataService.ts b/src/vs/platform/agentHost/node/sessionDataService.ts index 56a198cd9fe9d4..6e96324a49a490 100644 --- a/src/vs/platform/agentHost/node/sessionDataService.ts +++ b/src/vs/platform/agentHost/node/sessionDataService.ts @@ -71,7 +71,7 @@ export class SessionDataService implements ISessionDataService { } getSessionDataDir(session: URI): URI { - return this.getSessionDataDirById(AgentSession.id(session)); + return URI.joinPath(this._basePath, this._sanitizedSessionKey(session)); } getSessionDataDirById(sessionId: string): URI { @@ -80,7 +80,21 @@ export class SessionDataService implements ISessionDataService { } private _sanitizedSessionKey(session: URI): string { - return AgentSession.id(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + return this._dataKey(session).replace(/[^a-zA-Z0-9_.-]/g, '-'); + } + + /** + * Derives the per-URI storage key. Chat channel URIs + * (`ahp-chat://<chatId>/<base64(session)>`) carry the chat id in the + * authority while encoding the SAME owning-session URI in the path, so + * keying only by the path (via {@link AgentSession.id}) would collapse + * every peer chat of a session onto one data directory and database. + * Prefixing with the authority gives each chat its own storage while + * leaving plain session URIs (no authority) unchanged. + */ + private _dataKey(uri: URI): string { + const id = AgentSession.id(uri); + return uri.authority ? `${uri.authority}-${id}` : id; } openDatabase(session: URI): IReference<ISessionDatabase> { diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index ccce1b2189ed69..3198c371db8c91 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -6,9 +6,9 @@ import * as fs from 'fs'; import { SequencerByKey } from '../../../base/common/async.js'; import type { Database, RunResult } from '@vscode/sqlite3'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase } from '../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase } from '../common/sessionDataService.js'; import { dirname } from '../../../base/common/path.js'; -import type { URI } from '../../../base/common/uri.js'; +import { URI } from '../../../base/common/uri.js'; import type { Message } from '../common/state/sessionState.js'; /** @@ -94,6 +94,24 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ draft TEXT NOT NULL )`, }, + { + version: 7, + sql: `CREATE TABLE IF NOT EXISTS reviewed_files ( + uri TEXT NOT NULL, + nonce TEXT NOT NULL, + PRIMARY KEY (uri, nonce) + )`, + }, + { + version: 8, + sql: `CREATE TABLE IF NOT EXISTS local_turns ( + turn_id TEXT PRIMARY KEY NOT NULL, + chat_uri TEXT NOT NULL, + anchor_turn_id TEXT, + seq INTEGER NOT NULL, + payload TEXT NOT NULL + )`, + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -410,6 +428,41 @@ export class SessionDatabase implements ISessionDatabase { }); } + // ---- Local (host-injected) turns ------------------------------------ + + insertLocalTurn(record: ILocalTurnRecord): Promise<void> { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, + 'INSERT OR REPLACE INTO local_turns (turn_id, chat_uri, anchor_turn_id, seq, payload) VALUES (?, ?, ?, ?, ?)', + [record.turnId, record.chatUri, record.anchorTurnId ?? null, record.seq, record.payload], + ); + }); + } + + async getLocalTurns(): Promise<ILocalTurnRecord[]> { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT turn_id, chat_uri, anchor_turn_id, seq, payload FROM local_turns ORDER BY seq', []); + return rows.map(r => ({ + turnId: r.turn_id as string, + chatUri: r.chat_uri as string, + anchorTurnId: (r.anchor_turn_id as string | null) ?? undefined, + seq: r.seq as number, + payload: r.payload as string, + })); + } + + deleteLocalTurns(turnIds: readonly string[]): Promise<void> { + return this._track(async () => { + if (turnIds.length === 0) { + return; + } + const db = await this._ensureDb(); + const placeholders = turnIds.map(() => '?').join(','); + await dbRun(db, `DELETE FROM local_turns WHERE turn_id IN (${placeholders})`, [...turnIds]); + }); + } + // ---- File edits ----------------------------------------------------- storeFileEdit(edit: IFileEditRecord & IFileEditContent): Promise<void> { @@ -583,6 +636,40 @@ export class SessionDatabase implements ISessionDatabase { } } + // ---- Reviewed files ------------------------------------------------- + + markFileReviewed(uri: URI, nonce: string): Promise<void> { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'INSERT OR IGNORE INTO reviewed_files (uri, nonce) VALUES (?, ?)', [uri.toString(), nonce]); + }); + } + + unmarkFileReviewed(uri: URI, nonce: string): Promise<void> { + return this._track(async () => { + const db = await this._ensureDb(); + await dbRun(db, 'DELETE FROM reviewed_files WHERE uri = ? AND nonce = ?', [uri.toString(), nonce]); + }); + } + + async getReviewedFiles(): Promise<IReviewedFileRecord[]> { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files ORDER BY rowid', []); + return rows.map(toReviewedFileRecord); + } + + async getReviewedFilesForUri(uri: URI): Promise<IReviewedFileRecord[]> { + const db = await this._ensureDb(); + const rows = await dbAll(db, 'SELECT uri, nonce FROM reviewed_files WHERE uri = ? ORDER BY rowid', [uri.toString()]); + return rows.map(toReviewedFileRecord); + } + + async isFileReviewed(uri: URI, nonce: string): Promise<boolean> { + const db = await this._ensureDb(); + const row = await dbGet(db, 'SELECT 1 FROM reviewed_files WHERE uri = ? AND nonce = ? LIMIT 1', [uri.toString(), nonce]); + return !!row; + } + remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void> { return this._track(async () => { const db = await this._ensureDb(); @@ -608,6 +695,18 @@ export class SessionDatabase implements ISessionDatabase { await dbRun(db, 'UPDATE turns SET id = ? WHERE id = ?', [newId, oldId]); await dbRun(db, 'UPDATE file_edits SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); } + + if (oldIds.length > 0) { + const placeholders = oldIds.map(() => '?').join(','); + await dbRun(db, + `DELETE FROM local_turns WHERE turn_id NOT IN (${placeholders})`, + oldIds, + ); + } + for (const [oldId, newId] of mapping) { + await dbRun(db, 'UPDATE local_turns SET turn_id = ? WHERE turn_id = ?', [newId, oldId]); + await dbRun(db, 'UPDATE local_turns SET anchor_turn_id = ? WHERE anchor_turn_id = ?', [newId, oldId]); + } await dbExec(db, 'COMMIT'); } catch (err) { await dbExec(db, 'ROLLBACK'); @@ -658,6 +757,13 @@ export class SessionDatabase implements ISessionDatabase { } } +function toReviewedFileRecord(row: Record<string, unknown>): IReviewedFileRecord { + return { + uri: URI.parse(row.uri as string), + nonce: row.nonce as string, + }; +} + function toUint8Array(value: unknown): Uint8Array { if (value instanceof Buffer) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); diff --git a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts index 79bd90a6180107..8d7580386a1df4 100644 --- a/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts +++ b/src/vs/platform/agentHost/node/shared/agentFeedbackServerTools.ts @@ -5,7 +5,7 @@ import { generateUuid } from '../../../../base/common/uuid.js'; import { localize } from '../../../../nls.js'; -import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; +import { FEEDBACK_ANNOTATION_META_KEY, readFeedbackAnnotationMeta, VIEW_UNREVIEWED_COMMENTS_TOOL_NAME, ADD_COMMENT_TOOL_NAME, type IFeedbackAnnotationMeta } from '../../common/meta/agentFeedbackAnnotations.js'; import { buildAnnotationsUri } from '../../common/annotationsUri.js'; import type { AnnotationsAction } from '../../common/state/sessionActions.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; @@ -27,7 +27,7 @@ import type { IServerToolDisplay, IServerToolDisplayResult, IServerToolGroup } f * the actions) lives in the caller. */ -export const addCommentToolName = 'addComment'; +export const addCommentToolName = ADD_COMMENT_TOOL_NAME; export const listCommentsToolName = 'listComments'; export const deleteCommentsToolName = 'deleteComments'; export const resolveCommentsToolName = 'resolveComments'; diff --git a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts index abd91b26733aba..a63f2fd258a703 100644 --- a/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts +++ b/src/vs/platform/agentHost/node/shared/agentHostOctoKitService.ts @@ -6,6 +6,7 @@ import { LRUCache } from '../../../../base/common/map.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; export type FetchFunction = typeof globalThis.fetch; @@ -95,7 +96,6 @@ export interface IAgentHostOctoKitService { export const IAgentHostOctoKitService = createDecorator<IAgentHostOctoKitService>('agentHostOctoKitService'); -const GITHUB_API_HOST = 'https://api.github.com'; const GITHUB_API_VERSION = '2022-11-28'; const MAX_ERROR_RESPONSE_BODY_LENGTH = 500; @@ -119,6 +119,7 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { constructor( fetchFn: FetchFunction | undefined, @ILogService private readonly _logService: ILogService, + @IAgentHostGitHubEndpointService private readonly _endpoint: IAgentHostGitHubEndpointService, ) { this._fetch = fetchFn ?? globalThis.fetch; } @@ -197,7 +198,7 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { body?: Record<string, unknown>, etag?: string ): Promise<IGitHubApiResponse<T>> { - const url = `${GITHUB_API_HOST}/${routeSlug}`; + const url = `${this._endpoint.getApiBaseUri()}/${routeSlug}`; const headers: Record<string, string> = { 'Accept': 'application/vnd.github+json', 'Authorization': `Bearer ${token}`, @@ -267,7 +268,7 @@ export class AgentHostOctoKitService implements IAgentHostOctoKitService { token: string, signal: AbortSignal, ): Promise<unknown> { - const url = `${GITHUB_API_HOST}/graphql`; + const url = this._endpoint.getGraphQlUri(); const headers: Record<string, string> = { 'Accept': 'application/json', 'Authorization': `Bearer ${token}`, diff --git a/src/vs/platform/agentHost/node/shared/copilotApiService.ts b/src/vs/platform/agentHost/node/shared/copilotApiService.ts index 667de656e5d520..2c374007d52e69 100644 --- a/src/vs/platform/agentHost/node/shared/copilotApiService.ts +++ b/src/vs/platform/agentHost/node/shared/copilotApiService.ts @@ -8,9 +8,11 @@ import { CAPIClient, RequestType, type CCAModel, type IExtensionInformation } fr import { generateUuid } from '../../../../base/common/uuid.js'; import { getDevDeviceId, getMachineId } from '../../../../base/node/id.js'; import { createDecorator } from '../../../instantiation/common/instantiation.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { COPILOT_LICENSE_AGREEMENT } from '../../../endpoint/common/licenseAgreement.js'; +import { parseCopilotTokenFields } from '../copilot/copilotTokenFields.js'; // #region Types @@ -92,6 +94,10 @@ interface ICopilotUserResponse { interface ICachedClient { readonly capiClient: CAPIClient; readonly expiresAt: number; + /** The CAPI `endpoints.telemetry` base URL discovered for this token, if any. */ + readonly telemetryEndpoint?: string; + /** The CAPI `endpoints.api` base URL discovered (or overridden) for this token, if any. */ + readonly apiEndpoint?: string; } /** @@ -380,6 +386,20 @@ export const ICopilotApiService = createDecorator<ICopilotApiService>('copilotAp * and is not part of the Anthropic-shaped CAPI surface. * - Malformed JSON in an SSE `data:` line is logged and skipped, not thrown. */ +/** + * Restricted/enhanced telemetry context derived from a user's minted CAPI Copilot session token, + * mirroring what the Copilot extension reads off its `CopilotToken` (`rt` opt-in, `tid` tracking id) + * plus the CAPI `endpoints.telemetry` host. + */ +export interface IRestrictedTelemetryContext { + /** Whether the token opts into enhanced/restricted telemetry (the `rt=1` claim). */ + readonly restrictedTelemetryEnabled: boolean; + /** The Copilot user tracking id (`tid` claim), or `undefined` when absent. */ + readonly trackingId: string | undefined; + /** The CAPI `endpoints.telemetry` base URL, resolved only when enabled; `undefined` otherwise. */ + readonly telemetryEndpoint: string | undefined; +} + export interface ICopilotApiService { readonly _serviceBrand: undefined; @@ -473,6 +493,25 @@ export interface ICopilotApiService { request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions, ): Promise<string>; + + /** + * Resolve this user's restricted-telemetry context from the minted CAPI Copilot session token — + * the `rt` opt-in and `tid` tracking id — plus the CAPI `endpoints.telemetry` host. The GitHub + * token itself carries none of these claims; they live in the Copilot session token (minted via + * `RequestType.CopilotToken`), exactly as the Copilot extension reads them off its `CopilotToken`. + * The telemetry endpoint is resolved only when enabled, so public users incur no extra discovery. + */ + resolveRestrictedTelemetryContext(githubToken: string): Promise<IRestrictedTelemetryContext>; + + /** + * Resolve the CAPI `endpoints.api` base URL discovered for this GitHub token + * (or the loopback test override), or `undefined` when discovery hasn't run + * or failed. The effective CAPI host varies by account (consumer + * `api.githubcopilot.com` vs. Enterprise / proxy), so callers that need the + * real host — e.g. to resolve the correct proxy — should prefer this over the + * hardcoded default. + */ + resolveApiEndpoint(githubToken: string): Promise<string | undefined>; } export class CopilotApiService implements ICopilotApiService { @@ -488,6 +527,7 @@ export class CopilotApiService implements ICopilotApiService { fetchFn: FetchFunction | undefined, @ILogService private readonly _logService: ILogService, @IProductService private readonly _productService: IProductService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, ) { this._fetch = fetchFn ?? globalThis.fetch; } @@ -685,10 +725,13 @@ export class CopilotApiService implements ICopilotApiService { buildType: this._productService.quality === 'stable' ? 'prod' : 'dev', }; - // The user-info endpoint is hosted on api.github.com for consumer accounts. - // For GitHub Enterprise the host changes, but we don't currently support - // GHE in the agent host — see CopilotAgent for the same assumption. - const userUrl = 'https://api.github.com/copilot_internal/user'; + // Copilot endpoint discovery: GET `/copilot_internal/user` on the GitHub API + // host. For GitHub Enterprise the host is derived from `githubEnterpriseUri` + // (via the endpoint service); the response's `endpoints.api` then carries the + // enterprise CAPI base that CAPIClient routes through. Defaults to + // api.github.com when no enterprise URI is set. (GHE Cloud `*.ghe.com` is + // handled; GHE Server on-prem `/copilot_internal` routing is unverified.) + const userUrl = `${this._gitHubEndpointService.getApiBaseUri()}/copilot_internal/user`; return { extensionInfo, userUrl }; } @@ -799,16 +842,40 @@ export class CopilotApiService implements ICopilotApiService { * dispatched for token B. */ private _getClientForToken(githubToken: string): Promise<CAPIClient> { + return this._getEntryForToken(githubToken).then(entry => entry.capiClient); + } + + /** + * Resolve this user's restricted-telemetry context. Reads the `rt`/`tid` claims from the minted + * CAPI Copilot session token (the GitHub token has neither), and resolves the CAPI + * `endpoints.telemetry` host from the cached `/copilot_internal/user` discovery only when the + * user is opted in, so public users pay no extra discovery call. + */ + async resolveRestrictedTelemetryContext(githubToken: string): Promise<IRestrictedTelemetryContext> { + const fields = parseCopilotTokenFields(await this._getCopilotToken(githubToken)); + const restrictedTelemetryEnabled = fields.get('rt') === '1'; + const trackingId = fields.get('tid'); + const telemetryEndpoint = restrictedTelemetryEnabled + ? (await this._getEntryForToken(githubToken)).telemetryEndpoint + : undefined; + return { restrictedTelemetryEnabled, trackingId, telemetryEndpoint }; + } + + async resolveApiEndpoint(githubToken: string): Promise<string | undefined> { + return (await this._getEntryForToken(githubToken)).apiEndpoint; + } + + private _getEntryForToken(githubToken: string): Promise<ICachedClient> { const nowSeconds = Date.now() / 1000; const existing = this._clientsByToken.get(githubToken); if (existing) { return existing.then(entry => { if (entry.expiresAt - nowSeconds > CAPI_CONTEXT_REFRESH_BUFFER_SECONDS) { - return entry.capiClient; + return entry; } // Stale — evict and recurse to build a fresh entry. this._clientsByToken.delete(githubToken); - return this._getClientForToken(githubToken); + return this._getEntryForToken(githubToken); }).catch(err => { // A previous failed build leaked into the cache; evict and rebuild. this._clientsByToken.delete(githubToken); @@ -824,7 +891,7 @@ export class CopilotApiService implements ICopilotApiService { throw err; }); this._clientsByToken.set(githubToken, pending); - return pending.then(entry => entry.capiClient); + return pending; } private _invalidateClientForToken(githubToken: string): void { @@ -859,6 +926,7 @@ export class CopilotApiService implements ICopilotApiService { return { capiClient, expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, + apiEndpoint: overrideApi, }; } this._logService.warn(`[CopilotApiService] Ignoring non-loopback CAPI URL override ${overrideApi}; falling back to normal endpoint discovery`); @@ -882,7 +950,12 @@ export class CopilotApiService implements ICopilotApiService { capiClient.updateDomains( { endpoints: envelope.endpoints ?? {}, sku: envelope.access_type_sku ?? '' }, - undefined, + // Enterprise base URI (e.g. `https://acme.ghe.com`), or `undefined` for + // github.com. The package derives the GitHub API host (`api.<host>`) from + // this for `copilot_internal` endpoints - notably the Copilot session + // token mint (`/copilot_internal/v2/token`). Omitting it strands the mint + // on `api.github.com`, which 401s an enterprise token ("Bad credentials"). + this._gitHubEndpointService.getEnterpriseUri(), ); this._logService.debug('[CopilotApiService] CAPI endpoint discovered, api=', envelope.endpoints?.api); @@ -890,6 +963,8 @@ export class CopilotApiService implements ICopilotApiService { return { capiClient, expiresAt: Date.now() / 1000 + CAPI_CONTEXT_TTL_SECONDS, + telemetryEndpoint: envelope.endpoints?.telemetry, + apiEndpoint: envelope.endpoints?.api, }; } diff --git a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts index ab7b878c23a853..a007ba100c73bf 100644 --- a/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts +++ b/src/vs/platform/agentHost/node/shared/editSurvivalReporter.ts @@ -12,6 +12,7 @@ import { createDecorator } from '../../../instantiation/common/instantiation.js' import { ILogService } from '../../../log/common/log.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { AgentSession } from '../../common/agentService.js'; +import { isAhpChatChannel, parseRequiredSessionUriFromChatUri } from '../../common/state/sessionState.js'; import { computeChunkedEditSurvival, computeWholeFileEditSurvival } from './editSurvivalTracker.js'; /** @@ -191,13 +192,18 @@ class SessionEditSurvivalReporter extends Disposable { ? computeChunkedEditSurvival(this._params.beforeText, this._params.afterText, aiChunks, currentText) : computeWholeFileEditSurvival(this._params.beforeText, this._params.afterText, currentText); + // Sub-channel URIs (e.g. `ahp-chat:` for the default chat or + // subagent chats) encode the parent session URI; resolve them + // back so provider/id reflect the underlying harness rather than + // the chat scheme. See telemetry gap #6 in #8209. + const sessionUri = isAhpChatChannel(this._params.sessionUri) ? parseRequiredSessionUriFromChatUri(this._params.sessionUri) : this._params.sessionUri; this._telemetryService.publicLog2<IEditSurvivalTelemetryEvent, IEditSurvivalTelemetryClassification>( 'agentHost.trackEditSurvival', { - provider: AgentSession.provider(this._params.sessionUri) ?? 'unknown', + provider: AgentSession.provider(sessionUri) ?? 'unknown', modelId: this._params.modelId ?? '', toolName: this._params.toolName ?? '', - agentSessionId: AgentSession.id(this._params.sessionUri), + agentSessionId: AgentSession.id(sessionUri), turnId: this._params.turnId, toolCallId: this._params.toolCallId, fileExtension: extname(this._params.filePath), diff --git a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts index 9de8759cb41876..59318352343409 100644 --- a/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts +++ b/src/vs/platform/agentHost/node/shared/mcpCustomizationController.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, observableValue, transaction, type IObservable, type ITransaction } from '../../../../base/common/observable.js'; import { ActionType } from '../../common/state/protocol/common/actions.js'; import { CustomizationType, McpServerStatus, type AhpMcpUiHostCapabilities, type Customization, type McpServerCustomization, type McpServerState } from '../../common/state/protocol/channels-session/state.js'; import { DEFAULT_MCP_APP, DEFAULT_MCP_APP_CAPABILITIES } from '../../common/state/protocol/mcpAppDefaults.js'; @@ -22,6 +23,16 @@ export interface ISdkMcpServer { readonly state: McpServerState; } +/** + * Runtime fields of an MCP server customization that this controller + * owns — the high-frequency `state`/`channel` pair. Consumers overlay + * these onto their published customizations (keyed by customization id) + * so a wholesale customization republish preserves live MCP status + * rather than resetting it to the `Starting` default baked into + * `makeMcpServerCustomization`. + */ +export type IMcpServerRuntimeState = Pick<McpServerCustomization, 'state' | 'channel'>; + /** * Re-export so existing imports of `DEFAULT_MCP_APP_CAPABILITIES` from * the controller keep working — the canonical home is now @@ -89,24 +100,47 @@ interface ILiveEntry { * * The controller is SDK-agnostic: providers translate their own events * into {@link ISdkMcpServer} and call {@link applyAll} / {@link applyOne}. - * - * V1 scope: auth states are intentionally not surfaced as - * {@link McpServerStatus.AuthRequired} — they are folded back into - * {@link McpServerStatus.Starting} by the caller's translation layer. + * If a provider reports a coarse {@link McpServerStatus.Starting} update + * after a richer {@link McpServerStatus.AuthRequired} state, the controller + * preserves the auth-required state until a definitive + * {@link McpServerStatus.Ready}, {@link McpServerStatus.Error}, or + * {@link McpServerStatus.Stopped} update arrives. */ export class McpCustomizationController extends Disposable { /** Per-server live entries, keyed by server name. */ - private readonly _live = new Map<string, ILiveEntry>(); + private readonly _live = observableValue<ReadonlyMap<string, ILiveEntry>>(this, new Map()); + + /** + * Snapshot of every live server's runtime {@link IMcpServerRuntimeState}, + * keyed by the customization id under which it is published (the + * minted top-level id, or the plugin-derived child id resolved via + * {@link IMcpChildIdResolver}). Derived from {@link _live}. Callers mirror + * this into their own published customizations so a wholesale republish + * preserves live MCP status. Servers whose child id cannot currently be + * resolved are omitted. + */ + readonly runtimeStates: IObservable<ReadonlyMap<string, IMcpServerRuntimeState>>; constructor(private readonly _options: IMcpCustomizationControllerOptions) { super(); + this.runtimeStates = derived(this, reader => { + const out = new Map<string, IMcpServerRuntimeState>(); + for (const entry of this._live.read(reader).values()) { + const id = entry.topLevelId ?? this._options.resolveChildId(entry.serverName); + if (id === undefined) { + continue; + } + out.set(id, { state: entry.state, channel: this._buildChannel(entry.serverName, entry.state) }); + } + return out; + }); } /** Snapshot for inclusion in `getSessionCustomizations()` results. */ topLevelCustomizations(): readonly McpServerCustomization[] { const out: McpServerCustomization[] = []; - for (const entry of this._live.values()) { + for (const entry of this._live.get().values()) { if (entry.topLevelId === undefined) { continue; } @@ -124,7 +158,7 @@ export class McpCustomizationController extends Disposable { */ readyChannels(): readonly { readonly serverName: string; readonly channel: string }[] { const out: { serverName: string; channel: string }[] = []; - for (const entry of this._live.values()) { + for (const entry of this._live.get().values()) { if (entry.state.kind !== McpServerStatus.Ready) { continue; } @@ -147,13 +181,29 @@ export class McpCustomizationController extends Disposable { * server customization. */ customizationIdForServer(serverName: string): string | undefined { - const live = this._live.get(serverName); + const live = this._live.get().get(serverName); if (live?.topLevelId !== undefined) { return live.topLevelId; } return this._options.resolveChildId(serverName); } + /** Returns the live server name associated with a customization id. */ + serverNameForCustomizationId(id: string): string | undefined { + for (const entry of this._live.get().values()) { + const entryId = entry.topLevelId ?? this._options.resolveChildId(entry.serverName); + if (entryId === id) { + return entry.serverName; + } + } + return undefined; + } + + /** Returns the last live state recorded for the MCP server named `serverName`. */ + stateForServer(serverName: string): McpServerState | undefined { + return this._live.get().get(serverName)?.state; + } + /** * Returns the `mcp://` AHP channel URI currently advertised for the * MCP server named `serverName`, or `undefined` when the server is @@ -163,7 +213,7 @@ export class McpCustomizationController extends Disposable { * back through {@link IAgentHostService.handleMcpRequest}. */ channelForServer(serverName: string): string | undefined { - const live = this._live.get(serverName); + const live = this._live.get().get(serverName); if (!live || live.state.kind !== McpServerStatus.Ready) { return undefined; } @@ -173,23 +223,32 @@ export class McpCustomizationController extends Disposable { /** * Replaces the live inventory with `servers`. Servers no longer * present are removed; new servers and changed servers are upserted. + * Batched in a single transaction so {@link runtimeStates} observers + * see one coalesced update. */ applyAll(servers: readonly ISdkMcpServer[]): void { - const seen = new Set<string>(); - for (const server of servers) { - seen.add(server.name); - this.applyOne(server); - } - for (const name of [...this._live.keys()]) { - if (!seen.has(name)) { - this.remove(name); + transaction(tx => { + const seen = new Set<string>(); + for (const server of servers) { + seen.add(server.name); + this._applyOne(server, tx); } - } + for (const name of [...this._live.get().keys()]) { + if (!seen.has(name)) { + this._remove(name, tx); + } + } + }); } /** Upserts a single server. */ applyOne(server: ISdkMcpServer): void { - const previous = this._live.get(server.name); + transaction(tx => this._applyOne(server, tx)); + } + + private _applyOne(server: ISdkMcpServer, tx: ITransaction): void { + const previous = this._live.get().get(server.name); + const state = this._stateForUpdate(previous?.state, server.state); // Once promoted to a top-level entry, stay top-level for the // session — flipping back to a child mid-stream would orphan the // previously-published top-level id. @@ -197,21 +256,21 @@ export class McpCustomizationController extends Disposable { if (topLevelId === undefined) { const childId = this._options.resolveChildId(server.name); if (childId !== undefined) { - this._live.set(server.name, { serverName: server.name, state: server.state, topLevelId: undefined }); + this._setLiveEntry(server.name, { serverName: server.name, state, topLevelId: undefined }, tx); this._options.emit({ type: ActionType.SessionMcpServerStateChanged, id: childId, - state: server.state, - channel: this._buildChannel(server.name, server.state), + state, + channel: this._buildChannel(server.name, state), }); return; } topLevelId = this._mintTopLevelId(server.name); } - this._live.set(server.name, { serverName: server.name, state: server.state, topLevelId }); + this._setLiveEntry(server.name, { serverName: server.name, state, topLevelId }, tx); this._options.emit({ type: ActionType.SessionCustomizationUpdated, - customization: this._buildTopLevel(topLevelId, server.name, server.state), + customization: this._buildTopLevel(topLevelId, server.name, state), }); } @@ -228,11 +287,15 @@ export class McpCustomizationController extends Disposable { * actual removal of the child container. */ remove(serverName: string): void { - const entry = this._live.get(serverName); + transaction(tx => this._remove(serverName, tx)); + } + + private _remove(serverName: string, tx: ITransaction): void { + const entry = this._live.get().get(serverName); if (!entry) { return; } - this._live.delete(serverName); + this._deleteLiveEntry(serverName, tx); if (entry.topLevelId !== undefined) { this._options.emit({ type: ActionType.SessionCustomizationRemoved, @@ -253,6 +316,31 @@ export class McpCustomizationController extends Disposable { // ---- internals --------------------------------------------------------- + /** Immutable upsert into the {@link _live} observable. */ + private _setLiveEntry(serverName: string, entry: ILiveEntry, tx: ITransaction): void { + const next = new Map(this._live.get()); + next.set(serverName, entry); + this._live.set(next, tx); + } + + /** Immutable delete from the {@link _live} observable. */ + private _deleteLiveEntry(serverName: string, tx: ITransaction): void { + const current = this._live.get(); + if (!current.has(serverName)) { + return; + } + const next = new Map(current); + next.delete(serverName); + this._live.set(next, tx); + } + + private _stateForUpdate(previous: McpServerState | undefined, next: McpServerState): McpServerState { + if (previous?.kind === McpServerStatus.AuthRequired && next.kind === McpServerStatus.Starting) { + return previous; + } + return next; + } + private _mintTopLevelId(serverName: string): string { return `mcp-top-level:${this._options.providerId}:${this._options.sessionId}:${serverName}`; } @@ -315,6 +403,27 @@ export function findMcpChildId(customizations: readonly Customization[], serverN return undefined; } +export function findMcpServerName(customizations: readonly Customization[], id: string): string | undefined { + for (const top of customizations) { + if (top.type === CustomizationType.McpServer) { + if (top.id === id) { + return top.name; + } + continue; + } + const children = top.children; + if (!children) { + continue; + } + for (const child of children) { + if (child.type === CustomizationType.McpServer && child.id === id) { + return child.name; + } + } + } + return undefined; +} + /** * Parsed `mcp://<providerId>/<sessionId>/<serverName>` URI as minted by * {@link McpCustomizationController}. The path segments are diff --git a/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts new file mode 100644 index 00000000000000..3be196ad36aa66 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/persistSessionMetadata.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { ILogService } from '../../../log/common/log.js'; +import type { ISessionDataService } from '../../common/sessionDataService.js'; + +/** + * Fire-and-forget persistence of a single session-metadata key/value pair to a + * session's database. Opens the database, writes the value, and disposes the + * handle; failures are logged, not thrown. + * + * Used for host-owned fields that must survive restart (custom titles, isRead / + * isArchived flags, merged config values, …). Shared so callers do not each + * re-implement the open/write/dispose dance. + */ +export function persistSessionMetadata(sessionDataService: ISessionDataService, logService: ILogService, session: string, key: string, value: string): void { + const ref = sessionDataService.openDatabase(URI.parse(session)); + ref.object.setMetadata(key, value).catch(err => { + logService.warn(`[AgentHost] Failed to persist session metadata '${key}'`, err); + }).finally(() => { + ref.dispose(); + }); +} diff --git a/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts b/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts new file mode 100644 index 00000000000000..5394e190a86834 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/shellCommandExecution.ts @@ -0,0 +1,372 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import * as platform from '../../../../base/common/platform.js'; +import { removeAnsiEscapeCodes } from '../../../../base/common/strings.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILogService } from '../../../log/common/log.js'; +import { TerminalClaimKind } from '../../common/state/protocol/state.js'; +import { IAgentHostTerminalManager } from '../agentHostTerminalManager.js'; + +/** + * Maximum scrollback content (in bytes) returned to the model / caller in + * command results. + */ +export const SHELL_COMMAND_MAX_OUTPUT_BYTES = 80_000; + +/** + * Default command timeout in milliseconds (120 seconds). + */ +export const DEFAULT_SHELL_COMMAND_TIMEOUT_MS = 120_000; + +/** + * The sentinel prefix used to detect command completion in terminal output + * when shell integration is unavailable. The full sentinel format is: + * `<<<COPILOT_SENTINEL_<uuid>_EXIT_<code>>>`. + */ +const SENTINEL_PREFIX = '<<<COPILOT_SENTINEL_'; + +/** + * The kind of shell a command runs in. Determines sentinel syntax, history + * suppression and bracketed-paste heuristics. + */ +export type ShellType = 'bash' | 'powershell'; + +/** + * Routes a resolved shell executable to a {@link ShellType}. Falls back to the + * platform default for unknown shells. + */ +export function shellTypeForExecutable(shellPath: string): ShellType { + // Strip path on either separator and the .exe suffix. + const lastSep = Math.max(shellPath.lastIndexOf('/'), shellPath.lastIndexOf('\\')); + const base = shellPath.slice(lastSep + 1).toLowerCase().replace(/\.exe$/, ''); + switch (base) { + // PowerShell + case 'pwsh': + case 'powershell': + case 'pwsh-preview': + return 'powershell'; + // POSIX shells + case 'bash': + case 'sh': + case 'zsh': + case 'fish': + case 'csh': + case 'ksh': + case 'nu': + case 'xonsh': + // Git for Windows bash entry points + case 'git-cmd': + // WSL launchers — bash inside, but invoked via these stubs + case 'wsl': + case 'ubuntu': + case 'ubuntu1804': + case 'kali': + case 'debian': + case 'opensuse-42': + case 'sles-12': + return 'bash'; + default: + return platform.isWindows ? 'powershell' : 'bash'; + } +} + +/** + * For POSIX shells (bash/zsh) that honor `HISTCONTROL=ignorespace` / + * `HIST_IGNORE_SPACE`, prepending a single space prevents the command from + * being recorded in shell history. The shell integration scripts opt these + * settings in via the `VSCODE_PREVENT_SHELL_HISTORY` env var (set when the + * terminal is created with `preventShellHistory: true`). PowerShell + * suppresses history through PSReadLine instead, so no prefix is needed. + */ +export function prefixForHistorySuppression(shellType: ShellType): string { + return shellType === 'powershell' ? '' : ' '; +} + +export function isMultilineCommand(command: string): boolean { + const normalized = command.replace(/\r\n|\r/g, '\n'); + return /(?<!\\)\n/.test(normalized); +} + +function shouldUseBracketedPasteMode(command: string): boolean { + return platform.isMacintosh || isMultilineCommand(command); +} + +function makeSentinelId(): string { + return generateUuid().replace(/-/g, ''); +} + +function buildSentinelCommand(sentinelId: string, shellType: ShellType): string { + if (shellType === 'powershell') { + return `Write-Output "${SENTINEL_PREFIX}${sentinelId}_EXIT_$LASTEXITCODE>>>"`; + } + return `echo "${SENTINEL_PREFIX}${sentinelId}_EXIT_$?>>>"`; +} + +function parseSentinel(content: string, sentinelId: string): { found: boolean; exitCode: number; outputBeforeSentinel: string } { + const marker = `${SENTINEL_PREFIX}${sentinelId}_EXIT_`; + let markerIndex = content.lastIndexOf(marker); + while (markerIndex !== -1) { + const outputBeforeSentinel = content.substring(0, markerIndex); + const afterMarker = content.substring(markerIndex + marker.length); + const endIdx = afterMarker.indexOf('>>>'); + if (endIdx !== -1) { + const exitCodeStr = afterMarker.substring(0, endIdx).trim(); + if (/^-?\d+$/.test(exitCodeStr)) { + return { + found: true, + exitCode: parseInt(exitCodeStr, 10), + outputBeforeSentinel, + }; + } + } + // Ignore echoed sentinel command text (for example `$?`) and continue + // scanning for the latest complete numeric sentinel marker. + markerIndex = content.lastIndexOf(marker, markerIndex - 1); + } + + return { found: false, exitCode: -1, outputBeforeSentinel: content }; +} + +/** + * Strips ANSI escape codes and trims the terminal output to the last + * {@link SHELL_COMMAND_MAX_OUTPUT_BYTES} bytes so it is safe to surface to a + * model or the transcript. + */ +export function prepareOutputForModel(rawOutput: string): string { + let text = removeAnsiEscapeCodes(rawOutput).trim(); + if (text.length > SHELL_COMMAND_MAX_OUTPUT_BYTES) { + text = text.substring(text.length - SHELL_COMMAND_MAX_OUTPUT_BYTES); + } + return text; +} + +/** + * Terminal against which a shell command is executed. + */ +export interface IShellCommandTarget { + /** URI of the managed terminal the command runs in. */ + readonly terminalUri: string; + /** The kind of shell backing the terminal. */ + readonly shellType: ShellType; +} + +/** + * How a shell command execution finished. + * + * - `completed` — the command finished; {@link IShellCommandResult.exitCode} holds the exit code. + * - `timeout` — the command did not finish within the timeout; output is partial. + * - `background` — the terminal claim was narrowed (user chose to continue in background). + * - `altBuffer` — the command switched to the terminal's alternate buffer (interactive UI). + * - `shellExited` — the shell process exited unexpectedly. + */ +export type ShellCommandStatus = 'completed' | 'timeout' | 'background' | 'altBuffer' | 'shellExited'; + +/** + * Neutral, agent-agnostic result of executing a shell command. Callers map this + * to their own result shape (e.g. an SDK `ToolResultObject` or an AHP tool call + * completion). + */ +export interface IShellCommandResult { + /** How the command execution finished. */ + readonly status: ShellCommandStatus; + /** Exit code, when known (`completed` and `shellExited`). */ + readonly exitCode?: number; + /** Cleaned command output (empty for `background`/`altBuffer`). */ + readonly output: string; +} + +/** + * Execute a command on an already-created managed terminal, resolving once the + * command finishes, times out, backgrounds, enters the alternate buffer, or the + * shell exits. Uses shell integration (OSC 633) for completion detection when + * available and falls back to a sentinel echo otherwise. + * + * This is the shared shell-integration primitive used by both the Copilot SDK + * shell tools and the agent-host `!command` runner. + */ +export function executeShellCommand( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise<IShellCommandResult> { + return terminalManager.supportsCommandDetection(target.terminalUri) + ? executeCommandWithShellIntegration(target, command, timeoutMs, terminalManager, logService) + : executeCommandWithSentinel(target, command, timeoutMs, terminalManager, logService); +} + +function registerAltBufferHandler( + target: IShellCommandTarget, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, + disposables: DisposableStore, + finish: (result: IShellCommandResult) => void, +): void { + void terminalManager.createAltBufferPromise(target.terminalUri, disposables).then(() => { + logService.info('[ShellCommand] Command entered alternate buffer'); + finish({ status: 'altBuffer', output: '' }); + }); +} + +/** + * Execute a command using shell integration (OSC 633) for completion detection. + * No sentinel echo is injected — the shell's own command-finished signal + * provides the exit code and cleanly delineated output. + */ +async function executeCommandWithShellIntegration( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise<IShellCommandResult> { + const disposables = new DisposableStore(); + + const result = new Promise<IShellCommandResult>(resolve => { + let resolved = false; + const finish = (result: IShellCommandResult) => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(result); + }; + + disposables.add(terminalManager.onCommandFinished(target.terminalUri, event => { + const output = prepareOutputForModel(event.output); + const exitCode = event.exitCode ?? 0; + logService.info(`[ShellCommand] Command completed (shell integration) with exit code ${exitCode}`); + finish({ status: 'completed', exitCode, output }); + })); + + registerAltBufferHandler(target, terminalManager, logService, disposables, finish); + + disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => { + logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(fullContent) }); + })); + + disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => { + if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { + logService.info(`[ShellCommand] Continuing in background (claim narrowed)`); + finish({ status: 'background', output: '' }); + } + })); + + const timer = setTimeout(() => { + logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + finish({ status: 'timeout', output: prepareOutputForModel(fullContent) }); + }, timeoutMs); + disposables.add(toDisposable(() => clearTimeout(timer))); + + }); + + try { + await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, { + shouldExecute: true, + bracketedPasteMode: shouldUseBracketedPasteMode(command), + }); + } catch (err) { + disposables.dispose(); + throw err; + } + + return result; +} + +/** + * Fallback: execute a command using a sentinel echo to detect completion. + * Used when shell integration is not available. + */ +async function executeCommandWithSentinel( + target: IShellCommandTarget, + command: string, + timeoutMs: number, + terminalManager: IAgentHostTerminalManager, + logService: ILogService, +): Promise<IShellCommandResult> { + const sentinelId = makeSentinelId(); + const sentinelCmd = buildSentinelCommand(sentinelId, target.shellType); + const disposables = new DisposableStore(); + + const contentBefore = terminalManager.getContent(target.terminalUri) ?? ''; + const offsetBefore = contentBefore.length; + + const result = new Promise<IShellCommandResult>(resolve => { + let resolved = false; + const finish = (result: IShellCommandResult) => { + if (resolved) { + return; + } + resolved = true; + disposables.dispose(); + resolve(result); + }; + + const checkForSentinel = () => { + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + // Clamp offset: the terminal manager trims content when it exceeds + // 100k chars (slices to last 80k). If trimming happened after we + // captured offsetBefore, scan from the start of the current buffer. + const clampedOffset = Math.min(offsetBefore, fullContent.length); + const newContent = fullContent.substring(clampedOffset); + const parsed = parseSentinel(newContent, sentinelId); + if (parsed.found) { + const output = prepareOutputForModel(parsed.outputBeforeSentinel); + logService.info(`[ShellCommand] Command completed with exit code ${parsed.exitCode}`); + finish({ status: 'completed', exitCode: parsed.exitCode, output }); + } + }; + + disposables.add(terminalManager.onData(target.terminalUri, () => { + checkForSentinel(); + })); + + registerAltBufferHandler(target, terminalManager, logService, disposables, finish); + + disposables.add(terminalManager.onExit(target.terminalUri, (exitCode: number) => { + logService.info(`[ShellCommand] Shell exited unexpectedly with code ${exitCode}`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + const newContent = fullContent.substring(offsetBefore); + finish({ status: 'shellExited', exitCode, output: prepareOutputForModel(newContent) }); + })); + + disposables.add(terminalManager.onClaimChanged(target.terminalUri, (claim) => { + if (claim.kind === TerminalClaimKind.Session && !claim.toolCallId) { + logService.info(`[ShellCommand] Continuing in background (claim narrowed)`); + finish({ status: 'background', output: '' }); + } + })); + + const timer = setTimeout(() => { + logService.warn(`[ShellCommand] Command timed out after ${timeoutMs}ms`); + const fullContent = terminalManager.getContent(target.terminalUri) ?? ''; + const newContent = fullContent.substring(offsetBefore); + finish({ status: 'timeout', output: prepareOutputForModel(newContent) }); + }, timeoutMs); + disposables.add(toDisposable(() => clearTimeout(timer))); + + checkForSentinel(); + }); + + try { + await terminalManager.sendText(target.terminalUri, `${prefixForHistorySuppression(target.shellType)}${command}`, { + shouldExecute: true, + bracketedPasteMode: shouldUseBracketedPasteMode(command), + }); + await terminalManager.sendText(target.terminalUri, sentinelCmd, { shouldExecute: true }); + } catch (err) { + disposables.dispose(); + throw err; + } + + return result; +} diff --git a/src/vs/platform/agentHost/node/workspacelessScratchDir.ts b/src/vs/platform/agentHost/node/workspacelessScratchDir.ts new file mode 100644 index 00000000000000..f0f677deab3baa --- /dev/null +++ b/src/vs/platform/agentHost/node/workspacelessScratchDir.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as fs from 'fs/promises'; +import { joinPath } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Stable, deterministic per-session scratch directory for a workspace-less + * (workspace-less chat) session: `<userHome>/.copilot/chats/<sessionId>`. Shared by the + * Copilot and Claude agents so both resolve the same cwd for a session that was + * created with no `workingDirectory`. + */ +export function workspacelessScratchDir(userHome: URI, sessionId: string): URI { + return joinPath(userHome, '.copilot', 'chats', sessionId); +} + +/** Ensures the workspace-less scratch dir exists (mkdir -p), returning it. */ +export async function ensureWorkspacelessScratchDir(userHome: URI, sessionId: string): Promise<URI> { + const dir = workspacelessScratchDir(userHome, sessionId); + await fs.mkdir(dir.fsPath, { recursive: true }); + return dir; +} diff --git a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts index 9a1b308ab95fdc..16c20ea8d49be2 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSessionType.test.ts @@ -5,7 +5,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; +import { findRemoteAgentHostSessionTypeAuthority, isRemoteAgentHostSessionType, parseRemoteAgentHostHarness, parseRemoteAgentHostSessionTypeAuthority, remoteAgentHostSessionTypeAuthorityPrefix, remoteAgentHostSessionTypeId } from '../../common/agentHostSessionType.js'; suite('agentHostSessionType', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -51,4 +51,20 @@ suite('agentHostSessionType', () => { undefined, ]); }); + + test('parses harness from remote session type', () => { + assert.deepStrictEqual([ + parseRemoteAgentHostHarness('remote-foo-copilotcli'), + parseRemoteAgentHostHarness('remote-foo-bar-claude'), + parseRemoteAgentHostHarness('remote-10.0.0.1__8080-codex'), + parseRemoteAgentHostHarness('vscodeLocalChatSession'), + parseRemoteAgentHostHarness('remote-'), + ], [ + 'copilotcli', + 'claude', + 'codex', + undefined, + undefined, + ]); + }); }); diff --git a/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts b/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts new file mode 100644 index 00000000000000..4950407ee5a014 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/agentHostTelemetryEnv.test.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AgentHostDevDeviceIdEnvKey, AgentHostMachineIdEnvKey, AgentHostSqmIdEnvKey, buildAgentHostTelemetryIdEnv } from '../../common/agentHostTelemetryEnv.js'; + +suite('agentHostTelemetryEnv', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('buildAgentHostTelemetryIdEnv forwards present ids and omits empty ones', () => { + assert.deepStrictEqual([ + buildAgentHostTelemetryIdEnv({ machineId: 'm1', sqmId: 's1', devDeviceId: 'd1' }), + buildAgentHostTelemetryIdEnv({ machineId: 'm1', sqmId: '', devDeviceId: 'd1' }), + buildAgentHostTelemetryIdEnv({ machineId: '', sqmId: '', devDeviceId: '' }), + ], [ + { [AgentHostMachineIdEnvKey]: 'm1', [AgentHostSqmIdEnvKey]: 's1', [AgentHostDevDeviceIdEnvKey]: 'd1' }, + { [AgentHostMachineIdEnvKey]: 'm1', [AgentHostDevDeviceIdEnvKey]: 'd1' }, + {}, + ]); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index c89e45c31a58c3..c15147f75ef0d8 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta, toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; -import { readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; +import { getCommandArgumentHint, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, type AgentCustomization, type ToolCallState } from '../../common/state/sessionState.js'; import type { SimpleMessageAttachment } from '../../common/state/protocol/state.js'; @@ -91,8 +91,8 @@ suite('Agent host _meta readers', () => { suite('readCompletionAttachmentMeta', () => { test('classifies a command bag', () => { assert.deepStrictEqual( - readCompletionAttachmentMeta(attachment({ command: 'rename', description: 'Rename this chat' })), - { kind: 'command', command: 'rename', description: 'Rename this chat' } + readCompletionAttachmentMeta(attachment({ command: 'rename', description: 'Rename this chat', argumentHint: 'New name' })), + { kind: 'command', command: 'rename', description: 'Rename this chat', argumentHint: 'New name' } ); }); @@ -114,9 +114,20 @@ suite('Agent host _meta readers', () => { assert.deepStrictEqual(cmd, { command: 'rename' }); assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmd)), { kind: 'command', command: 'rename' }); + const cmdWithHint = toCommandCompletionAttachmentMeta({ command: 'rename', argumentHint: 'New name', description: undefined }); + assert.deepStrictEqual(cmdWithHint, { command: 'rename', argumentHint: 'New name' }); + assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(cmdWithHint)), { kind: 'command', command: 'rename', argumentHint: 'New name' }); + const skill = toSkillCompletionAttachmentMeta({ uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon', description: undefined }); assert.deepStrictEqual(skill, { uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon' }); assert.deepStrictEqual(readCompletionAttachmentMeta(attachment(skill)), { kind: 'skill', uri: 'file:///s/SKILL.md', name: 'mon', displayName: 'mon' }); }); + + test('getCommandArgumentHint reads the hint and ignores wrong-typed / absent bags', () => { + assert.strictEqual(getCommandArgumentHint({ argumentHint: 'New name' }), 'New name'); + assert.strictEqual(getCommandArgumentHint({ argumentHint: 5 }), undefined); + assert.strictEqual(getCommandArgumentHint({ command: 'rename' }), undefined); + assert.strictEqual(getCommandArgumentHint(undefined), undefined); + }); }); }); diff --git a/src/vs/platform/agentHost/test/common/agentService.test.ts b/src/vs/platform/agentHost/test/common/agentService.test.ts index 8545e9f22084b7..6c253e021a3adb 100644 --- a/src/vs/platform/agentHost/test/common/agentService.test.ts +++ b/src/vs/platform/agentHost/test/common/agentService.test.ts @@ -7,7 +7,8 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IConfigurationService } from '../../../configuration/common/configuration.js'; -import { AgentSession, AgentHostOTelEnvVars, buildAgentHostOTelEnv, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings } from '../../common/agentService.js'; +import { AgentHostByokModelsEnabledEnvVar, AgentSession, AgentHostOTelEnvVars, buildAgentHostOTelEnv, buildAgentSdkEnv, isAgentEnabled, readAgentHostOTelPolicySettings, sanitizeAgentHostOTelPolicySettings } from '../../common/agentService.js'; +import { buildChatUri, buildDefaultChatUri, resolveChatUri } from '../../common/state/sessionState.js'; suite('AgentSession namespace', () => { @@ -240,3 +241,45 @@ suite('sanitizeAgentHostOTelPolicySettings', () => { assert.strictEqual(({} as Record<string, unknown>).polluted, undefined); }); }); + +suite('resolveChatUri', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = AgentSession.uri('copilot', 'sess-1'); + + test('default chat collapses onto the scope (session) URI', () => { + const defaultChat = URI.parse(buildDefaultChatUri(session)); + assert.strictEqual(resolveChatUri(session, defaultChat).toString(), session.toString()); + }); + + test('peer chat is addressed by its own URI', () => { + const peer = URI.parse(buildChatUri(session, 'peer-42')); + assert.strictEqual(resolveChatUri(session, peer).toString(), peer.toString()); + }); +}); + +suite('buildAgentSdkEnv (BYOK gate forwarding)', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('forwards byokModelsEnabled=true as the enable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'true'); + }); + + test('forwards byokModelsEnabled=false as the disable env var', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: false }, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], 'false'); + }); + + test('omits the env var when byokModelsEnabled is undefined', () => { + const env = buildAgentSdkEnv({}, {}); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); + + test('lets an inherited env var win over the setting (developer override)', () => { + const env = buildAgentSdkEnv({ byokModelsEnabled: true }, { [AgentHostByokModelsEnabledEnvVar]: 'false' }); + assert.strictEqual(env[AgentHostByokModelsEnabledEnvVar], undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts b/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts new file mode 100644 index 00000000000000..cf1387af8aa1bc --- /dev/null +++ b/src/vs/platform/agentHost/test/common/copilotCliConfig.test.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { applyModelFamilyAlias } from '../../common/copilotCliConfig.js'; +import type { ModelSelection } from '../../common/state/protocol/state.js'; + +suite('copilotCliConfig', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('applyModelFamilyAlias substitutes a usable alias and ignores everything else', () => { + const model: ModelSelection = { id: 'preview-model-x', config: { thinkingLevel: 'high' } }; + assert.deepStrictEqual( + [ + // usable alias: id substituted, picker config preserved + applyModelFamilyAlias(model, { 'preview-model-x': { family: 'claude-opus-4-8' } }), + // no overrides / override for another id / no usable family → unchanged + applyModelFamilyAlias(model, undefined), + applyModelFamilyAlias(model, { 'other-model': { family: 'claude-opus-4-8' } }), + applyModelFamilyAlias(model, { 'preview-model-x': {} }), + applyModelFamilyAlias(model, { 'preview-model-x': { family: '' } }), + // no model → undefined + applyModelFamilyAlias(undefined, { 'preview-model-x': { family: 'claude-opus-4-8' } }), + ], + [ + { id: 'claude-opus-4-8', config: { thinkingLevel: 'high' } }, + model, + model, + model, + model, + undefined, + ] + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/githubEndpoints.test.ts b/src/vs/platform/agentHost/test/common/githubEndpoints.test.ts new file mode 100644 index 00000000000000..bbb58b73619537 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/githubEndpoints.test.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE, GITHUB_REPO_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { deriveGitHubEndpoints, gitHubCopilotResource, gitHubRepoResource } from '../../common/githubEndpoints.js'; + +suite('githubEndpoints', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const DOT_COM = { + apiBaseUri: 'https://api.github.com', + graphQlUri: 'https://api.github.com/graphql', + oauthServer: 'https://github.com/login/oauth', + enterpriseHost: undefined, + }; + + test('deriveGitHubEndpoints: github.com defaults for unset / empty / unparseable / github.com host', () => { + assert.deepStrictEqual({ + unset: deriveGitHubEndpoints(undefined), + empty: deriveGitHubEndpoints(''), + garbage: deriveGitHubEndpoints('not a uri'), + dotCom: deriveGitHubEndpoints('https://github.com'), + apiDotCom: deriveGitHubEndpoints('https://api.github.com'), + }, { + unset: DOT_COM, + empty: DOT_COM, + garbage: DOT_COM, + dotCom: DOT_COM, + apiDotCom: DOT_COM, + }); + }); + + test('deriveGitHubEndpoints: GitHub Enterprise Cloud (.ghe.com) uses the api. subdomain', () => { + assert.deepStrictEqual(deriveGitHubEndpoints('https://acme.ghe.com'), { + apiBaseUri: 'https://api.acme.ghe.com', + graphQlUri: 'https://api.acme.ghe.com/graphql', + oauthServer: 'https://acme.ghe.com/login/oauth', + enterpriseHost: 'acme.ghe.com', + }); + }); + + test('deriveGitHubEndpoints: GitHub Enterprise Server uses /api/v3 and /api/graphql', () => { + // The GraphQL endpoint is `/api/graphql`, NOT `apiBaseUri + /graphql` + // (which would give the wrong `/api/v3/graphql`). + assert.deepStrictEqual(deriveGitHubEndpoints('https://ghe.acme.com'), { + apiBaseUri: 'https://ghe.acme.com/api/v3', + graphQlUri: 'https://ghe.acme.com/api/graphql', + oauthServer: 'https://ghe.acme.com/login/oauth', + enterpriseHost: 'ghe.acme.com', + }); + }); + + test('deriveGitHubEndpoints: preserves scheme and ignores path', () => { + assert.deepStrictEqual(deriveGitHubEndpoints('http://ghe.local/some/path'), { + apiBaseUri: 'http://ghe.local/api/v3', + graphQlUri: 'http://ghe.local/api/graphql', + oauthServer: 'http://ghe.local/login/oauth', + enterpriseHost: 'ghe.local', + }); + }); + + test('resource builders derive resource + authorization_servers from endpoints', () => { + const endpoints = deriveGitHubEndpoints('https://ghe.acme.com'); + assert.deepStrictEqual({ + copilot: gitHubCopilotResource(endpoints), + repo: gitHubRepoResource(endpoints), + }, { + copilot: { + resource: 'https://ghe.acme.com/api/v3', + resource_name: 'GitHub Copilot', + authorization_servers: ['https://ghe.acme.com/login/oauth'], + scopes_supported: ['read:user', 'user:email'], + required: true, + }, + repo: { + resource: 'https://ghe.acme.com/api/v3/repos', + resource_name: 'GitHub Repository', + authorization_servers: ['https://ghe.acme.com/login/oauth'], + scopes_supported: ['repo'], + required: false, + }, + }); + }); + + test('github.com resources are byte-for-byte the canonical protected-resource constants', () => { + // Backward-compat invariant: with no enterprise URI, token-store keys and + // advertised metadata must be unchanged for the common non-enterprise case. + const endpoints = deriveGitHubEndpoints(undefined); + assert.deepStrictEqual({ + copilot: gitHubCopilotResource(endpoints), + repo: gitHubRepoResource(endpoints), + }, { + copilot: GITHUB_COPILOT_PROTECTED_RESOURCE, + repo: GITHUB_REPO_PROTECTED_RESOURCE, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts index cf27b6c919b13e..57e451a81cc7fc 100644 --- a/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts +++ b/src/vs/platform/agentHost/test/common/sessionTestHelpers.ts @@ -8,13 +8,15 @@ import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { Event } from '../../../../base/common/event.js'; import type { IDiffComputeService, IDiffCountResult } from '../../common/diffComputeService.js'; -import type { IFileEditContent, IFileEditRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; +import type { IFileEditContent, IFileEditRecord, ILocalTurnRecord, IReviewedFileRecord, ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { Message } from '../../common/state/sessionState.js'; export class TestSessionDatabase implements ISessionDatabase { private readonly _edits: (IFileEditRecord & IFileEditContent)[] = []; private readonly _metadata = new Map<string, string>(); private readonly _drafts = new Map<string, Message>(); + private readonly _reviewedFiles: IReviewedFileRecord[] = []; + private readonly _localTurns = new Map<string, ILocalTurnRecord>(); getAllFileEditsCalls = 0; getFileEditsByTurnCalls = 0; @@ -113,8 +115,46 @@ export class TestSessionDatabase implements ISessionDatabase { this._edits.length = 0; } + async insertLocalTurn(record: ILocalTurnRecord): Promise<void> { + this._localTurns.set(record.turnId, record); + } + + async getLocalTurns(): Promise<ILocalTurnRecord[]> { + return [...this._localTurns.values()].sort((a, b) => a.seq - b.seq); + } + + async deleteLocalTurns(turnIds: readonly string[]): Promise<void> { + for (const id of turnIds) { + this._localTurns.delete(id); + } + } async remapTurnIds(_mapping: ReadonlyMap<string, string>): Promise<void> { } + async markFileReviewed(uri: URI, nonce: string): Promise<void> { + if (!this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce)) { + this._reviewedFiles.push({ uri, nonce }); + } + } + + async unmarkFileReviewed(uri: URI, nonce: string): Promise<void> { + const index = this._reviewedFiles.findIndex(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + if (index >= 0) { + this._reviewedFiles.splice(index, 1); + } + } + + async getReviewedFiles(): Promise<IReviewedFileRecord[]> { + return [...this._reviewedFiles]; + } + + async getReviewedFilesForUri(uri: URI): Promise<IReviewedFileRecord[]> { + return this._reviewedFiles.filter(r => r.uri.toString() === uri.toString()); + } + + async isFileReviewed(uri: URI, nonce: string): Promise<boolean> { + return this._reviewedFiles.some(r => r.uri.toString() === uri.toString() && r.nonce === nonce); + } + async setTurnCheckpointRef(_turnId: string, _ref: string): Promise<void> { } async getTurnCheckpointRef(_turnId: string): Promise<string | undefined> { return undefined; } @@ -213,16 +253,51 @@ export function createNoopGitService(): import('../../common/agentHostGitService push: async () => { }, getSessionGitState: async () => undefined, computeSessionFileDiffs: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, showBlob: async () => undefined, captureWorkingTreeAsTree: async () => undefined, commitTree: async () => undefined, updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; } +/** + * Returns a no-op {@link IAgentHostChangesetService} for tests that need to + * inject the changeset service but don't exercise changeset computation. + * Individual methods can be reassigned by callers that want to spy on them. + */ +export function createNoopChangesetService(): import('../../common/agentHostChangesetService.js').IAgentHostChangesetService { + return { + _serviceBrand: undefined, + registerStaticChangesets: () => { }, + restoreStaticChangeset: () => { }, + parsePersistedStaticChangesets: () => ({}), + applyPersistedStaticChangesets: () => { }, + restorePersistedStaticChangesets: () => ({}), + persistChangesSummary: () => { }, + getListMetadataKeys: () => undefined, + computeListEntryChanges: () => undefined, + isStaticChangesetComputeActive: () => false, + refreshChangesetCatalog: () => { }, + refreshBranchChangeset: () => { }, + refreshSessionChangeset: () => { }, + onWorkingDirectoryAvailable: () => { }, + recomputeSubscribedChangesets: () => { }, + onSessionDisposed: () => { }, + computeTurnChangeset: async session => session, + computeCompareTurnsChangeset: async session => session, + computeUncommittedChangeset: async session => session, + onToolCallEditsApplied: () => { }, + onTurnComplete: () => { }, + onSessionTruncated: () => { }, + }; +} + function createReference<T>(object: T): IReference<T> { return { object, diff --git a/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts new file mode 100644 index 00000000000000..155fbe30147e3d --- /dev/null +++ b/src/vs/platform/agentHost/test/common/sessionWorkspacelessMeta.test.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { readSessionWorkspaceless, SESSION_META_WORKSPACELESS_KEY, withSessionGitHubState, withSessionWorkspaceless } from '../../common/state/sessionState.js'; + +suite('Session workspace-less meta', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('readSessionWorkspaceless returns false for absent / non-true values', () => { + assert.strictEqual(readSessionWorkspaceless(undefined), false); + assert.strictEqual(readSessionWorkspaceless({}), false); + assert.strictEqual(readSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: false }), false); + assert.strictEqual(readSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: 'true' }), false); + }); + + test('withSessionWorkspaceless round-trips the marker and preserves other slots', () => { + const withOther = withSessionGitHubState(undefined, { owner: 'octo' }); + const tagged = withSessionWorkspaceless(withOther, true); + + assert.strictEqual(readSessionWorkspaceless(tagged), true); + // Co-existing well-known slots are preserved. + assert.deepStrictEqual(tagged?.['github'], { owner: 'octo' }); + + // Clearing removes only the workspace-less slot; an otherwise empty bag collapses to undefined. + assert.strictEqual(withSessionWorkspaceless(tagged, false)?.[SESSION_META_WORKSPACELESS_KEY], undefined); + assert.strictEqual(withSessionWorkspaceless({ [SESSION_META_WORKSPACELESS_KEY]: true }, false), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts new file mode 100644 index 00000000000000..d27958bfbdfe8c --- /dev/null +++ b/src/vs/platform/agentHost/test/electron-browser/localAgentHostService.test.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { IChannelServer, IServerChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AGENT_HOST_CLIENT_RESOURCE_CHANNEL } from '../../common/agentHostClientResourceChannel.js'; +import { AGENT_HOST_CLIENT_PROXY_CHANNEL } from '../../common/agentHostClientProxyChannel.js'; +import { AGENT_HOST_CLIENT_BYOK_LM_CHANNEL, AgentHostClientByokLmChannel } from '../../common/agentHostClientByokLmChannel.js'; +import { registerAgentHostClientChannels } from '../../electron-browser/localAgentHostService.js'; + +/** + * Regression coverage for the renderer reverse-RPC channel registration. The + * BYOK language-model bridge depends on `IAgentHostByokLmHandler`, registered by + * the chat contribution of every window that backs BYOK (the main workbench and + * the Agents app). The registration must still degrade gracefully should a + * window ever connect without binding the handler — `createInstance` then throws, + * and that must NOT abort the rest of `_connect` (client completion, + * action/notification wiring, root-state subscription), or the whole window loses + * its agent host. + */ +suite('registerAgentHostClientChannels', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function fakeChannelServer(): { server: IChannelServer; registered: string[] } { + const registered: string[] = []; + const server: IChannelServer = { + registerChannel: (name: string, _channel: IServerChannel) => { registered.push(name); }, + }; + return { server, registered }; + } + + /** + * Minimal {@link IInstantiationService} whose `createInstance` throws for the + * BYOK channel when `byokHandlerMissing`, mirroring the strict "UNKNOWN + * service agentHostByokLmHandler" failure in windows without the handler. + */ + function fakeInstantiationService(byokHandlerMissing: boolean): IInstantiationService { + return { + createInstance: (ctor: unknown) => { + if (ctor === AgentHostClientByokLmChannel && byokHandlerMissing) { + throw new Error('[createInstance] AgentHostClientByokLmChannel depends on UNKNOWN service agentHostByokLmHandler.'); + } + return {}; + }, + } as unknown as IInstantiationService; + } + + test('registers both channels when BYOK is enabled and the handler is available', () => { + const { server, registered } = fakeChannelServer(); + registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, true); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL, AGENT_HOST_CLIENT_BYOK_LM_CHANNEL]); + }); + + test('registers only the resource and proxy channels and does NOT throw when the BYOK handler is missing', () => { + const { server, registered } = fakeChannelServer(); + // Must not throw: the agent host connection has to come up even if a + // window connects without the handler and so cannot serve BYOK itself. + registerAgentHostClientChannels(server, fakeInstantiationService(true), new NullLogService(), undefined, true); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL]); + }); + + test('registers only the resource and proxy channels when BYOK is disabled', () => { + const { server, registered } = fakeChannelServer(); + registerAgentHostClientChannels(server, fakeInstantiationService(false), new NullLogService(), undefined, false); + assert.deepStrictEqual(registered, [AGENT_HOST_CLIENT_RESOURCE_CHANNEL, AGENT_HOST_CLIENT_PROXY_CHANNEL]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts b/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts new file mode 100644 index 00000000000000..48c723c3452a74 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostBangCommand.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { parseBangCommand } from '../../node/agentHostBangCommand.js'; + +suite('agentHostBangCommand', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('parses a leading ! command', () => { + assert.strictEqual(parseBangCommand('!echo hi'), 'echo hi'); + assert.strictEqual(parseBangCommand('!ls -la'), 'ls -la'); + assert.strictEqual(parseBangCommand('! npm test '), 'npm test'); + }); + + test('is undefined for non-bang messages', () => { + assert.strictEqual(parseBangCommand('echo hi'), undefined); + assert.strictEqual(parseBangCommand(' !echo hi'), undefined); + assert.strictEqual(parseBangCommand('run !echo'), undefined); + }); + + test('is undefined for a lone ! or whitespace-only command', () => { + assert.strictEqual(parseBangCommand('!'), undefined); + assert.strictEqual(parseBangCommand('! '), undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts index 4ca135c746ead8..7f0701c5f9e049 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetCoordinator.test.ts @@ -11,7 +11,7 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; +import { buildDefaultChangesetCatalog, buildSessionChangesetUri, buildUncommittedChangesetUri, ChangesetKind, parseChangesetUri } from '../../common/changesetUri.js'; import { ActionType } from '../../common/state/sessionActions.js'; import { buildSubagentSessionUri, SessionStatus, type ISessionFileDiff, type ISessionGitHubState } from '../../common/state/sessionState.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -44,7 +44,7 @@ suite('ChangesetSessionCoordinator', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }, { emitNotification }); - stateManager.setSessionChangesets(session, buildDefaultChangesetCatalogue(session)); + stateManager.setSessionChangesets(session, buildDefaultChangesetCatalog(session)); stateManager.dispatchServerAction(session, { type: ActionType.SessionReady }); } @@ -68,7 +68,7 @@ suite('ChangesetSessionCoordinator', () => { const operationContributionService: IAgentHostChangesetOperationService = { _serviceBrand: undefined, registerContribution: () => Disposable.None, - getOperations: () => undefined, + getOperations: () => [], updateOperations: () => { }, invokeChangesetOperation: async () => ({}), dispose: () => { }, @@ -109,13 +109,11 @@ suite('ChangesetSessionCoordinator', () => { acquisitions: environment.monitor.acquisitions, branchRefreshes: environment.changesets.branchRefreshes, uncommittedRefreshes: environment.changesets.uncommittedRefreshes, - sessionRefreshes: environment.changesets.sessionRefreshes, gitStateRefreshes: environment.gitStateService.refreshed, }, { acquisitions: ['file:///repo'], branchRefreshes: [firstSession], uncommittedRefreshes: [secondSession], - sessionRefreshes: [firstSession], gitStateRefreshes: [firstSession, secondSession], }); }); @@ -451,6 +449,7 @@ class TestChangesetService implements IAgentHostChangesetService { restorePersistedStaticChangesets(_sessionUri: string, _metadata: IPersistedChangesetMetadata): IRestoredChangesetDiffs { return {}; } persistChangesSummary(_sessionUri: string, _summary: ChangesSummary): void { } isStaticChangesetComputeActive(_changesetUri: string): boolean { return false; } + refreshChangesetCatalog(_session: string): void { } refreshBranchChangeset(session: string): void { this.branchRefreshes.push(session); } diff --git a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts index 287465505fbf8f..cb581ed2d29c30 100644 --- a/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostChangesetService.test.ts @@ -11,13 +11,14 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; -import { buildBranchChangesetUri, buildDefaultChangesetCatalogue, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; +import { buildBranchChangesetUri, buildDefaultChangesetCatalog, buildSessionChangesetUri, buildTurnChangesetUri, buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { ActionEnvelope, ActionType } from '../../common/state/sessionActions.js'; import { ChangesetStatus, SessionStatus, withSessionGitState, type Changeset } from '../../common/state/sessionState.js'; import { AgentHostChangesetService } from '../../node/agentHostChangesetService.js'; import { IAgentHostChangesetSubscriptionService } from '../../common/agentHostChangesetSubscriptionService.js'; import { IAgentHostChangesetOperationService } from '../../common/agentHostChangesetOperationService.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -77,7 +78,7 @@ suite.skip('AgentHostChangesetService', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -92,6 +93,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), + NULL_REVIEW_SERVICE, )); }); @@ -276,7 +278,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); localStateManager.createSession({ resource: sessionUri.toString(), @@ -354,7 +356,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -390,7 +392,7 @@ suite.skip('AgentHostChangesetService', () => { }, } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -423,7 +425,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); localStateManager.createSession({ resource: sessionUri.toString(), @@ -483,7 +485,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService())); + localStateManager, new NullLogService(), sessionDataService, stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -557,7 +559,7 @@ suite.skip('AgentHostChangesetService', () => { } as unknown as IAgentHostGitService; const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); const localChangesets = disposables.add(new AgentHostChangesetService( - localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())))); + localStateManager, new NullLogService(), createNullSessionDataService(), stubGit, NULL_CHECKPOINT_SERVICE, disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildUncommittedChangesetUri(sessionUri.toString())), NULL_REVIEW_SERVICE)); const sessionStr = sessionUri.toString(); localStateManager.createSession({ @@ -606,6 +608,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), subscriptionService, + NULL_REVIEW_SERVICE, )); return { service, localStateManager, computes, subscriptions: subscriptionService.subscriptions }; } @@ -621,7 +624,7 @@ suite.skip('AgentHostChangesetService', () => { modifiedAt: new Date().toISOString(), workingDirectory, }); - localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalogue(sessionStr)); + localStateManager.setSessionChangesets(sessionStr, buildDefaultChangesetCatalog(sessionStr)); return sessionStr; } @@ -927,6 +930,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), subscriptionService, + NULL_REVIEW_SERVICE, )); } test('onTurnComplete schedules a per-turn recompute when someone is subscribed', async () => { @@ -1034,6 +1038,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(localStateManager, new NullLogService())), createOperationService(), createSubscriptionService(buildTurnChangesetUri(sessionUri.toString(), 'turn-1')), + NULL_REVIEW_SERVICE, )); localStateManager.createSession({ @@ -1114,6 +1119,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1147,6 +1153,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1177,6 +1184,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); @@ -1208,6 +1216,7 @@ suite.skip('AgentHostChangesetService', () => { disposables.add(new AgentConfigurationService(stateManager, new NullLogService())), createOperationService(), createSubscriptionService(), + NULL_REVIEW_SERVICE, )); const compareUri = await svc.computeCompareTurnsChangeset(sessionStr, 'orig', 'mod'); diff --git a/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts new file mode 100644 index 00000000000000..0912e644c04a67 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostClientByokLmChannel.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Event } from '../../../../base/common/event.js'; +import type { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IAgentHostByokLmHandler, IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { AgentHostClientByokLmChannel, createAgentHostClientByokLmConnection } from '../../common/agentHostClientByokLmChannel.js'; + +suite('agentHostClientByokLmChannel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function handlerOf( + chat: (request: IByokLmChatRequest) => Promise<IByokLmChatResult>, + listModels: () => Promise<IByokLmModelInfo[]> = async () => [], + ): IAgentHostByokLmHandler { + return { _serviceBrand: undefined, chat: (request) => chat(request), listModels: () => listModels() }; + } + + /** + * Wire the node-side connection straight to the renderer server channel, + * standing in for the MessagePort transport so the full request → handler → + * response round-trip can be exercised without the renderer or the SDK. + */ + function bridge(handler: IAgentHostByokLmHandler) { + const server = new AgentHostClientByokLmChannel(handler); + const channel: IChannel = { + call<T>(command: string, arg?: unknown): Promise<T> { + return server.call<T>(null, command, arg); + }, + listen<T>(event: string): Event<T> { + return server.listen<T>(null, event); + }, + }; + return createAgentHostClientByokLmConnection(channel); + } + + test('round-trips a chat request to the handler and back', async () => { + let seen: IByokLmChatRequest | undefined; + const connection = bridge(handlerOf(async (request) => { + seen = request; + return { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }; + })); + + const request: IByokLmChatRequest = { vendor: 'acme', modelId: 'm', messages: [{ role: 'user', content: 'ping' }] }; + const result = await connection.chat(request); + + assert.deepStrictEqual(seen, request); + assert.deepStrictEqual(result, { content: 'pong', toolCalls: [{ id: 'c1', name: 'noop', argumentsJson: '{}' }] }); + }); + + test('forwards a bridge error result unchanged', async () => { + const connection = bridge(handlerOf(async () => ({ content: '', error: 'no model' }))); + const result = await connection.chat({ vendor: 'v', modelId: 'm', messages: [] }); + assert.strictEqual(result.error, 'no model'); + }); + + test('round-trips listModels to the handler', async () => { + const models: IByokLmModelInfo[] = [{ vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 128000 }]; + const connection = bridge(handlerOf(async () => ({ content: '' }), async () => models)); + assert.deepStrictEqual(await connection.listModels(), models); + }); + + test('rejects unknown channel commands', async () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + await assert.rejects(() => server.call(null, 'frobnicate'), /Unknown command/); + }); + + test('exposes no events', () => { + const server = new AgentHostClientByokLmChannel(handlerOf(async () => ({ content: '' }))); + assert.throws(() => server.listen(null, 'anything'), /No event/); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts index 232953961ea6cf..800ef96a12f6a1 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationHandler.test.ts @@ -15,6 +15,7 @@ import { buildUncommittedChangesetUri } from '../../common/changesetUri.js'; import { SessionStatus, withSessionGitState, type ISessionFileDiff } from '../../common/state/sessionState.js'; import type { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentHostCommitOperationHandler } from '../../node/agentHostCommitOperationHandler.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { CopilotApiError, type ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; import { GITHUB_COPILOT_PROTECTED_RESOURCE, IAgentService } from '../../common/agentService.js'; @@ -64,6 +65,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise<void> { } async deleteRefs(): Promise<void> { } async revParse(): Promise<string | undefined> { return undefined; } + async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; } + async overlayPathIntoTree(): Promise<string | undefined> { return undefined; } + async diffTreePaths(): Promise<string[] | undefined> { return undefined; } async computeFileDiffsBetweenRefs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; } } @@ -88,6 +92,8 @@ class TestCopilotApiService implements ICopilotApiService { } async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); } async models(): Promise<CCAModel[]> { return []; } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> { this.calls.push({ token: githubToken, request, options }); if (this.error) { @@ -110,6 +116,7 @@ class TestChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record<string, true> | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record<string, string | undefined>): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { this.calls.push(`refreshChangesets:${session}`); } refreshBranchChangeset(session: string): void { this.calls.push(`refreshBranch:${session}`); } refreshSessionChangeset(session: string): void { this.calls.push(`refreshSession:${session}`); } onWorkingDirectoryAvailable(_session: string): void { } @@ -153,7 +160,7 @@ function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitSer if (options?.onCommittedError) { throw options.onCommittedError; } - }, createAgentService('gh-repo-token'), gitService, copilotApiService, new NullLogService()), + }, createAgentService('gh-repo-token'), createTestGitHubEndpointService(), gitService, copilotApiService, new NullLogService()), session, committedSessions, }; diff --git a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts index 3a699875a059f0..863a3694dbc45e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostCommitOperationProvider.test.ts @@ -43,7 +43,7 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: uncommittedChangesetUri, changesetKind: ChangesetKind.Uncommitted, gitState: { ...gitStateWithUncommittedChanges, uncommittedChanges: 0 } }); - assert.strictEqual(operations, undefined); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); test('advertises commit on the session changeset when there are uncommitted changes', () => { @@ -51,6 +51,6 @@ suite('AgentHostCommitOperationContribution', () => { const operations = provider.getOperations({ sessionKey, changesetUri: buildSessionChangesetUri(sessionKey), changesetKind: ChangesetKind.Session, gitState: gitStateWithUncommittedChanges }); - assert.deepStrictEqual(operations?.map(op => op.id), ['commit']); + assert.deepStrictEqual(operations?.map(op => op.id), []); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts index e8f5f65d23fbcf..27bf7e89a3f500 100644 --- a/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostDiscardChangesOperationHandler.test.ts @@ -51,6 +51,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise<void> { } async deleteRefs(): Promise<void> { } async revParse(): Promise<string | undefined> { return undefined; } + async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; } + async overlayPathIntoTree(): Promise<string | undefined> { return undefined; } + async diffTreePaths(): Promise<string[] | undefined> { return undefined; } async computeFileDiffsBetweenRefs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/agentHostGitHubEndpointService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitHubEndpointService.test.ts new file mode 100644 index 00000000000000..926ec1057e7f65 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostGitHubEndpointService.test.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +import { AgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { AgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; + +suite('AgentHostGitHubEndpointService', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(enterpriseUri?: string): { service: AgentHostGitHubEndpointService; configService: AgentConfigurationService } { + const logService = new NullLogService(); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + if (enterpriseUri !== undefined) { + configService.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: enterpriseUri }); + } + const service = disposables.add(new AgentHostGitHubEndpointService(configService, logService)); + return { service, configService }; + } + + function snapshot(service: AgentHostGitHubEndpointService) { + return { + api: service.getApiBaseUri(), + graphql: service.getGraphQlUri(), + copilotResource: service.getCopilotResource().resource, + repoResource: service.getRepoResource().resource, + oauth: service.getCopilotResource().authorization_servers, + enterpriseHost: service.getEnterpriseHost(), + }; + } + + test('defaults to github.com when unset', () => { + const { service } = createService(); + assert.deepStrictEqual(snapshot(service), { + api: 'https://api.github.com', + graphql: 'https://api.github.com/graphql', + copilotResource: 'https://api.github.com', + repoResource: 'https://api.github.com/repos', + oauth: ['https://github.com/login/oauth'], + enterpriseHost: undefined, + }); + }); + + test('computes enterprise resources and endpoints when set', () => { + const { service } = createService('https://ghe.acme.com'); + assert.deepStrictEqual(snapshot(service), { + api: 'https://ghe.acme.com/api/v3', + graphql: 'https://ghe.acme.com/api/graphql', + copilotResource: 'https://ghe.acme.com/api/v3', + repoResource: 'https://ghe.acme.com/api/v3/repos', + oauth: ['https://ghe.acme.com/login/oauth'], + enterpriseHost: 'ghe.acme.com', + }); + }); + + test('onDidChange fires only when the enterprise URI actually changes', () => { + const { service, configService } = createService(); + let fires = 0; + disposables.add(service.onDidChange(() => fires++)); + + // An unrelated root-config change must NOT fire. + configService.updateRootConfig({ [AgentHostConfigKey.ClaudeUseCopilotProxy]: false }); + assert.strictEqual(fires, 0); + + // Setting the enterprise URI fires once and repoints the endpoints. + configService.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://ghe.acme.com' }); + assert.strictEqual(fires, 1); + assert.strictEqual(service.getApiBaseUri(), 'https://ghe.acme.com/api/v3'); + + // Re-applying the same URI must NOT fire again. + configService.updateRootConfig({ [AgentHostConfigKey.GithubEnterpriseUri]: 'https://ghe.acme.com' }); + assert.strictEqual(fires, 1); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts index eff82ceeaf7b61..fc431c4329ed7e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.integrationTest.ts @@ -371,10 +371,10 @@ suite('AgentHostGitService - computeSessionFileDiffs (real git)', () => { await fs.writeFile(join(dir, 'a.txt'), 'original\n'); run('add', '.'); run('commit', '-q', '-m', 'init'); - const sha = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); + const ref = cp.execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(); await fs.writeFile(join(dir, 'a.txt'), 'changed\n'); - const blob = await svc!.showBlob(URI.file(dir), sha, 'a.txt'); + const blob = await svc!.showBlob(URI.file(dir), ref, 'a.txt'); assert.ok(blob); assert.strictEqual(blob.toString(), 'original\n'); }); @@ -580,3 +580,179 @@ suite('AgentHostGitService - restore (real git)', () => { await assert.rejects(() => svc!.restore(URI.file(dir), ['a.txt'])); }); }); + +suite('AgentHostGitService - overlayPathIntoTree (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + let tmpRoot: string | undefined; + let svc: AgentHostGitService | undefined; + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + + setup(() => { + tmpRoot = undefined; + svc = createGitService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + async function initRepoWithFiles(files: Record<string, string>): Promise<{ dir: string; run: (...args: string[]) => Buffer }> { + tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-overlay-')); + const fs = await import('fs/promises'); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(join(tmpRoot!, name), content); + } + run('add', '.'); + run('commit', '-q', '-m', 'init'); + return { dir: tmpRoot!, run }; + } + + const headTree = (dir: string) => cp.execFileSync('git', ['rev-parse', 'HEAD^{tree}'], { cwd: dir, env, encoding: 'utf8' }).trim(); + const lsTree = (dir: string, tree: string) => cp.execFileSync('git', ['ls-tree', '-r', '--name-only', tree], { cwd: dir, env, encoding: 'utf8' }).trim().split('\n').filter(Boolean); + const blobAt = (dir: string, tree: string, path: string) => cp.execFileSync('git', ['cat-file', 'blob', `${tree}:${path}`], { cwd: dir, env, encoding: 'utf8' }); + + (hasGit ? test : test.skip)('overlays a modified path from the source tree, leaving other paths untouched', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + const base = headTree(dir); + + // Working tree modifies a.txt only; capture it as the source tree. + await fs.writeFile(join(dir, 'a.txt'), 'a-v2\n'); + const source = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(source, 'expected a working-tree snapshot'); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base, 'a.txt', source!); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual( + { + files: lsTree(dir, result!), + aContent: blobAt(dir, result!, 'a.txt'), + bContent: blobAt(dir, result!, 'b.txt'), + }, + { + files: ['a.txt', 'b.txt'], + aContent: 'a-v2\n', // overlaid from the source tree + bContent: 'b-v1\n', // copied verbatim from the base tree + }); + }); + + (hasGit ? test : test.skip)('overlays an added path from the source tree', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n' }); + const base = headTree(dir); + + // Working tree adds an untracked file; capture it as the source tree. + await fs.writeFile(join(dir, 'fresh.txt'), 'fresh\n'); + const source = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(source, 'expected a working-tree snapshot'); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base, 'fresh.txt', source!); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual( + { files: lsTree(dir, result!), freshContent: blobAt(dir, result!, 'fresh.txt') }, + { files: ['a.txt', 'fresh.txt'], freshContent: 'fresh\n' }); + }); + + (hasGit ? test : test.skip)('removes a path absent from the source tree', async () => { + const fs = await import('fs/promises'); + const { dir } = await initRepoWithFiles({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + + // Base = working tree that includes an untracked file; source = HEAD tree + // (which lacks it). Overlaying that path removes it from the base. + await fs.writeFile(join(dir, 'fresh.txt'), 'fresh\n'); + const base = await svc!.captureWorkingTreeAsTree(URI.file(dir)); + assert.ok(base, 'expected a working-tree snapshot'); + const source = headTree(dir); + + const result = await svc!.overlayPathIntoTree(URI.file(dir), base!, 'fresh.txt', source); + assert.ok(result, 'expected a result tree'); + + assert.deepStrictEqual(lsTree(dir, result!), ['a.txt', 'b.txt']); + }); + + (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-overlay-')); + tmpRoot = dir; + const result = await svc!.overlayPathIntoTree(URI.file(dir), 'HEAD', 'a.txt', 'HEAD'); + assert.strictEqual(result, undefined); + }); +}); + +suite('AgentHostGitService - resolveBranchBaselineCommit (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + let tmpRoot: string | undefined; + let svc: AgentHostGitService | undefined; + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + + setup(() => { + tmpRoot = undefined; + svc = createGitService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + function initRepo(): (...args: string[]) => Buffer { + tmpRoot = mkdtempSync(join(tmpdir(), 'agent-host-git-baseline-')); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + return run; + } + + (hasGit ? test : test.skip)('returns the merge-base of HEAD and the base branch', async () => { + const fs = await import('fs/promises'); + const run = initRepo(); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'base\n'); + run('add', '.'); + run('commit', '-q', '-m', 'base'); + const baseCommit = run('rev-parse', 'HEAD').toString().trim(); + + // Diverge onto a feature branch with an extra commit. + run('checkout', '-q', '-b', 'feature'); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'feature\n'); + run('commit', '-q', '-am', 'feature'); + + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!), 'main'); + assert.strictEqual(result, baseCommit); + }); + + (hasGit ? test : test.skip)('falls back to HEAD when no base branch is given', async () => { + const fs = await import('fs/promises'); + const run = initRepo(); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'base\n'); + run('add', '.'); + run('commit', '-q', '-m', 'base'); + const headCommit = run('rev-parse', 'HEAD').toString().trim(); + + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!)); + assert.strictEqual(result, headCommit); + }); + + (hasGit ? test : test.skip)('falls back to the empty tree for a repo with no commits', async () => { + initRepo(); + const result = await svc!.resolveBranchBaselineCommit(URI.file(tmpRoot!)); + assert.strictEqual(result, '4b825dc642cb6eb9a060e54bf8d69288fbee4904'); + }); + + (hasGit ? test : test.skip)('returns undefined for a non-git directory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-nongit-baseline-')); + tmpRoot = dir; + const result = await svc!.resolveBranchBaselineCommit(URI.file(dir), 'main'); + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts index f8c974e35824fe..de63295160c6ea 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitService.test.ts @@ -5,10 +5,10 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; +import { formatGitError, parseChangedPaths, parseDefaultBranchRef, parseGitDiffRawNumstat, parseGitHubRepoFromRemote, parseGitStatusV2, parseHasGitHubRemote, parseSingleLsTreeEntry, parseUntrackedPaths, summarizeStderrForError } from '../../node/agentHostGitService.js'; import { buildGitBlobUri } from '../../node/gitDiffContent.js'; import { URI } from '../../../../base/common/uri.js'; -import { EMPTY_TREE_OBJECT, getBranchCompletions } from '../../common/agentHostGitService.js'; +import { EMPTY_TREE_OBJECT, getBranchCompletions, resolveDiffBaseBranchName } from '../../common/agentHostGitService.js'; suite('AgentHostGitService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -389,4 +389,37 @@ suite('AgentHostGitService', () => { assert.ok(result.endsWith('…'), 'expected trailing ellipsis'); }); }); + + suite('resolveDiffBaseBranchName', () => { + test('prefers the persisted base branch, then git state, then undefined', () => { + assert.deepStrictEqual( + [ + resolveDiffBaseBranchName('persisted', 'gitState'), + resolveDiffBaseBranchName(undefined, 'gitState'), + resolveDiffBaseBranchName('persisted', undefined), + resolveDiffBaseBranchName(undefined, undefined), + ], + ['persisted', 'gitState', 'persisted', undefined], + ); + }); + }); + + suite('parseSingleLsTreeEntry', () => { + test('parses mode/oid and treats empty output as absent', () => { + assert.deepStrictEqual( + [ + parseSingleLsTreeEntry('100644 blob e69de29bb2d1d6434b8b29ae775ad8c2e48c5391\ta.txt\x00'), + parseSingleLsTreeEntry('100755 blob abc123\tdir/with space.txt\x00'), + parseSingleLsTreeEntry(''), + parseSingleLsTreeEntry(undefined), + ], + [ + { mode: '100644', oid: 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' }, + { mode: '100755', oid: 'abc123' }, + undefined, + undefined, + ], + ); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts index 5d12fdbbbe9796..33e09f24fc4870 100644 --- a/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostGitStateService.test.ts @@ -13,6 +13,7 @@ import type { IAgentService } from '../../common/agentService.js'; import { readSessionGitHubState, readSessionGitState, withSessionGitState, SessionStatus, type ISessionGitState, type SessionSummary } from '../../common/state/sessionState.js'; import { META_GIT_STATE } from '../../common/agentHostGitStateService.js'; import { AgentHostGitStateService } from '../../node/agentHostGitStateService.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import { TestSessionDatabase, createNoopGitService, createSessionDataService } from '../common/sessionTestHelpers.js'; @@ -50,6 +51,7 @@ suite('AgentHostGitStateService', () => { gitService, {} as unknown as IAgentHostOctoKitService, {} as unknown as IAgentService, + createTestGitHubEndpointService(), new NullLogService(), sessionDataService, )); @@ -101,6 +103,32 @@ suite('AgentHostGitStateService', () => { }); }); + test('defers git state while a session is creating', async () => { + await runWithFakedTimers({ useFakeTimers: true }, async () => { + const h = createHarness(); + h.stateManager.createSession({ + resource: SESSION, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date(0).toISOString(), + modifiedAt: new Date(0).toISOString(), + workingDirectory: 'file:///original', + }, { emitNotification: false }); + h.setGitResult({ branchName: 'feature' }); + + await h.service.refreshSessionGitState(SESSION, URI.parse('file:///explicit')); + + assert.deepStrictEqual({ + gitCalls: h.gitCalls, + runEvents: h.runEvents, + }, { + gitCalls: [], + runEvents: [], + }); + }); + }); + test('resolves the working directory from the session summary when none is provided', async () => { await runWithFakedTimers({ useFakeTimers: true }, async () => { const h = createHarness(); @@ -176,22 +204,6 @@ suite('AgentHostGitStateService', () => { }); }); - test('git returning undefined leaves the session untouched and fires no events', async () => { - const h = createHarness(); - seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); - h.setGitResult(undefined); - - await h.service.refreshSessionGitState(SESSION, undefined); - - assert.deepStrictEqual({ - gitState: readSessionGitState(h.stateManager.getSessionState(SESSION)?._meta), - runEvents: h.runEvents, - }, { - gitState: undefined, - runEvents: [], - }); - }); - test('swallows git errors and fires no events', async () => { const h = createHarness(); seedSession(h.stateManager, { workingDirectory: WORKING_DIRECTORY }); diff --git a/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts new file mode 100644 index 00000000000000..596cb5307eb964 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostLocalTurns.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { NullLogService } from '../../../log/common/log.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { MessageKind, TurnState, type Turn } from '../../common/state/sessionState.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { TestSessionDatabase, createSessionDataService } from '../common/sessionTestHelpers.js'; + +suite('AgentHostLocalTurns', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'mock:/session-1'; + + function turn(id: string): Turn { + return { id, message: { text: id, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete }; + } + + test('records, resolves anchors, persists, and deletes local turns', async () => { + const db = new TestSessionDatabase(); + const registry = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const chat = 'ahp-chat://default/xyz'; + + // Two locals: one anchored to a real turn, one anchored before any real turn. + registry.record(session, chat, turn('local-a'), 'real-1'); + registry.record(session, chat, turn('local-b'), undefined); + + assert.strictEqual(registry.isLocal(chat, 'local-a'), true); + assert.strictEqual(registry.isLocal(chat, 'real-1'), false); + // Local resolves to its concrete anchor; a concrete turn resolves to itself. + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'local-a'), 'real-1'); + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'local-b'), undefined); + assert.strictEqual(registry.resolveConcreteTurnId(chat, 'real-1'), 'real-1'); + assert.deepStrictEqual(new Set(registry.getLocalTurnIds(chat)), new Set(['local-a', 'local-b'])); + + // Persisted to the database with the chat discriminator. + const persisted = await db.getLocalTurns(); + assert.deepStrictEqual(persisted.map(r => ({ turnId: r.turnId, chatUri: r.chatUri, anchorTurnId: r.anchorTurnId })), [ + { turnId: 'local-a', chatUri: chat, anchorTurnId: 'real-1' }, + { turnId: 'local-b', chatUri: chat, anchorTurnId: undefined }, + ]); + + // Delete removes from memory and the database. + registry.deleteLocals(session, ['local-a']); + assert.strictEqual(registry.isLocal(chat, 'local-a'), false); + assert.deepStrictEqual((await db.getLocalTurns()).map(r => r.turnId), ['local-b']); + }); + + test('load re-populates the in-memory index from the database, scoped per chat', async () => { + const db = new TestSessionDatabase(); + const chatA = 'ahp-chat://default/a'; + const chatB = 'ahp-chat://peer/b'; + await db.insertLocalTurn({ turnId: 'local-x', chatUri: chatA, anchorTurnId: 'real-9', seq: 3, payload: JSON.stringify(turn('local-x')) }); + await db.insertLocalTurn({ turnId: 'local-y', chatUri: chatB, anchorTurnId: undefined, seq: 4, payload: JSON.stringify(turn('local-y')) }); + + const registry = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const recordsA = await registry.loadForChat(session, chatA); + + // loadForChat returns only the requested chat's records... + assert.deepStrictEqual(recordsA.map(r => r.turnId), ['local-x']); + // ...but the in-memory index is populated for every chat in the session. + assert.strictEqual(registry.isLocal(chatA, 'local-x'), true); + assert.strictEqual(registry.resolveConcreteTurnId(chatA, 'local-x'), 'real-9'); + assert.strictEqual(registry.isLocal(chatB, 'local-y'), true); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts index 554800a8bd96e7..8cb10847ae8a43 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPromptRegistry.test.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import type { SectionOverride, SystemMessageSection } from '@github/copilot-sdk'; -import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; +import type { SectionOverride, SystemMessageConfig, SystemMessageSection } from '@github/copilot-sdk'; +import { CopilotCliConfigKey, applyModelFamilyAlias, copilotCliConfigSchema } from '../../common/copilotCliConfig.js'; import type { SchemaValues } from '../../common/agentHostSchema.js'; import type { ModelSelection } from '../../common/state/protocol/state.js'; import { AgentHostPromptRegistry, agentHostPromptRegistry, type IAgentHostPromptContext } from '../../node/copilot/prompts/promptRegistry.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; +import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS, COPILOT_AGENT_HOST_SYSTEM_MESSAGE } from '../../node/copilot/prompts/systemMessage.js'; import { BrowserChatToolReferenceName } from '../../../browserView/common/browserChatToolReferenceNames.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import '../../node/copilot/prompts/allPrompts.js'; @@ -18,11 +18,12 @@ import '../../node/copilot/prompts/allPrompts.js'; * Builds a prompt context backed by an in-memory bag of customization settings * and an optional set of available tool names. */ -function context(settings: SchemaValues<typeof agentHostCustomizationConfigSchema.definition> = {}, tools: readonly string[] = []): IAgentHostPromptContext { +function context(settings: SchemaValues<typeof copilotCliConfigSchema.definition> = {}, tools: readonly string[] = [], workspaceless = false): IAgentHostPromptContext { const toolNames = new Set(tools); return { getSetting: key => settings[key], hasClientTool: name => toolNames.has(name), + workspaceless, }; } @@ -30,14 +31,19 @@ suite('AgentHostPromptRegistry', () => { ensureNoDisposablesAreLeakedInTestSuite(); + const withFileLinkInstructions = (config: SystemMessageConfig): SystemMessageConfig => ({ + ...config, + content: config.content ? `${config.content}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}` : COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, + }); + test('falls back to the default system message when no model is provided', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig(undefined, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('falls back to the default when no contributor matches the model', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'unknown-model' }, context()), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('a contributor can fully replace the system prompt (replace mode)', () => { @@ -64,7 +70,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), - { mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } } + withFileLinkInstructions({ mode: 'customize', sections: { guidelines: { action: 'append', content: 'Be concise.' } } }) ); }); @@ -78,7 +84,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context()), - COPILOT_AGENT_HOST_SYSTEM_MESSAGE + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) ); }); @@ -104,16 +110,16 @@ suite('AgentHostPromptRegistry', () => { registry.registerPrompt(class { static readonly familyPrefixes = ['claude']; resolveSectionOverrides(_model: ModelSelection, ctx: IAgentHostPromptContext): Partial<Record<SystemMessageSection, SectionOverride>> | undefined { - return ctx.getSetting(AgentHostConfigKey.Opus48Prompt) === true ? { tone: { action: 'append', content: 'GATED' } } : undefined; + return ctx.getSetting(CopilotCliConfigKey.Opus48Prompt) === true ? { tone: { action: 'append', content: 'GATED' } } : undefined; } }); assert.deepStrictEqual( - registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [AgentHostConfigKey.Opus48Prompt]: true })), - { mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } } + registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({ [CopilotCliConfigKey.Opus48Prompt]: true })), + withFileLinkInstructions({ mode: 'customize', sections: { tone: { action: 'append', content: 'GATED' } } }) ); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context()), - COPILOT_AGENT_HOST_SYSTEM_MESSAGE + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) ); }); @@ -121,16 +127,85 @@ suite('AgentHostPromptRegistry', () => { const opusModel: ModelSelection = { id: 'claude-opus-4-8' }; function resolveOpus(enabled: boolean | undefined) { - return agentHostPromptRegistry.resolveSystemMessageConfig(opusModel, context(enabled === undefined ? {} : { [AgentHostConfigKey.Opus48Prompt]: enabled })); + return agentHostPromptRegistry.resolveSystemMessageConfig(opusModel, context(enabled === undefined ? {} : { [CopilotCliConfigKey.Opus48Prompt]: enabled })); } test('applies customize overrides only when enabled', () => { - assert.strictEqual(resolveOpus(undefined), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); - assert.strictEqual(resolveOpus(false), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(resolveOpus(undefined), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); + assert.deepStrictEqual(resolveOpus(false), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); assert.strictEqual(resolveOpus(true).mode, 'customize'); }); }); + suite('model capability overrides (family alias)', () => { + // The launcher composes `applyModelFamilyAlias` with the registry (see + // `_buildSessionConfig`); this guards that composition end-to-end using + // the real Opus contributor, whose custom `matchesModel` checks the id. + // The alias helper's own behavior is covered in copilotCliConfig.test.ts. + test('an aliased preview model routes to the family contributor', () => { + const overrides = { 'preview-model-x': { family: 'claude-opus-4-8' } }; + const result = agentHostPromptRegistry.resolveSystemMessageConfig( + applyModelFamilyAlias({ id: 'preview-model-x' }, overrides), + context({ [CopilotCliConfigKey.Opus48Prompt]: true }) + ); + assert.strictEqual(result.mode, 'customize'); + }); + }); + + suite('workspace-less scratch/repoless wiring', () => { + test('appends the scratch instructions to the default config for a workspace-less chat', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig(undefined, context({}, [], true)), + { + mode: 'customize', + sections: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, + } + ); + }); + + test('is a no-op for a workspace-bound session', () => { + const registry = new AgentHostPromptRegistry(); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig(undefined, context({}, [], false)), + withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE) + ); + }); + + test('composes with per-model customize content for a workspace-less chat', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['claude']; + resolveSectionOverrides(): Partial<Record<SystemMessageSection, SectionOverride>> { + return { guidelines: { action: 'append', content: 'Be concise.' } }; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'claude-sonnet' }, context({}, [], true)), + { + mode: 'customize', + sections: { guidelines: { action: 'append', content: 'Be concise.' } }, + content: `${COPILOT_AGENT_HOST_WORKSPACELESS_INSTRUCTIONS}\n\n${COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS}`, + } + ); + }); + + test('does not append scratch instructions to a full replace prompt', () => { + const registry = new AgentHostPromptRegistry(); + registry.registerPrompt(class { + static readonly familyPrefixes = ['gpt-5']; + resolveFullSystemPrompt(): string { + return 'FULL PROMPT'; + } + }); + assert.deepStrictEqual( + registry.resolveSystemMessageConfig({ id: 'gpt-5-mini' }, context({}, [], true)), + { mode: 'replace', content: 'FULL PROMPT' } + ); + }); + }); + suite('universal tool instructions wiring', () => { // The browser line is the registered universal tool-instruction (see // toolInstructions.ts). These guard that the registry layers it end-to-end; @@ -140,20 +215,20 @@ suite('AgentHostPromptRegistry', () => { test('is a no-op when the session exposes no matching tools', () => { const registry = new AgentHostPromptRegistry(); - assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(registry.resolveSystemMessageConfig({ id: 'm' }, context({}, ['anyTool'])), withFileLinkInstructions(COPILOT_AGENT_HOST_SYSTEM_MESSAGE)); }); test('layers the browser tool_instructions onto the default config when browser tools are present', () => { const registry = new AgentHostPromptRegistry(); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'm' }, context({}, browserTools)), - { + withFileLinkInstructions({ mode: 'customize', sections: { identity: COPILOT_AGENT_HOST_SYSTEM_MESSAGE.sections.identity, tool_instructions: { action: 'append', content: `\n${BROWSER_LINE}` }, }, - } + }) ); }); @@ -167,7 +242,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, browserTools)), - { mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } } + withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: `\nAlways prefer ripgrep.\n${BROWSER_LINE}` } } }) ); }); @@ -181,7 +256,7 @@ suite('AgentHostPromptRegistry', () => { }); assert.deepStrictEqual( registry.resolveSystemMessageConfig({ id: 'claude-x' }, context({}, ['anyTool'])), - { mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } } + withFileLinkInstructions({ mode: 'customize', sections: { tool_instructions: { action: 'append', content: 'Always prefer ripgrep.' } } }) ); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts index 8ff35f88976287..ed0de083270efa 100644 --- a/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostPullRequestOperationHandler.test.ts @@ -14,6 +14,7 @@ import { buildSessionChangesetUri } from '../../common/changesetUri.js'; import { withSessionGitHubState, withSessionGitState, type ISessionFileDiff, MessageKind, ResponsePartKind, SessionStatus, TurnState, type Turn } from '../../common/state/sessionState.js'; import type { IAgentHostGitService, IPushOptions } from '../../common/agentHostGitService.js'; import { AgentHostPullRequestOperationHandler } from '../../node/agentHostPullRequestOperationHandler.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import type { AutoMergeMethod, CreatedPullRequest, IAgentHostOctoKitService } from '../../node/shared/agentHostOctoKitService.js'; import type { ICopilotApiService, ICopilotApiServiceRequestOptions, ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; @@ -35,6 +36,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); } async models(): Promise<CCAModel[]> { return []; } async responses(): Promise<Response> { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> { this.calls.push({ token: githubToken, request, options }); if (this.error) { @@ -89,6 +92,9 @@ class TestGitService implements IAgentHostGitService { async updateRef(): Promise<void> { } async deleteRefs(): Promise<void> { } async revParse(): Promise<string | undefined> { return undefined; } + async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; } + async overlayPathIntoTree(): Promise<string | undefined> { return undefined; } + async diffTreePaths(): Promise<string[] | undefined> { return undefined; } async computeFileDiffsBetweenRefs(): Promise<readonly ISessionFileDiff[] | undefined> { return undefined; } } @@ -184,7 +190,7 @@ function setup(disposables: Pick<DisposableStore, 'add'>, gitService: TestGitSer return state; }, event => createdEvents.push(`${event.sessionKey}:${event.pullRequestUrl}`), - createAgentService(options?.withCopilotToken), gitService, octoKitService, copilotApiService, new NullLogService()), + createAgentService(options?.withCopilotToken), gitService, octoKitService, createTestGitHubEndpointService(), copilotApiService, new NullLogService()), session, createdEvents, copilotApiService, diff --git a/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts new file mode 100644 index 00000000000000..1d96162a4aa7db --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostRestrictedTelemetry.test.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { ICommonProperties } from '../../../telemetry/common/telemetry.js'; +import { AgentHostRestrictedTelemetrySender } from '../../node/agentHostRestrictedTelemetry.js'; + +/** The enhanced/restricted iKey (`copilot_v0_restricted_copilot_event`). */ +const GH_ENHANCED_IKEY = '3fdd7f28-937a-48c8-9a21-ba337db23bd1'; + +interface ICapturedPost { + url: string; + iKey: string; +} + +suite('AgentHostRestrictedTelemetrySender', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const commonProperties = {} as ICommonProperties; + + function createSender(): { sender: AgentHostRestrictedTelemetrySender; posts: ICapturedPost[] } { + const posts: ICapturedPost[] = []; + const fetchFn = (async (url: string | URL | Request, init?: RequestInit): Promise<Response> => { + const envelope = JSON.parse(String(init?.body)); + posts.push({ url: String(url), iKey: envelope.iKey }); + return { ok: true, status: 200 } as Response; + }) as typeof globalThis.fetch; + const sender = new AgentHostRestrictedTelemetrySender(commonProperties, new NullLogService(), 'https://default.example/telemetry', undefined, fetchFn); + return { sender, posts }; + } + + test('enhanced GH telemetry is dropped until the token opts in (rt=1), then routes to the enhanced iKey', () => { + const { sender, posts } = createSender(); + + // Public user (rt not opted in): the restricted sink must not emit, even with content. + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + assert.deepStrictEqual(posts, [], 'enhanced telemetry must not be sent without rt opt-in'); + + // Opt in, then flip back off: emits only while enabled, and to the enhanced iKey. + sender.setRestrictedTelemetryEnabled(true); + sender.setRestrictedTelemetryEndpoint('https://ghe.example'); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + sender.setRestrictedTelemetryEnabled(false); + sender.sendEnhancedGHTelemetryEvent('request.options.tools', { messagesJson: 'x' }); + + assert.deepStrictEqual(posts, [{ url: 'https://ghe.example', iKey: GH_ENHANCED_IKEY }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts b/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts new file mode 100644 index 00000000000000..61c694dda20bb4 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostReviewFileOperationHandler.test.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import type { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { buildBranchChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; +import { META_DIFF_BASE_BRANCH } from '../../common/agentHostGitService.js'; +import type { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; +import { ChangesetOperationTargetKind, type InvokeChangesetOperationParams } from '../../common/state/protocol/channels-changeset/commands.js'; +import { AHP_SESSION_NOT_FOUND, JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; +import { SessionStatus, } from '../../common/state/sessionState.js'; +import { AgentHostReviewFileOperationHandler } from '../../node/agentHostReviewFileOperationHandler.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { createNoopChangesetService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; + +interface IRecordedCall { + readonly session: string; + readonly workingDirectory: string; + readonly baseBranch: string | undefined; + readonly resource: string; +} + +class TestReviewService implements IAgentHostReviewService { + declare readonly _serviceBrand: undefined; + + readonly markCalls: IRecordedCall[] = []; + readonly unmarkCalls: IRecordedCall[] = []; + error: Error | undefined; + + async markFileReviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> { + this.markCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() }); + if (this.error) { throw this.error; } + } + + async markFileUnreviewed(session: string, workingDirectory: URI, baseBranch: string | undefined, resource: URI): Promise<void> { + this.unmarkCalls.push({ session, workingDirectory: workingDirectory.toString(), baseBranch, resource: resource.toString() }); + if (this.error) { throw this.error; } + } + + async getReviewedPaths(): Promise<ReadonlySet<string>> { + return new Set(); + } + + async copyReviewedRef(): Promise<void> { } +} + +function makeResourceTarget(resource: URI): InvokeChangesetOperationParams['target'] { + return { kind: ChangesetOperationTargetKind.Resource, resource: resource.toString() as unknown as InvokeChangesetOperationParams['channel'] }; +} + +function setup(disposables: Pick<DisposableStore, 'add'>, opts?: { readonly reviewed?: boolean; readonly withWorkingDirectory?: boolean; readonly registerSession?: boolean; readonly baseBranch?: string }): { handler: AgentHostReviewFileOperationHandler; reviewService: TestReviewService; refreshedSessions: string[]; session: URI } { + const reviewService = new TestReviewService(); + const database = new TestSessionDatabase(); + if (opts?.baseBranch) { + database.setMetadata(META_DIFF_BASE_BRANCH, opts.baseBranch); + } + const sessionDataService = createSessionDataService(database); + const refreshedSessions: string[] = []; + const changesetService = createNoopChangesetService(); + changesetService.refreshBranchChangeset = session => { refreshedSessions.push(session); }; + const stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const session = URI.parse('agent:/session'); + if (opts?.registerSession !== false) { + stateManager.createSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + createdAt: new Date(1).toISOString(), + modifiedAt: new Date(1).toISOString(), + workingDirectory: opts?.withWorkingDirectory === false ? undefined : URI.file('/repo').toString(), + }); + } + const handler = new AgentHostReviewFileOperationHandler( + opts?.reviewed ?? true, + sessionKey => stateManager.getSessionState(sessionKey), + reviewService, + changesetService, + sessionDataService, + new NullLogService(), + ); + return { handler, reviewService, refreshedSessions, session }; +} + +suite('AgentHostReviewFileOperationHandler', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('marks the targeted file as reviewed, passing the resolved base branch', async () => { + const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: true, baseBranch: 'main' }); + const target = URI.file('/repo/src/file.ts'); + + const result = await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(target), + }, CancellationToken.None); + + assert.deepStrictEqual({ + markCalls: reviewService.markCalls, + unmarkCalls: reviewService.unmarkCalls, + refreshedSessions, + message: result.message, + }, { + markCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: 'main', resource: target.toString() }], + unmarkCalls: [], + refreshedSessions: [session.toString()], + message: { markdown: 'Marked `file.ts` as reviewed.' }, + }); + }); + + test('clears the reviewed mark for the targeted file', async () => { + const { handler, reviewService, refreshedSessions, session } = setup(disposables, { reviewed: false }); + const target = URI.file('/repo/src/file.ts'); + + const result = await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_UNREVIEWED, + target: makeResourceTarget(target), + }, CancellationToken.None); + + assert.deepStrictEqual({ + markCalls: reviewService.markCalls, + unmarkCalls: reviewService.unmarkCalls, + refreshedSessions, + message: result.message, + }, { + markCalls: [], + unmarkCalls: [{ session: session.toString(), workingDirectory: URI.file('/repo').toString(), baseBranch: undefined, resource: target.toString() }], + refreshedSessions: [session.toString()], + message: { markdown: 'Removed the reviewed mark from `file.ts`.' }, + }); + }); + + test('rejects channels that are not branch-changeset URIs', async () => { + const { handler, reviewService, session } = setup(disposables); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildSessionChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(URI.file('/repo/src/file.ts')), + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 }); + }); + + test('throws AHP_SESSION_NOT_FOUND when the session is unknown', async () => { + const { handler, reviewService } = setup(disposables, { registerSession: false }); + const session = URI.parse('agent:/missing'); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: makeResourceTarget(URI.file('/repo/src/file.ts')), + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: AHP_SESSION_NOT_FOUND, marks: 0 }); + }); + + test('rejects invocations without a Resource target', async () => { + const { handler, reviewService, session } = setup(disposables); + + let err: ProtocolError | undefined; + try { + await handler.invoke({ + channel: buildBranchChangesetUri(session.toString()), + operationId: AgentHostReviewFileOperationHandler.OPERATION_MARK_AS_REVIEWED, + target: undefined, + }, CancellationToken.None); + } catch (error) { + err = error as ProtocolError; + } + + assert.deepStrictEqual({ code: err?.code, marks: reviewService.markCalls.length }, { code: JsonRpcErrorCodes.InvalidParams, marks: 0 }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts new file mode 100644 index 00000000000000..06d116f5db9dcc --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostReviewService.integrationTest.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Integration tests for {@link AgentHostReviewService} that spawn real `git` + * against temporary on-disk repositories, exercising the reviewed-ref based + * review model end to end. Run via `scripts/test-integration.sh`. + */ + +import assert from 'assert'; +import * as cp from 'child_process'; +import { mkdtempSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from '../../../../base/common/path.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Schemas } from '../../../../base/common/network.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { FileService } from '../../../files/common/fileService.js'; +import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; +import { AgentHostGitService } from '../../node/agentHostGitService.js'; +import { AgentHostReviewService } from '../../node/agentHostReviewService.js'; +import { buildReviewedRefName } from '../../common/agentHostReviewService.js'; +import { createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { INativeEnvironmentService } from '../../../environment/common/environment.js'; + +function rmDirWithRetry(path: string | undefined): void { + if (!path) { + return; + } + try { rmSync(path, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } catch { /* best-effort */ } +} + +suite.skip('AgentHostReviewService (real git)', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + const hasGit = (() => { + try { cp.execFileSync('git', ['--version'], { stdio: 'ignore' }); return true; } catch { return false; } + })(); + + const env = { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }; + const sessionUri = URI.parse('copilot:/review-test-session'); + + let tmpRoot: string | undefined; + let svc: AgentHostReviewService | undefined; + let db: TestSessionDatabase | undefined; + + function createService(store: Pick<DisposableStore, 'add'>): AgentHostReviewService { + const logService = new NullLogService(); + const fileService = store.add(new FileService(logService)); + store.add(fileService.registerProvider(Schemas.file, store.add(new DiskFileSystemProvider(logService)))); + const env: Partial<INativeEnvironmentService> = { tmpDir: URI.file(tmpdir()) }; + const gitService = new AgentHostGitService(fileService, env as INativeEnvironmentService, logService); + db = new TestSessionDatabase(); + const sessionDataService = createSessionDataService(db); + const stateManager = disposables.add(new AgentHostStateManager(logService)); + return store.add(new AgentHostReviewService(stateManager, gitService, sessionDataService, logService,)); + } + + setup(() => { + tmpRoot = undefined; + svc = createService(disposables); + }); + + teardown(() => { + rmDirWithRetry(tmpRoot); + }); + + async function initRepo(files: Record<string, string>): Promise<string> { + const fs = await import('fs/promises'); + // Canonicalize the temp root so it matches the path `git rev-parse + // --show-toplevel` reports (macOS symlinks /var -> /private/var); the + // review service computes repo-relative paths against that root. + tmpRoot = await fs.realpath(mkdtempSync(join(tmpdir(), 'agent-host-review-'))); + const run = (...args: string[]) => cp.execFileSync('git', args, { cwd: tmpRoot!, env, stdio: 'pipe' }); + run('init', '-q', '-b', 'main'); + for (const [name, content] of Object.entries(files)) { + await fs.writeFile(join(tmpRoot!, name), content); + } + run('add', '.'); + run('commit', '-q', '-m', 'init'); + return tmpRoot!; + } + + const wd = () => URI.file(tmpRoot!); + const reviewedPaths = async () => [...await svc!.getReviewedPaths(sessionUri.toString(), wd(), undefined)].sort(); + const chainLength = () => { + const ref = buildReviewedRefName('review-test-session'); + // Count only the review commits layered on top of the baseline (HEAD), + // excluding the baseline's own history. + try { return Number(cp.execFileSync('git', ['rev-list', '--count', `HEAD..${ref}`], { cwd: tmpRoot!, env, encoding: 'utf8' }).trim()); } catch { return 0; } + }; + + (hasGit ? test : test.skip)('marks and unmarks a modified file (against HEAD baseline)', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n', 'b.txt': 'b-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + + const beforeMark = await reviewedPaths(); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterMark = await reviewedPaths(); + await svc!.markFileUnreviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterUnmark = await reviewedPaths(); + + assert.deepStrictEqual( + { beforeMark, afterMark, afterUnmark }, + { beforeMark: [], afterMark: ['a.txt'], afterUnmark: [] }); + }); + + (hasGit ? test : test.skip)('auto-unreviews a file that is edited again after being reviewed', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const reviewed = await reviewedPaths(); + + // Agent edits the file again -> its working content no longer matches + // the reviewed tree, so it flips back to unreviewed. + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v3\n'); + const afterReedit = await reviewedPaths(); + + assert.deepStrictEqual({ reviewed, afterReedit }, { reviewed: ['a.txt'], afterReedit: [] }); + }); + + (hasGit ? test : test.skip)('reviews an added (untracked) file and a deleted file', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n', 'gone.txt': 'bye\n' }); + await fs.writeFile(join(tmpRoot!, 'fresh.txt'), 'fresh\n'); + await fs.unlink(join(tmpRoot!, 'gone.txt')); + + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'fresh.txt'))); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'gone.txt'))); + + assert.deepStrictEqual(await reviewedPaths(), ['fresh.txt', 'gone.txt']); + }); + + (hasGit ? test : test.skip)('advances the ref chain once per action and skips no-op marks', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + + const initial = chainLength(); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterMark = chainLength(); + // Marking the same content again is a no-op and must not grow the chain. + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterDup = chainLength(); + await svc!.markFileUnreviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const afterUnmark = chainLength(); + + assert.deepStrictEqual( + { initial, afterMark, afterDup, afterUnmark }, + { initial: 0, afterMark: 1, afterDup: 1, afterUnmark: 2 }); + }); + + (hasGit ? test : test.skip)('deletes the reviewed ref on session data disposal', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + const beforeDispose = chainLength(); + + await svc!.disposeSessionData(sessionUri.toString()); + const afterDispose = chainLength(); + + assert.deepStrictEqual({ beforeDispose, afterDispose }, { beforeDispose: 1, afterDispose: 0 }); + }); + + (hasGit ? test : test.skip)('copies the reviewed ref to a forked session', async () => { + const fs = await import('fs/promises'); + await initRepo({ 'a.txt': 'a-v1\n' }); + await fs.writeFile(join(tmpRoot!, 'a.txt'), 'a-v2\n'); + await svc!.markFileReviewed(sessionUri.toString(), wd(), undefined, URI.file(join(tmpRoot!, 'a.txt'))); + + const forkUri = URI.parse('copilot:/forked-session'); + await svc!.copyReviewedRef(sessionUri.toString(), forkUri.toString(), wd()); + + const forkReviewed = [...await svc!.getReviewedPaths(forkUri.toString(), wd(), undefined)].sort(); + const sourceReviewed = await reviewedPaths(); + assert.deepStrictEqual({ forkReviewed, sourceReviewed }, { forkReviewed: ['a.txt'], sourceReviewed: ['a.txt'] }); + }); + + (hasGit ? test : test.skip)('is a no-op when the working directory is not a git repository', async () => { + const dir = mkdtempSync(join(tmpdir(), 'agent-host-review-nongit-')); + tmpRoot = dir; + await svc!.markFileReviewed(sessionUri.toString(), URI.file(dir), undefined, URI.file(join(dir, 'a.txt'))); + assert.deepStrictEqual([...await svc!.getReviewedPaths(sessionUri.toString(), URI.file(dir), undefined)], []); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts index e8d4f102697c7a..277c8f11da3805 100644 --- a/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostSessionTitleController.test.ts @@ -33,6 +33,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); } async models(): Promise<CCAModel[]> { return []; } async responses(): Promise<Response> { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 3419aff74af4cc..03ba03b341818e 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { NullLogService } from '../../../log/common/log.js'; import { ActionType, NotificationType, type ActionEnvelope, type INotification } from '../../common/state/sessionActions.js'; -import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, parseSubagentSessionUri, readHostBuildInfo, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; +import { MessageKind, SessionSummary, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, buildSubagentSessionUriPrefix, isSubagentSession, mergeSessionWithDefaultChat, parseSubagentSessionUri, readHostBuildInfo, type ChatState, type MarkdownResponsePart, type SessionState } from '../../common/state/sessionState.js'; import { type SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { buildChangesetUri, buildSessionChangesetUri } from '../../common/changesetUri.js'; @@ -220,6 +220,25 @@ suite('AgentHostStateManager', () => { assert.strictEqual(notifications[0].type, NotificationType.SessionAdded); }); + test('default chat inherits the session working directory resolved at materialization', () => { + // A deferred (provisional) session is created with a pre-materialization + // working directory; materialization later resolves it to a different + // one (e.g. a git worktree) via markSessionPersisted. The default chat + // has no per-chat working-directory override, so getSessionState must + // project the RESOLVED session working directory, never the stale + // create-time value that was seeded onto the default chat. + manager.createSession({ ...makeSessionSummary(), workingDirectory: 'file:///provisional' }, { emitNotification: false }); + manager.markSessionPersisted(sessionUri, { ...makeSessionSummary(), workingDirectory: 'file:///resolved-worktree' }); + + assert.deepStrictEqual({ + session: manager.getSessionState(sessionUri)?.workingDirectory, + defaultChat: manager.getSessionState(sessionChatUri)?.workingDirectory, + }, { + session: 'file:///resolved-worktree', + defaultChat: 'file:///resolved-worktree', + }); + }); + test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); @@ -1271,6 +1290,280 @@ suite('AgentHostStateManager', () => { ); }); }); + + // Characterization tests (task A3): pin down the *current* catalog behavior + // — the default-chat pointer set up by `_ensureDefaultChat`, the + // `restoreChat` re-registration path, and the rolled-up session summary + // produced by the SessionSummaryNotifier — so the upcoming `providerData` + // change cannot silently regress them. + suite('catalog characterization (A3)', () => { + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + test('_ensureDefaultChat seeds a single inheriting default chat and points defaultChat at it on createSession', () => { + manager.createSession(makeSessionSummary()); + const state = manager.getSessionState(sessionUri); + + assert.deepStrictEqual( + { + defaultChat: state?.defaultChat, + defaultChatIsDeterministic: state?.defaultChat === buildDefaultChatUri(sessionUri), + chatResources: state?.chats.map(c => c.resource.toString()), + // Empty title => the default chat inherits the session title for display. + defaultChatTitle: state?.chats[0]?.title, + defaultChatStatePresent: manager.getDefaultChatState(sessionUri) !== undefined, + }, + { + defaultChat: buildDefaultChatUri(sessionUri), + defaultChatIsDeterministic: true, + chatResources: [buildDefaultChatUri(sessionUri)], + defaultChatTitle: '', + defaultChatStatePresent: true, + }, + ); + }); + + test('_ensureDefaultChat seeds the default-chat pointer on restoreSession too', () => { + const turns = [ + { + id: 'turn-1', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'world' } satisfies MarkdownResponsePart], + usage: undefined, + state: TurnState.Complete, + }, + ]; + manager.restoreSession(makeSessionSummary(), turns); + const state = manager.getSessionState(sessionUri); + + assert.deepStrictEqual( + { + defaultChat: state?.defaultChat, + chatResources: state?.chats.map(c => c.resource.toString()), + defaultChatTurns: manager.getDefaultChatState(sessionUri)?.turns.length, + }, + { + defaultChat: buildDefaultChatUri(sessionUri), + chatResources: [buildDefaultChatUri(sessionUri)], + defaultChatTurns: 1, + }, + ); + }); + + test('restoreChat re-registers a peer chat in place, seeding turns and draft without dispatching SessionChatAdded', () => { + manager.restoreSession(makeSessionSummary(), []); + + const envelopes: ActionEnvelope[] = []; + disposables.add(manager.onDidEmitEnvelope(e => envelopes.push(e))); + + const turns = [ + { + id: 'peer-turn-1', + message: { text: 'restored', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'history' } satisfies MarkdownResponsePart], + usage: undefined, + state: TurnState.Complete, + }, + ]; + const draft = { text: 'work in progress', origin: { kind: MessageKind.User } }; + manager.restoreChat(sessionUri, peerChat, { title: 'Restored Peer', turns, draft }); + + const peerState = manager.getChatState(peerChat); + assert.deepStrictEqual( + { + chatResources: manager.getSessionState(sessionUri)?.chats.map(c => c.resource.toString()).sort(), + restoredTitle: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, + peerTurns: peerState?.turns.length, + peerDraft: peerState?.draft?.text, + // restoreChat runs before clients subscribe, so it adds the + // catalog entry in place rather than via a dispatched action. + chatAddedEvents: envelopes.filter(e => e.action.type === ActionType.SessionChatAdded).length, + }, + { + chatResources: [buildDefaultChatUri(sessionUri), peerChat].sort(), + restoredTitle: 'Restored Peer', + peerTurns: 1, + peerDraft: 'work in progress', + chatAddedEvents: 0, + }, + ); + }); + + test('restoreChat is a no-op for an already-registered chat URI', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const turns = [ + { + id: 'ignored-turn', + message: { text: 'ignored', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + state: TurnState.Complete, + }, + ]; + manager.restoreChat(sessionUri, peerChat, { title: 'Ignored', turns }); + + assert.deepStrictEqual( + { + chatCount: manager.getSessionState(sessionUri)?.chats.length, + title: manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat)?.title, + // The existing (empty) chat state is preserved; the supplied turns are dropped. + peerTurns: manager.getChatState(peerChat)?.turns.length, + }, + { + chatCount: 2, + title: 'Peer', + peerTurns: 0, + }, + ); + }); + + test('restoreChat for an unknown session is a no-op', () => { + manager.restoreChat('copilot:/missing', peerChat, { turns: [] }); + + assert.strictEqual(manager.getChatState(peerChat), undefined); + }); + + test('SessionSummaryNotifier rolls a running peer chat up onto the session summary and emits one coalesced SessionSummaryChanged', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + manager.createSession(makeSessionSummary()); + manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady }); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(n => notifications.push(n))); + + const summaryHasInProgress = () => ((manager.getSessionSummary(sessionUri)?.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress; + const idleRollup = summaryHasInProgress(); + + // Only the peer chat streams; the default chat stays idle. + manager.dispatchServerAction(peerChat, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-peer', + message: { text: 'b', origin: { kind: MessageKind.User } }, + }); + const runningRollup = summaryHasInProgress(); + + await new Promise(r => setTimeout(r, 150)); + + const summaryChanges = notifications.filter(n => n.type === NotificationType.SessionSummaryChanged) as SessionSummaryChangedParams[]; + + assert.deepStrictEqual( + { + idleRollup, + runningRollup, + summaryChangedCount: summaryChanges.length, + notifiedStatusHasInProgress: ((summaryChanges[0]?.changes.status ?? 0) & SessionStatus.InProgress) === SessionStatus.InProgress, + notifiedSession: summaryChanges[0]?.session, + }, + { + idleRollup: false, + runningRollup: true, + summaryChangedCount: 1, + notifiedStatusHasInProgress: true, + notifiedSession: sessionUri, + }, + ); + }); + }); + }); + + // Exercises the opaque, agent-owned `providerData` blob the StateManager + // records alongside a peer-chat catalog entry (G-B1). The StateManager must + // store the string verbatim, return it unchanged, keep it off the default + // chat, and drop it when the chat or its session goes away — it must NEVER + // parse or otherwise interpret the value. + suite('providerData (G-B1)', () => { + const peerChat = buildChatUri(sessionUri, 'peer-1'); + const peerChat2 = buildChatUri(sessionUri, 'peer-2'); + + test('addChat records providerData verbatim and getChatProviderData returns it unchanged', () => { + manager.createSession(makeSessionSummary()); + // A deliberately structured-but-opaque blob: the StateManager must + // not parse it, so embedded JSON / quotes must round-trip exactly. + const blob = '{"sdkSessionId":"abc-123","model":{"id":"x\\"y"}}'; + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: blob }); + + assert.deepStrictEqual( + { + providerData: manager.getChatProviderData(peerChat), + // The blob is stored separately and never leaks onto the + // protocol-visible catalog entry / chat state. + summaryHasBlob: (manager.getSessionState(sessionUri)?.chats.find(c => c.resource === peerChat) as { providerData?: unknown } | undefined)?.providerData !== undefined, + chatStateHasBlob: (manager.getChatState(peerChat) as { providerData?: unknown } | undefined)?.providerData !== undefined, + }, + { + providerData: blob, + summaryHasBlob: false, + chatStateHasBlob: false, + }, + ); + }); + + test('the default chat never carries providerData', () => { + manager.createSession(makeSessionSummary()); + + assert.strictEqual(manager.getChatProviderData(buildDefaultChatUri(sessionUri)), undefined); + }); + + test('addChat without providerData stores nothing', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer' }); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('addChat is idempotent and preserves the originally stored providerData', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: 'first' }); + // Re-adding the same chat URI is a no-op; it must not clobber the blob. + manager.addChat(sessionUri, peerChat, { title: 'Ignored', providerData: 'second' }); + + assert.strictEqual(manager.getChatProviderData(peerChat), 'first'); + }); + + test('restoreChat records providerData verbatim alongside turns', () => { + manager.restoreSession(makeSessionSummary(), []); + const blob = 'opaque-restore-token'; + manager.restoreChat(sessionUri, peerChat, { title: 'Restored', turns: [], providerData: blob }); + + assert.strictEqual(manager.getChatProviderData(peerChat), blob); + }); + + test('restoreChat without providerData stores nothing', () => { + manager.restoreSession(makeSessionSummary(), []); + manager.restoreChat(sessionUri, peerChat, { title: 'Restored', turns: [] }); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('removeChat drops the chat providerData', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer', providerData: 'blob' }); + manager.removeChat(sessionUri, peerChat); + + assert.strictEqual(manager.getChatProviderData(peerChat), undefined); + }); + + test('removeSession drops providerData for every peer chat', () => { + manager.createSession(makeSessionSummary()); + manager.addChat(sessionUri, peerChat, { title: 'Peer 1', providerData: 'blob-1' }); + manager.addChat(sessionUri, peerChat2, { title: 'Peer 2', providerData: 'blob-2' }); + + manager.removeSession(sessionUri); + + assert.deepStrictEqual( + { + peer1: manager.getChatProviderData(peerChat), + peer2: manager.getChatProviderData(peerChat2), + }, + { + peer1: undefined, + peer2: undefined, + }, + ); + }); + }); }); suite('Subagent URI helpers', () => { @@ -1335,4 +1628,51 @@ suite('Subagent URI helpers', () => { 'copilot:/session-1//nested/../kept/subagent/', ); }); + + suite('mergeSessionWithDefaultChat', () => { + function makeSessionState(workingDirectory?: string): SessionState { + return { + provider: 'copilot', + title: 'Session', + status: SessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + workingDirectory, + }; + } + + function makeChatState(workingDirectory?: string): ChatState { + return { + resource: 'copilot:/test-session/chat/peer', + title: 'Peer', + status: SessionStatus.Idle, + modifiedAt: new Date().toISOString(), + workingDirectory, + turns: [], + }; + } + + test('resolves the per-chat working directory override over the session default', () => { + const merged = mergeSessionWithDefaultChat( + makeSessionState('file:///session-wd'), + makeChatState('file:///peer-worktree'), + ); + assert.strictEqual(merged.workingDirectory, 'file:///peer-worktree'); + }); + + test('falls back to the session working directory when the chat does not override it', () => { + const merged = mergeSessionWithDefaultChat( + makeSessionState('file:///session-wd'), + makeChatState(undefined), + ); + assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + }); + + test('falls back to the session working directory when no chat state is hydrated', () => { + const merged = mergeSessionWithDefaultChat(makeSessionState('file:///session-wd'), undefined); + assert.strictEqual(merged.workingDirectory, 'file:///session-wd'); + assert.deepStrictEqual(merged.turns, []); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts new file mode 100644 index 00000000000000..5868a1457a47e5 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryReporter.test.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { AgentSession } from '../../common/agentService.js'; +import type { ToolDefinition } from '../../common/state/protocol/state.js'; +import { IAgentHostRestrictedTelemetry, TelemetryMeasurements, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; +import { AgentHostTelemetryReporter } from '../../node/agentHostTelemetryReporter.js'; + +interface IRestrictedCall { + eventName: string; + properties: TelemetryProps | undefined; +} + +class TestRestrictedTelemetryService implements ITelemetryService, IAgentHostRestrictedTelemetry { + declare readonly _serviceBrand: undefined; + + telemetryLevel = TelemetryLevel.USAGE; + sendErrorTelemetry = true; + sessionId = 'sessionId'; + machineId = 'machineId'; + sqmId = 'sqmId'; + devDeviceId = 'devDeviceId'; + firstSessionDate = 'firstSessionDate'; + + readonly enhancedEvents: IRestrictedCall[] = []; + readonly internalEvents: IRestrictedCall[] = []; + + publicLog(): void { } + publicLogError(): void { } + publicLog2(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } + + sendGHTelemetryEvent(): void { } + sendEnhancedGHTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.enhancedEvents.push({ eventName, properties }); + } + sendInternalMSFTTelemetryEvent(eventName: string, properties?: TelemetryProps, _measurements?: TelemetryMeasurements): void { + this.internalEvents.push({ eventName, properties }); + } + setCopilotTrackingId(): void { } + setRestrictedTelemetryEndpoint(): void { } + setRestrictedTelemetryEnabled(): void { } +} + +suite('AgentHostTelemetryReporter', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = 'agent-session://copilot/abc'; + const tools: ToolDefinition[] = [{ name: 'grep' }, { name: 'edit' }]; + + test('assistantMessageReceived emits request.options.tools keyed on the service request id, and no-ops without one or without tools', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.assistantMessageReceived(session, undefined, tools); // dropped: no service request id + reporter.assistantMessageReceived(session, 'svc-1', []); // dropped: no tools + reporter.assistantMessageReceived(session, 'svc-1', tools); // emitted + + assert.deepStrictEqual(service.enhancedEvents, [{ + eventName: 'request.options.tools', + properties: { + headerRequestId: 'svc-1', + conversationId: AgentSession.id(session), + messagesJson: JSON.stringify(tools), + }, + }]); + }); + + test('userMessageText emits conversation.messageText (source=user) to enhanced + internal, and no-ops on empty content', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.userMessageText(session, '', 3); // dropped: no content + reporter.userMessageText(session, 'hello agent', 3); // emitted + + const expected: IRestrictedCall = { + eventName: 'conversation.messageText', + properties: { + source: 'user', + conversationId: AgentSession.id(session), + turnIndex: '3', + messageText: 'hello agent', + }, + }; + assert.deepStrictEqual(service.enhancedEvents, [expected]); + assert.deepStrictEqual(service.internalEvents, [expected]); + }); + + test('modelMessageText emits conversation.messageText (source=model) with headerRequestId, and no-ops on empty content', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.modelMessageText(session, '', 3, 'svc-1'); // dropped: no content + reporter.modelMessageText(session, 'sure, here you go', 3, 'svc-1'); // emitted + + const expected: IRestrictedCall = { + eventName: 'conversation.messageText', + properties: { + source: 'model', + conversationId: AgentSession.id(session), + turnIndex: '3', + headerRequestId: 'svc-1', + messageText: 'sure, here you go', + }, + }; + assert.deepStrictEqual(service.enhancedEvents, [expected]); + assert.deepStrictEqual(service.internalEvents, [expected]); + }); + + test('toolCallDetails emits toolCallDetailsExternal + toolCallDetailsInternal aggregate whenever tools were available, and no-ops when none were', () => { + const service = new TestRestrictedTelemetryService(); + const reporter = new AgentHostTelemetryReporter(service); + + reporter.toolCallDetails({ + session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', model: 'gpt-x', responseType: 'success', + toolCounts: {}, availableTools: [], + numRequests: 1, totalToolCalls: 0, parallelToolCallRounds: 0, parallelToolCallsTotal: 0, + }); // dropped: no tools were available + reporter.toolCallDetails({ + session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', model: 'gpt-x', responseType: 'success', + toolCounts: {}, availableTools: ['grep', 'edit'], + numRequests: 1, totalToolCalls: 0, parallelToolCallRounds: 0, parallelToolCallsTotal: 0, + }); // emitted: tools available, even though no tool calls were made + reporter.toolCallDetails({ + session, turnId: 'a1b2c3d4-0000-4000-8000-000000000000', model: 'gpt-x', responseType: 'success', + toolCounts: { grep: 2, edit: 1 }, availableTools: ['grep', 'edit'], + numRequests: 2, totalToolCalls: 3, parallelToolCallRounds: 1, parallelToolCallsTotal: 2, + }); // emitted + + assert.deepStrictEqual(service.enhancedEvents, [{ + eventName: 'toolCallDetailsExternal', + properties: { + conversationId: AgentSession.id(session), + requestId: 'a1b2c3d4-0000-4000-8000-000000000000', + messageId: 'a1b2c3d4-0000-4000-8000-000000000000', + responseType: 'success', + model: 'gpt-x', + toolCounts: JSON.stringify({}), + availableTools: JSON.stringify(['grep', 'edit']), + }, + }, { + eventName: 'toolCallDetailsExternal', + properties: { + conversationId: AgentSession.id(session), + requestId: 'a1b2c3d4-0000-4000-8000-000000000000', + messageId: 'a1b2c3d4-0000-4000-8000-000000000000', + responseType: 'success', + model: 'gpt-x', + toolCounts: JSON.stringify({ grep: 2, edit: 1 }), + availableTools: JSON.stringify(['grep', 'edit']), + }, + }]); + assert.strictEqual(service.internalEvents.length, 2); + assert.strictEqual(service.internalEvents[0].eventName, 'toolCallDetailsInternal'); + assert.strictEqual(service.internalEvents[1].eventName, 'toolCallDetailsInternal'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts index 35050bc5126210..7a8f05da86bbf2 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTelemetryService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { ITelemetryData, ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { AgentHostTelemetryLevelConfigKey, telemetryLevelToAgentHostConfigValue } from '../../common/agentHostSchema.js'; +import { IAgentHostRestrictedTelemetry, TelemetryProps } from '../../node/agentHostRestrictedTelemetry.js'; import { AgentHostTelemetryService, updateAgentHostTelemetryLevelFromConfig } from '../../node/agentHostTelemetryService.js'; class TestTelemetryService implements ITelemetryService { @@ -42,6 +43,31 @@ class TestTelemetryService implements ITelemetryService { setCommonProperty(): void { } } +class TestRestrictedSink implements IAgentHostRestrictedTelemetry { + readonly enhanced: string[] = []; + readonly standard: string[] = []; + readonly trackingIds: (string | undefined)[] = []; + readonly endpoints: (string | undefined)[] = []; + readonly enabledFlags: boolean[] = []; + + sendGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.standard.push(eventName); + } + sendEnhancedGHTelemetryEvent(eventName: string, _properties?: TelemetryProps): void { + this.enhanced.push(eventName); + } + sendInternalMSFTTelemetryEvent(): void { } + setCopilotTrackingId(trackingId: string | undefined): void { + this.trackingIds.push(trackingId); + } + setRestrictedTelemetryEndpoint(endpointUrl: string | undefined): void { + this.endpoints.push(endpointUrl); + } + setRestrictedTelemetryEnabled(enabled: boolean): void { + this.enabledFlags.push(enabled); + } +} + suite('AgentHostTelemetryService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -88,4 +114,45 @@ suite('AgentHostTelemetryService', () => { assert.strictEqual(service.telemetryLevel, TelemetryLevel.ERROR); }); + + test('enhanced GH telemetry is gated on the restricted (rt) opt-in; standard GH telemetry is not', () => { + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(new TestTelemetryService(), restricted)); + + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // dropped: rt disabled by default + service.sendGHTelemetryEvent('completion'); // sent: standard GH telemetry is not rt-gated + service.setRestrictedTelemetryEnabled(true); + service.sendEnhancedGHTelemetryEvent('request.options.tools'); // sent: rt now enabled + service.setCopilotTrackingId('tid-1'); + service.setRestrictedTelemetryEndpoint('https://ghe.example/telemetry'); + + assert.deepStrictEqual({ + enhanced: restricted.enhanced, + standard: restricted.standard, + trackingIds: restricted.trackingIds, + endpoints: restricted.endpoints, + }, { + enhanced: ['request.options.tools'], + standard: ['completion'], + trackingIds: ['tid-1'], + endpoints: ['https://ghe.example/telemetry'], + }); + // The rt opt-in is mirrored onto the sender (defense in depth), matching the extension's + // opted-in-only restricted reporter. + assert.deepStrictEqual(restricted.enabledFlags, [true]); + }); + + test('enhanced GH telemetry stays suppressed when telemetry is disabled, even for an rt=1 user', () => { + const delegate = new TestTelemetryService(); + delegate.telemetryLevel = TelemetryLevel.ERROR; // user opted below USAGE + const restricted = new TestRestrictedSink(); + const service = disposables.add(new AgentHostTelemetryService(delegate, restricted)); + + service.setRestrictedTelemetryEnabled(true); // rt=1 + service.sendEnhancedGHTelemetryEvent('request.options.tools'); + service.sendGHTelemetryEvent('completion'); + + // Neither standard nor enhanced GH telemetry is delegated below USAGE, regardless of rt. + assert.deepStrictEqual({ enhanced: restricted.enhanced, standard: restricted.standard }, { enhanced: [], standard: [] }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts new file mode 100644 index 00000000000000..99f28c33064a72 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTelemetry.test.ts @@ -0,0 +1,435 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../base/common/async.js'; +import { DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../base/test/common/virtualScheduling/runWithFakedTimers.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { AgentSession, IAgent } from '../../common/agentService.js'; +import { SessionInputRequestKind } from '../../common/state/protocol/state.js'; +import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; +import { buildDefaultChatUri, MessageKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; +import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; +import { AgentSideEffects } from '../../node/agentSideEffects.js'; +import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; +import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; + +class FakeChangesetService implements IAgentHostChangesetService { + declare readonly _serviceBrand: undefined; + registerStaticChangesets(): void { } + restoreStaticChangeset(): void { } + parsePersistedStaticChangesets(): { session?: undefined } { return {}; } + applyPersistedStaticChangesets(): void { } + restorePersistedStaticChangesets(): { session?: undefined } { return {}; } + persistChangesSummary(): void { } + isStaticChangesetComputeActive(): boolean { return false; } + getListMetadataKeys() { return undefined; } + computeListEntryChanges() { return undefined; } + refreshBranchChangeset(): void { } + refreshSessionChangeset(): void { } + refreshChangesetCatalog(): void { } + onWorkingDirectoryAvailable(): void { } + recomputeSubscribedChangesets(): void { } + onSessionDisposed(): void { } + async computeUncommittedChangeset(session: string): Promise<string> { return `${session}/changeset/uncommitted`; } + async computeTurnChangeset(session: string): Promise<string> { return `${session}/x`; } + async computeCompareTurnsChangeset(session: string): Promise<string> { return `${session}/y`; } + onToolCallEditsApplied(): void { } + onTurnComplete(): void { } + onSessionTruncated(): void { } +} + +class CapturingTelemetryService implements ITelemetryService { + declare readonly _serviceBrand: undefined; + readonly telemetryLevel = TelemetryLevel.USAGE; + readonly sessionId = 'test-session'; + readonly machineId = 'test-machine'; + readonly sqmId = 'test-sqm'; + readonly devDeviceId = 'test-dev-device'; + readonly firstSessionDate = 'test-first-session-date'; + readonly sendErrorTelemetry = false; + readonly events: { eventName: string; data: unknown }[] = []; + + publicLog(): void { } + publicLog2(eventName: string, data?: unknown): void { + this.events.push({ eventName, data }); + } + publicLogError(): void { } + publicLogError2(): void { } + setExperimentProperty(): void { } + setCommonProperty(): void { } +} + +/** + * Integration tests covering the {@link AgentHostToolCallTracker} as it is + * driven through {@link AgentSideEffects}. These exercise the full wiring + * (tool-call start stamping, completion emission, dedup and the in-flight + * leak guard) so we cover both the tracker and its integration with the + * side-effect dispatch in one place. + */ +suite('AgentSideEffects — tool call telemetry', () => { + + const disposables = new DisposableStore(); + let stateManager: AgentHostStateManager; + let agent: MockAgent; + let sideEffects: AgentSideEffects; + let telemetry: CapturingTelemetryService; + + const sessionUri = AgentSession.uri('mock', 'session-1'); + const sessionKey = sessionUri.toString(); + const defaultChatUri = buildDefaultChatUri(sessionUri); + + function setupSession(): void { + stateManager.createSession({ + resource: sessionKey, + provider: 'mock', + title: 'Test', + status: SessionStatus.Idle, + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + }); + stateManager.dispatchServerAction(sessionKey, { type: ActionType.SessionReady }); + } + + function startTurn(turnId: string, text = 'hello'): void { + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId, + message: { text, origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(defaultChatUri, action); + } + + function fire(action: ChatAction): void { + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action }); + } + + function toolStart(turnId: string, toolCallId: string, toolName: string, contributor?: ToolCallContributor): void { + fire({ type: ActionType.ChatToolCallStart, turnId, toolCallId, toolName, displayName: toolName, contributor }); + } + + function toolComplete(turnId: string, toolCallId: string, result: ToolCallResult): void { + fire({ type: ActionType.ChatToolCallComplete, turnId, toolCallId, result }); + } + + function toolEvents(): { eventName: string; data: Record<string, unknown> }[] { + return telemetry.events + .filter(e => e.eventName === 'languageModelToolInvoked') + .map(e => { + const data = e.data as Record<string, unknown>; + return { + eventName: e.eventName, + data: { ...data, invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0 }, + }; + }); + } + + function stalledEvents(): { eventName: string; data: Record<string, unknown> }[] { + return telemetry.events + .filter(e => e.eventName === 'agentHost.toolCallStalled') + .map(e => { + const data = e.data as Record<string, unknown>; + return { + eventName: e.eventName, + data: { ...data, stalledTimeMs: typeof data.stalledTimeMs === 'number' && data.stalledTimeMs >= 0 }, + }; + }); + } + + function stalledCompletionEvents(): { eventName: string; data: Record<string, unknown> }[] { + return telemetry.events + .filter(e => e.eventName === 'agentHost.stalledToolCallCompleted') + .map(e => { + const data = e.data as Record<string, unknown>; + return { + eventName: e.eventName, + data: { + ...data, + totalTimeMs: typeof data.totalTimeMs === 'number' && data.totalTimeMs >= 0, + timeAfterStallMs: typeof data.timeAfterStallMs === 'number' && data.timeAfterStallMs >= 0, + }, + }; + }); + } + + setup(() => { + agent = new MockAgent(); + disposables.add(toDisposable(() => agent.dispose())); + stateManager = disposables.add(new AgentHostStateManager(new NullLogService())); + const agentList = observableValue<readonly IAgent[]>('agents', [agent]); + telemetry = new CapturingTelemetryService(); + + const logService = new NullLogService(); + const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const telemetryService = disposables.add(new AgentHostTelemetryService(telemetry)); + const sessionDataService = createNullSessionDataService(); + const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( + [ILogService, logService], + [IAgentConfigurationService, configService], + [IAgentHostChangesetService, new FakeChangesetService()], + [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], + [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], + [ISessionDataService, sessionDataService], + ), /*strict*/ true)); + sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService, + localTurns: new AgentHostLocalTurns(sessionDataService, logService), + onTurnComplete: () => { }, + })); + disposables.add(sideEffects.registerProgressListener(agent)); + }); + + teardown(() => { + disposables.clear(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); + + test('emits a successful agent-host tool invocation', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-1', 'bash'); + toolComplete('turn-1', 'tc-1', { success: true, pastTenseMessage: 'ran' }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'success', + chatSessionId: sessionKey, + toolId: 'bash', + toolExtensionId: undefined, + toolSourceKind: 'agentHost', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits userCancelled with mcp source kind for a denied mcp tool', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-mcp', 'lookup', { kind: ToolCallContributorKind.MCP, customizationId: 'c1' }); + toolComplete('turn-1', 'tc-mcp', { success: false, pastTenseMessage: 'denied', error: { message: 'denied', code: 'denied' } }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'userCancelled', + chatSessionId: sessionKey, + toolId: 'lookup', + toolExtensionId: undefined, + toolSourceKind: 'mcp', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits client source kind for a client-contributed tool', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-client', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + toolComplete('turn-1', 'tc-client', { success: true, pastTenseMessage: 'ran tests' }); + + assert.deepStrictEqual(toolEvents(), [{ + eventName: 'languageModelToolInvoked', + data: { + result: 'success', + chatSessionId: sessionKey, + toolId: 'run_tests', + toolExtensionId: undefined, + toolSourceKind: 'client', + provider: 'mock', + invocationTimeMs: true, + }, + }]); + }); + + test('emits error for a failure without a cancellation code', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-err', 'bash'); + toolComplete('turn-1', 'tc-err', { success: false, pastTenseMessage: 'boom', error: { message: 'boom' } }); + + assert.strictEqual(toolEvents()[0].data.result, 'error'); + }); + + test('emits a single event when a tool completion is duplicated', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-dup', 'bash'); + toolComplete('turn-1', 'tc-dup', { success: true, pastTenseMessage: 'ran' }); + toolComplete('turn-1', 'tc-dup', { success: true, pastTenseMessage: 'ran' }); + + assert.strictEqual(toolEvents().length, 1); + }); + + test('drops an in-flight tool call when the turn is cancelled before completion', () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-inflight', 'bash'); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + // A late completion after the turn ended must not emit: the start entry + // was cleared, so there is no timing to report. + toolComplete('turn-1', 'tc-inflight', { success: true, pastTenseMessage: 'ran' }); + + assert.strictEqual(toolEvents().length, 0); + }); + + test('emits once when a tool confirmation remains blocked', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-confirm', 'write'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-confirm', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-confirm', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents(), [{ + eventName: 'agentHost.toolCallStalled', + data: { + provider: 'mock', + agentSessionId: 'session-1', + isSubagentSession: false, + blockerKind: SessionInputRequestKind.ToolConfirmation, + toolId: 'write', + toolSourceKind: 'agentHost', + stalledTimeMs: true, + }, + }]); + }); + + test('replaces confirmation tracking with client execution tracking', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-client-stall', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-client-stall', + invocationMessage: 'Run tests', + confirmationTitle: 'Run tests', + }); + fire({ + type: ActionType.ChatToolCallConfirmed, + turnId: 'turn-1', + toolCallId: 'tc-client-stall', + approved: true, + confirmed: ToolCallConfirmationReason.UserAction, + }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents().map(e => e.data.blockerKind), [SessionInputRequestKind.ToolClientExecution]); + }); + + test('does not emit after a client tool completes or its turn is cancelled', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-complete', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-complete', + invocationMessage: 'Run tests', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + toolComplete('turn-1', 'tc-complete', { success: true, pastTenseMessage: 'ran tests' }); + + toolStart('turn-1', 'tc-cancel', 'write'); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-cancel', + invocationMessage: 'Write file', + confirmationTitle: 'Write file', + }); + fire({ type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + + await timeout(5 * 60 * 1000); + }); + + assert.deepStrictEqual(stalledEvents(), []); + assert.deepStrictEqual(stalledCompletionEvents(), []); + }); + + test('emits when a stalled client tool later completes', async () => { + await runWithFakedTimers({}, async () => { + setupSession(); + startTurn('turn-1'); + + toolStart('turn-1', 'tc-recovered', 'run_tests', { kind: ToolCallContributorKind.Client, clientId: 'client-1' }); + fire({ + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tc-recovered', + invocationMessage: 'Run tests', + confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + await timeout(5 * 60 * 1000); + toolComplete('turn-1', 'tc-recovered', { success: true, pastTenseMessage: 'ran tests' }); + }); + + assert.deepStrictEqual(stalledCompletionEvents(), [{ + eventName: 'agentHost.stalledToolCallCompleted', + data: { + provider: 'mock', + agentSessionId: 'session-1', + isSubagentSession: false, + blockerKind: SessionInputRequestKind.ToolClientExecution, + toolId: 'run_tests', + toolSourceKind: 'client', + result: 'success', + totalTimeMs: true, + timeAfterStallMs: true, + }, + }]); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts new file mode 100644 index 00000000000000..02e517098dfcb1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/agentHostToolCallTracker.test.ts @@ -0,0 +1,56 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { ToolCallContributorKind, type ToolCallContributor, type ToolCallResult } from '../../common/state/sessionState.js'; +import { deriveToolInvokedResult, toolSourceKindFromContributor } from '../../node/agentHostToolCallTracker.js'; + +function result(success: boolean, code?: string): ToolCallResult { + return { + success, + pastTenseMessage: 'done', + error: code !== undefined || !success ? { message: 'failed', code } : undefined, + }; +} + +suite('agentHostToolCallTracker', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('deriveToolInvokedResult maps success/cancel/error buckets', () => { + const actual = { + success: deriveToolInvokedResult(result(true)), + denied: deriveToolInvokedResult(result(false, 'denied')), + rejected: deriveToolInvokedResult(result(false, 'rejected')), + cancelled: deriveToolInvokedResult(result(false, 'cancelled')), + otherCode: deriveToolInvokedResult(result(false, 'timeout')), + noCode: deriveToolInvokedResult(result(false)), + }; + assert.deepStrictEqual(actual, { + success: 'success', + denied: 'userCancelled', + rejected: 'userCancelled', + cancelled: 'userCancelled', + otherCode: 'error', + noCode: 'error', + }); + }); + + test('toolSourceKindFromContributor maps contributor kind', () => { + const mcp: ToolCallContributor = { kind: ToolCallContributorKind.MCP, customizationId: 'c1' }; + const client: ToolCallContributor = { kind: ToolCallContributorKind.Client, clientId: 'x' }; + const actual = { + none: toolSourceKindFromContributor(undefined), + mcp: toolSourceKindFromContributor(mcp), + client: toolSourceKindFromContributor(client), + }; + assert.deepStrictEqual(actual, { + none: 'agentHost', + mcp: 'mcp', + client: 'client', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts index 50a00273515aa4..8bc7f39cacbe58 100644 --- a/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostTurnTelemetry.test.ts @@ -16,13 +16,17 @@ import { AgentSession, IAgent } from '../../common/agentService.js'; import { ActionType, type ChatAction } from '../../common/state/sessionActions.js'; import { buildDefaultChatUri, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus } from '../../common/state/sessionState.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IAgentHostChangesetService } from '../../common/agentHostChangesetService.js'; import { AgentSideEffects } from '../../node/agentSideEffects.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; +import { ISessionDataService } from '../../common/sessionDataService.js'; import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; class FakeChangesetService implements IAgentHostChangesetService { declare readonly _serviceBrand: undefined; @@ -35,6 +39,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys() { return undefined; } computeListEntryChanges() { return undefined; } + refreshChangesetCatalog(): void { } refreshBranchChangeset(): void { } refreshSessionChangeset(): void { } onWorkingDirectoryAvailable(): void { } @@ -149,17 +154,21 @@ suite('AgentSideEffects — turn tracker telemetry', () => { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); const telemetryService = disposables.add(new AgentHostTelemetryService(telemetry)); + const sessionDataService = createNullSessionDataService(); const instantiationService = disposables.add(new InstantiationService(new ServiceCollection( [ILogService, logService], [IAgentConfigurationService, configService], [IAgentHostChangesetService, new FakeChangesetService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, disposables.add(new TestAgentHostTerminalManager())], + [ISessionDataService, sessionDataService], ), /*strict*/ true)); sideEffects = disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, { getAgent: () => agent, agents: agentList, - sessionDataService: createNullSessionDataService(), + sessionDataService, + localTurns: new AgentHostLocalTurns(sessionDataService, logService), onTurnComplete: () => { }, })); // Wire the agent's progress signals through side-effects (this is how diff --git a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts index e41f93800ce09d..6daa264734debf 100644 --- a/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSdkDownloader.test.ts @@ -19,7 +19,7 @@ import type { IFileService } from '../../../files/common/files.js'; import { DiskFileSystemProvider } from '../../../files/node/diskFileSystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { RequestService } from '../../../request/node/requestService.js'; -import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage } from '../../node/agentSdkDownloader.js'; +import { AgentSdkDownloader, resolveSdkTarget, type IAgentSdkPackage, type IAgentSdkDownloadProgress } from '../../node/agentSdkDownloader.js'; import { ClaudeSdkPackage } from '../../node/claude/claudeAgentSdkService.js'; import { AgentHostClaudeSdkRootEnvVar } from '../../common/agentService.js'; import type { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -128,7 +128,7 @@ suite('resolveSdkTarget', () => { ensureNoDisposablesAreLeakedInTestSuite(); function fakePkg(hasSeparateMuslLinuxPackage: boolean): IAgentSdkPackage { - return { id: 'test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; + return { id: 'test', displayName: 'Test', devOverrideEnvVar: 'X', hasSeparateMuslLinuxPackage }; } test('returns <platform>-<arch> for supported (platform, arch)', () => { @@ -239,13 +239,13 @@ suite('AgentSdkDownloader', () => { version: productConfig?.version ?? '1.0.0', urlTemplate: productConfig?.urlTemplate ?? `http://127.0.0.1:${server.port}/sdk-{sdkTarget}.tgz`, }; - return new AgentSdkDownloader( + return disposables.add(new AgentSdkDownloader( makeEnvService(userDataPath), makeProductService(config), makeRequestService(disposables), makeFileService(disposables), new NullLogService(), - ); + )); } test('isAvailable: false when no env override and no product config', () => { @@ -280,6 +280,32 @@ suite('AgentSdkDownloader', () => { assert.ok(fs.existsSync(path.join(root, '.complete'))); }); + test('loadSdkRoot: reports monotonic download progress ending at totalBytes', async () => { + const downloader = makeDownloader(); + const samples: IAgentSdkDownloadProgress[] = []; + disposables.add(downloader.onDidDownloadProgress(p => samples.push(p))); + + await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); + + const tarballSize = (await fsp.stat(fixture.tarballPath)).size; + // One `started`, ≥1 `progress`, one terminal `completed`, all sharing a + // single downloadId and carrying the brand display name. + assert.ok(samples.length >= 2, 'expected at least a started and a completed frame'); + assert.strictEqual(samples[0].phase, 'started'); + const completed = samples[samples.length - 1]; + assert.strictEqual(completed.phase, 'completed'); + assert.ok(samples.every(s => s.downloadId === samples[0].downloadId), 'all frames share one downloadId'); + assert.ok(samples.every(s => s.displayName === 'Claude'), 'all frames carry the brand display name'); + + // receivedBytes is monotonically non-decreasing and reaches the total + // reported via Content-Length. + for (let i = 1; i < samples.length; i++) { + assert.ok(samples[i].receivedBytes >= samples[i - 1].receivedBytes, 'receivedBytes must be monotonic'); + } + assert.strictEqual(completed.totalBytes, tarballSize); + assert.strictEqual(completed.receivedBytes, tarballSize); + }); + test('loadSdkRoot: cache hit returns immediately without re-downloading', async () => { const downloader = makeDownloader(); await downloader.loadSdkRoot(ClaudeSdkPackage, newToken()); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 41884cd379d0e3..14626eafbec2c1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -22,11 +22,11 @@ import { hasKey } from '../../../../base/common/types.js'; import { NullLogService } from '../../../log/common/log.js'; import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession, type IAgentCreateChatForkSource, type IAgentCreateChatOptions } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, IRestoredSubagentSession, SubagentChatSignal, type IAgentChatDataChange, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionResult, type IAgentLegacyChat, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope } from '../../common/state/sessionActions.js'; -import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseSubagentSessionUri, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SessionLifecycle, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isSubagentSession, parseChatUri, parseSubagentSessionUri, ChatOriginKind, type ChangesetState, type MarkdownResponsePart, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { type MessageResourceAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -79,6 +79,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); } async models(): Promise<CCAModel[]> { return []; } async responses(): Promise<Response> { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -403,7 +405,7 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('generates an AI title for forked sessions from the forked conversation', async () => { + test('generates an AI title for forked sessions from the forked chat', async () => { const copilotApiService = new TestCopilotApiService(); copilotApiService.response = 'Source generated title'; const { svc, session: sourceSession } = await setupTitleGeneration(copilotApiService); @@ -422,7 +424,7 @@ suite('AgentService (node dispatcher)', () => { await waitForCondition(() => (svc.stateManager.getSessionState(sourceSession.toString())?.turns.length ?? 0) === 1, 'source turn should be complete before forking'); // The fork inherits a `Forked: …` placeholder, then regenerates a - // content-derived title from the copied conversation. + // content-derived title from the copied chat. copilotApiService.response = 'Forked branch title'; const forkedSession = await svc.createSession({ provider: 'copilot', @@ -439,11 +441,11 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual({ title: svc.stateManager.getSessionState(forkedSession.toString())?.title, utilityCalls: copilotApiService.utilityCalls.length, - includesForkedConversation: userMessage.includes('Seed fork title'), + includesForkedChat: userMessage.includes('Seed fork title'), }, { title: 'Forked branch title', utilityCalls: 2, - includesForkedConversation: true, + includesForkedChat: true, }); }); }); @@ -798,6 +800,48 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(sessions[0].summary, 'My Custom Title'); }); + test('listSessions overlays the AH-owned workspaceless marker for any agent', async () => { + // The AH service owns `agentHost.workspaceless` in the central session + // database and overlays it onto every agent's summary `_meta` — so an + // agent that persists/re-emits nothing itself still restores as a quick + // chat. Pre-seed the AH key with no agent-side re-emit. + const db = disposables.add(await SessionDatabase.open(':memory:')); + await db.setMetadata('agentHost.workspaceless', 'true'); + + const sessionId = 'test-session-workspaceless'; + const sessionUri = AgentSession.uri('copilot', sessionId); + + const sessionDataService: ISessionDataService = { + _serviceBrand: undefined, + getSessionDataDir: () => URI.parse('inmemory:/session-data'), + getSessionDataDirById: () => URI.parse('inmemory:/session-data'), + openDatabase: (): IReference<ISessionDatabase> => ({ + object: db, + dispose: () => { }, + }), + tryOpenDatabase: async (): Promise<IReference<ISessionDatabase> | undefined> => ({ + object: db, + dispose: () => { }, + }), + deleteSessionData: async () => { }, + onWillDeleteSessionData: Event.None, + cleanupOrphanedData: async () => { }, + whenIdle: async () => { }, + }; + + // The agent returns the session with NO `_meta.workspaceless` of its own. + const agent = new MockAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + (agent as unknown as { _sessions: Map<string, URI> })._sessions.set(sessionId, sessionUri); + + const svc = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + + const sessions = await svc.listSessions(); + assert.strictEqual(sessions.length, 1); + assert.deepStrictEqual(sessions[0]._meta, { workspaceless: true }); + }); + test('listSessions uses SDK title when no custom title exists', async () => { service.registerProvider(copilotAgent); copilotAgent.sessionMetadataOverrides = { summary: 'Auto-generated Title' }; @@ -912,6 +956,50 @@ suite('AgentService (node dispatcher)', () => { ); }); + test('listSessions overlays live workspace metadata over a stale provider snapshot', async () => { + class DelayedListAgent extends MockAgent { + readonly listStarted = new DeferredPromise<void>(); + readonly releaseList = new DeferredPromise<void>(); + override async listSessions() { + const snapshot = await super.listSessions(); + this.listStarted.complete(); + await this.releaseList.p; + return snapshot; + } + } + + const agent = new DelayedListAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + agent.resolvedWorkingDirectory = URI.file('/original'); + service.registerProvider(agent); + const { session } = await agent.createSession(); + + const listing = service.listSessions(); + await agent.listStarted.p; + service.stateManager.restoreSession({ + resource: session.toString(), + provider: 'copilot', + title: 'Materialized', + status: SessionStatus.Idle, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + project: { uri: URI.file('/project').toString(), displayName: 'project' }, + workingDirectory: URI.file('/worktree').toString(), + }, []); + agent.releaseList.complete(); + + const listed = (await listing).find(item => item.session.toString() === session.toString()); + assert.deepStrictEqual({ + modifiedTime: listed?.modifiedTime, + project: listed?.project && { uri: listed.project.uri.path, displayName: listed.project.displayName }, + workingDirectory: listed?.workingDirectory?.path, + }, { + modifiedTime: 2000, + project: { uri: '/project', displayName: 'project' }, + workingDirectory: '/worktree', + }); + }); + test.skip('listSessions synthesizes the session changeset catalogue from persisted diffs for unopened sessions', async () => { // Pre-seed a `'diffs'` blob in the in-memory DB. The agent's // `listSessions()` returns the session metadata but the session @@ -1271,6 +1359,9 @@ suite('AgentService (node dispatcher)', () => { updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); @@ -1280,7 +1371,9 @@ suite('AgentService (node dispatcher)', () => { agent.sessionMetadataOverrides = { workingDirectory }; localService.registerProvider(agent); - const session = await localService.createSession({ provider: 'copilot' }); + // A normal session passes an input workingDirectory, so it is not + // inferred workspace-less; `_meta` carries only the git overlay. + const session = await localService.createSession({ provider: 'copilot', workingDirectory }); // _attachGitState is fire-and-forget; drain microtasks until the // git service's promise has resolved and setSessionMeta has run. @@ -1339,7 +1432,7 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('createSession skips git overlay when no working directory or no git state', async () => { + test('createSession infers workspace-less (and skips git overlay) when no working directory', async () => { const gitService = { _serviceBrand: undefined, getCurrentBranch: async () => undefined, @@ -1365,6 +1458,9 @@ suite('AgentService (node dispatcher)', () => { updateRef: async () => { }, deleteRefs: async () => { }, revParse: async () => undefined, + resolveBranchBaselineCommit: async () => undefined, + overlayPathIntoTree: async () => undefined, + diffTreePaths: async () => undefined, computeFileDiffsBetweenRefs: async () => undefined, }; const localService = disposables.add(new AgentService(new NullLogService(), fileService, nullSessionDataService, { _serviceBrand: undefined } as IProductService, gitService)); @@ -1380,7 +1476,9 @@ suite('AgentService (node dispatcher)', () => { const sessions = await localService.listSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(localService.stateManager.getSessionState(session.toString())?._meta, undefined); + // No input workingDirectory → inferred workspace-less (tagged), and no + // git overlay because there is no working directory to probe. + assert.deepStrictEqual(localService.stateManager.getSessionState(session.toString())?._meta, { workspaceless: true }); }); test.skip('createSession strips git-only catalogue entries for non-git working directory', async () => { @@ -1760,6 +1858,23 @@ suite('AgentService (node dispatcher)', () => { assert.deepStrictEqual(await db.getChatDraft(chat), expected); } + test('restores the AH-owned workspaceless marker onto the summary _meta for any agent', async () => { + // The workspace-less marker is owned by the AH service and overlaid on + // restore from the central session DB — the agent (MockAgent) re-emits + // nothing itself, yet the restored session still carries the tag. + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + await db.setMetadata('agentHost.workspaceless', 'true'); + + await localService.restoreSession(sessionResource); + + assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?._meta, { workspaceless: true }); + }); + test('restores a session with message history', async () => { service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); @@ -1784,6 +1899,56 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(state!.turns[0].state, TurnState.Complete); }); + test('interleaves persisted host-injected local turns after their anchor on restore', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + const { session } = await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + + // SDK transcript reconstructs a single real turn keyed by the user + // envelope id (`msg-real`, per mapSessionEvents). + copilotAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-real', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-real-a', content: 'Hi there!', toolRequests: [] }, + ]; + + // A host-injected local turn anchored after the real turn, plus one + // with no anchor (precedes any real turn), plus an orphan whose + // anchor is absent from the SDK transcript (should be dropped). + const localTurn = (id: string, text: string) => ({ id, message: { text, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete }); + await db.insertLocalTurn({ turnId: 'local-head', chatUri: defaultChatUri, anchorTurnId: undefined, seq: 1, payload: JSON.stringify(localTurn('local-head', '!pwd')) }); + await db.insertLocalTurn({ turnId: 'local-after', chatUri: defaultChatUri, anchorTurnId: 'msg-real', seq: 2, payload: JSON.stringify(localTurn('local-after', '!ls')) }); + await db.insertLocalTurn({ turnId: 'local-orphan', chatUri: defaultChatUri, anchorTurnId: 'gone', seq: 3, payload: JSON.stringify(localTurn('local-orphan', '!echo')) }); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + // head (no anchor) first, then the real turn, then its anchored local; orphan dropped. + assert.deepStrictEqual(state!.turns.map(t => t.id), ['local-head', 'msg-real', 'local-after']); + }); + + + test('restores the default chat\'s independently-renamed title', async () => { + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(copilotAgent); + await copilotAgent.createSession(); + const sessionResource = (await copilotAgent.listSessions())[0].session; + copilotAgent.sessionMessages = []; + + // The host persists an independent default-chat rename under this key; + // restore must seed it back or the main chat tab reverts to the session title. + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + await db.setMetadata(`customChatTitle:${defaultChatUri}`, 'Renamed Default Chat'); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + assert.strictEqual(state?.chats.find(c => c.resource === defaultChatUri)?.title, 'Renamed Default Chat'); + }); + test('persists chat drafts to session metadata', async () => { const db = new TestSessionDatabase(); const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); @@ -2366,7 +2531,7 @@ suite('AgentService (node dispatcher)', () => { // scheme fallback instead of throwing `no provider for session`. const created: { session: string; chat: string }[] = []; class MultiChatAgent extends MockAgent { - async createChat(session: URI, chat: URI): Promise<void> { + override async createChat(session: URI, chat: URI): Promise<void> { created.push({ session: session.toString(), chat: chat.toString() }); } } @@ -2392,7 +2557,7 @@ suite('AgentService (node dispatcher)', () => { test('routes a tracked session and registers the chat with its title in the catalog', async () => { class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise<void> { } + override async createChat(_session: URI, _chat: URI): Promise<void> { } } const agent = disposables.add(new MultiChatAgent('copilot')); service.registerProvider(agent); @@ -2408,10 +2573,10 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('creates the backing conversation before registering the chat in the catalog', async () => { + test('creates the backing chat before registering the chat in the catalog', async () => { let catalogHadChatDuringCreate: boolean | undefined; class MultiChatAgent extends MockAgent { - async createChat(session: URI, chat: URI): Promise<void> { + override async createChat(session: URI, chat: URI): Promise<void> { const state = service.stateManager.getSessionState(session.toString()); catalogHadChatDuringCreate = !!state?.chats.some(c => c.resource.toString() === chat.toString()); } @@ -2437,11 +2602,11 @@ suite('AgentService (node dispatcher)', () => { ); }); - test('disposeChat removes the chat from the catalog and tears down the conversation', async () => { + test('disposeChat removes the chat from the catalog and tears down the chat', async () => { const disposed: string[] = []; class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise<void> { } - async disposeChat(_session: URI, chat: URI): Promise<void> { + override async createChat(_session: URI, _chat: URI): Promise<void> { } + override async disposeChat(_session: URI, chat: URI): Promise<void> { disposed.push(chat.toString()); } } @@ -2463,48 +2628,42 @@ suite('AgentService (node dispatcher)', () => { }); }); - test('restoreSession re-registers persisted peer chats with their history', async () => { + test('restoreSession preserves peer chat catalog order regardless of load timing', async () => { class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI): Promise<void> { } - async getChats(session: URI): Promise<readonly URI[]> { - return [URI.parse(buildChatUri(session, 'peer-1'))]; - } + override async createChat(_session: URI, _chat: URI): Promise<void> { } override async getSessionMessages(session: URI): Promise<readonly Turn[]> { - if (session.scheme === 'ahp-chat') { - return [{ - id: 'peer-turn-1', - state: TurnState.Complete, - message: { text: 'hi peer', origin: { kind: MessageKind.User } }, - responseParts: [], - usage: undefined, - }]; - } + // Resolve in the reverse of catalog order so a resolution-order + // append would scramble the catalog; the restore must keep a,b,c. + const delays: Record<string, number> = { 'peer-a': 30, 'peer-b': 15, 'peer-c': 0 }; + await timeout(delays[parseChatUri(session)?.chatId ?? ''] ?? 0); return []; } } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); const agent = disposables.add(new MultiChatAgent('copilot')); - service.registerProvider(agent); - const { session } = await agent.createSession(); - service.stateManager.deleteSession(session.toString()); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); - await service.restoreSession(session); + // Seed the orchestrator catalog in a,b,c order via createChat. + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-a'))); + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-b'))); + await localService.createChat(session, URI.parse(buildChatUri(session, 'peer-c'))); - const state = service.stateManager.getSessionState(session.toString()); - const peerUri = buildChatUri(session, 'peer-1'); - const peerChatState = service.stateManager.getChatState(URI.parse(peerUri).toString()); - assert.deepStrictEqual({ - inCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri), - peerTurnIds: peerChatState?.turns.map(t => t.id) ?? [], - }, { - inCatalog: true, - peerTurnIds: ['peer-turn-1'], - }); + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + const peerChatIds = (state?.chats ?? []) + .map(c => parseChatUri(c.resource)?.chatId) + .filter((id): id is string => !!id && id.startsWith('peer-')); + assert.deepStrictEqual(peerChatIds, ['peer-a', 'peer-b', 'peer-c']); }); test('fork seeds the new chat with remapped source turns and forwards fork to the provider', async () => { let receivedFork: IAgentCreateChatForkSource | undefined; class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> { receivedFork = options?.fork; } } @@ -2547,7 +2706,7 @@ suite('AgentService (node dispatcher)', () => { test('fork with an unknown turn id drops the fork and seeds no turns', async () => { let receivedFork: IAgentCreateChatForkSource | undefined; class MultiChatAgent extends MockAgent { - async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> { receivedFork = options?.fork; } } @@ -2572,6 +2731,860 @@ suite('AgentService (node dispatcher)', () => { newTurnCount: 0, }); }); + + test('fork at a host-injected local turn redirects the SDK boundary to the concrete anchor and carries the local turn into the new chat', async () => { + let receivedFork: IAgentCreateChatForkSource | undefined; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI, options?: IAgentCreateChatOptions): Promise<void> { + receivedFork = options?.fork; + } + } + const db = new TestSessionDatabase(); + const agent = disposables.add(new MultiChatAgent('copilot')); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(agent); + const { session } = await agent.createSession(); + const sessionResource = (await agent.listSessions())[0].session; + const defaultChatUri = buildDefaultChatUri(sessionResource.toString()); + + // SDK transcript reconstructs a single real turn keyed by the user + // message id; a host-injected local turn is persisted after it. + agent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'real-1-a', content: 'Hi', toolRequests: [] }, + ]; + const localTurn: Turn = { id: 'local-1', state: TurnState.Complete, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }; + await db.insertLocalTurn({ turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) }); + + // Restore so the source chat interleaves [real-1, local-1] and the + // in-memory local index knows local-1 is a local turn. + await localService.restoreSession(sessionResource); + assert.deepStrictEqual(localService.stateManager.getSessionState(sessionResource.toString())?.turns.map(t => t.id), ['real-1', 'local-1']); + + // Fork the default chat AT the local turn into a new peer chat. + const peerUri = URI.parse(buildChatUri(sessionResource, 'peer-1')); + await localService.createChat(sessionResource, peerUri, { fork: { source: URI.parse(defaultChatUri), turnId: 'local-1' } }); + + const peerTurns = localService.stateManager.getChatState(peerUri.toString())?.turns ?? []; + const forkedLocals = (await db.getLocalTurns()).filter(r => r.chatUri === peerUri.toString()); + assert.deepStrictEqual({ + // SDK fork boundary redirected from the local turn to its concrete anchor. + sdkForkTurnId: receivedFork?.turnId, + // New chat seeded with remapped copies of both turns. + peerTurnCount: peerTurns.length, + // The forked local turn is persisted under the new chat, anchored to + // the forked copy of the real turn. + forkedLocalCount: forkedLocals.length, + forkedLocalAnchor: forkedLocals[0]?.anchorTurnId, + anchorIsPeerFirstTurn: forkedLocals[0]?.anchorTurnId === peerTurns[0]?.id, + }, { + sdkForkTurnId: 'real-1', + peerTurnCount: 2, + forkedLocalCount: 1, + forkedLocalAnchor: peerTurns[0]?.id, + anchorIsPeerFirstTurn: true, + }); + }); + + test('a peer chat backing session is filtered out of listSessions and stays filtered across a restart', async () => { + // Per-session databases so the backing SDK session's marker is + // isolated from the parent session's own database. + const dbs = new Map<string, TestSessionDatabase>(); + const dbFor = (session: URI): TestSessionDatabase => { + const key = session.toString(); + let db = dbs.get(key); + if (!db) { + db = new TestSessionDatabase(); + dbs.set(key, db); + } + return db; + }; + const perSessionDataService: ISessionDataService = { + ...createSessionDataService(), + openDatabase: (session: URI): IReference<ISessionDatabase> => ({ object: dbFor(session), dispose: () => { } }), + tryOpenDatabase: async (session: URI): Promise<IReference<ISessionDatabase> | undefined> => ({ object: dbFor(session), dispose: () => { } }), + }; + + const backingSdkId = 'backing-sdk-id'; + const backingUri = AgentSession.uri('copilot', backingSdkId).toString(); + // A Claude-like agent whose peer-chat backing is a fresh SDK session + // it also enumerates from listSessions — the leak this fix suppresses. + class LeakyMultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise<IAgentCreateChatResult> { + return { providerData: 'blob', backingSession: AgentSession.uri(this.id, backingSdkId) }; + } + override async listSessions(): Promise<IAgentSessionMetadata[]> { + const base = await super.listSessions(); + return [...base, { session: AgentSession.uri(this.id, backingSdkId), startTime: Date.now(), modifiedTime: Date.now() }]; + } + } + + const agent = disposables.add(new LeakyMultiChatAgent('copilot')); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + const session = await svc.createSession({ provider: 'copilot' }); + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await svc.createChat(session, chatUri); + + const beforeRestart = await svc.listSessions(); + + // Simulate a host restart: a fresh service over the same persisted + // databases, with a fresh agent still leaking the backing session. + const restartAgent = disposables.add(new LeakyMultiChatAgent('copilot')); + const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + restarted.registerProvider(restartAgent); + const afterRestart = await restarted.listSessions(); + + assert.deepStrictEqual({ + leakedBeforeRestart: beforeRestart.map(s => s.session.toString()).includes(backingUri), + markerPersisted: await dbFor(AgentSession.uri('copilot', backingSdkId)).getMetadata('peerChatBacking'), + leakedAfterRestart: afterRestart.map(s => s.session.toString()).includes(backingUri), + }, { + leakedBeforeRestart: false, + markerPersisted: chatUri.toString(), + leakedAfterRestart: false, + }); + }); + }); + + // ---- chat surface routing (G-C1) ---------------------------- + + suite('chat surface routing', () => { + + /** + * An agent that exposes the chat surface AND the legacy + * `(session, chat?)` peer-chat methods, recording which path the + * orchestrator takes. + */ + class ChatSurfaceAgent extends MockAgent { + readonly sessionCreateCalls: URI[] = []; + readonly sessionDisposeCalls: URI[] = []; + readonly legacyCreateChatCalls: URI[] = []; + readonly chatCalls: { op: string; args: string[] }[] = []; + + override async createSession(config?: import('../../common/agentService.js').IAgentCreateSessionConfig): Promise<IAgentCreateSessionResult> { + const result = await super.createSession(config); + this.sessionCreateCalls.push(result.session); + return result; + } + + override async disposeSession(session: URI): Promise<void> { + this.sessionDisposeCalls.push(session); + await super.disposeSession(session); + } + + // The legacy peer-chat method is present too; it must NOT be used + // when the chats surface exists. + override async createChat(_session: URI, chat: URI): Promise<void> { + this.legacyCreateChatCalls.push(chat); + } + + override readonly chats: IAgentChats = { + createChat: async (chat: URI, options?: IAgentCreateChatOptions) => { + const session = parseChatUri(chat)!.session; + this.chatCalls.push({ op: 'createChat', args: [session, chat.toString(), options?.title ?? ''] }); + return { providerData: 'pd' }; + }, + fork: async (chat: URI, source: IAgentCreateChatForkSource) => { + const session = parseChatUri(chat)!.session; + this.chatCalls.push({ op: 'fork', args: [session, chat.toString(), source.source.toString(), source.turnId] }); + return { providerData: 'pd-fork' }; + }, + disposeChat: async (chat: URI) => { + this.chatCalls.push({ op: 'disposeChat', args: [chat.toString()] }); + }, + sendMessage: async () => { }, + abort: async () => { }, + changeModel: async () => { }, + changeAgent: async () => { }, + getMessages: async (chat: URI) => { + this.chatCalls.push({ op: 'getMessages', args: [chat.toString()] }); + return []; + }, + }; + } + + test('createSession/createChat/disposeChat/disposeSession prefer the chat surface over legacy methods', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + + const session = await service.createSession({ provider: 'copilot' }); + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { title: 'Peer' }); + await service.disposeChat(session, chatUri); + await service.disposeSession(session); + + assert.deepStrictEqual({ + sessionCreate: agent.sessionCreateCalls.map(s => s.toString()), + sessionDispose: agent.sessionDisposeCalls.map(s => s.toString()), + legacyCreateChat: agent.legacyCreateChatCalls.length, + chatOps: agent.chatCalls.map(c => c.op), + createChatArgs: agent.chatCalls.find(c => c.op === 'createChat')?.args, + disposeChatArg: agent.chatCalls.find(c => c.op === 'disposeChat')?.args[0], + }, { + sessionCreate: [session.toString()], + sessionDispose: [session.toString()], + legacyCreateChat: 0, + chatOps: ['createChat', 'disposeChat'], + createChatArgs: [session.toString(), chatUri.toString(), 'Peer'], + disposeChatArg: chatUri.toString(), + }); + }); + + test('fork routes to chats.fork with the resolved source chat', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const sourceTurns: Turn[] = [ + { id: 't1', state: TurnState.Complete, message: { text: 'first', origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined }, + ]; + service.stateManager.seedDefaultChatTurns(session.toString(), sourceTurns); + + const chatUri = URI.parse(buildChatUri(session, 'peer-1')); + await service.createChat(session, chatUri, { fork: { source: session, turnId: 't1' } }); + + const forkCall = agent.chatCalls.find(c => c.op === 'fork'); + assert.deepStrictEqual(forkCall?.args, [session.toString(), chatUri.toString(), session.toString(), 't1']); + }); + + test('restore reads the default chat via chats.getMessages on the default chat URI', async () => { + const agent = disposables.add(new ChatSurfaceAgent('copilot')); + service.registerProvider(agent); + const { session } = await agent.createSession(); + service.stateManager.deleteSession(session.toString()); + + await service.restoreSession(session); + + const getMessages = agent.chatCalls.filter(c => c.op === 'getMessages').map(c => c.args[0]); + assert.deepStrictEqual(getMessages, [buildDefaultChatUri(session)]); + }); + }); + + // ---- spawn channel routing (G-D1) ----------------------------------- + + suite('spawn channel routing', () => { + + /** + * An agent that exposes the first-class spawn membership channel, + * with a test hook to fire {@link IAgent.onDidSpawnChat}. + */ + class SpawnChannelAgent extends MockAgent { + private readonly _onDidSpawnChat = new Emitter<IAgentSpawnChatEvent>(); + readonly onDidSpawnChat = this._onDidSpawnChat.event; + + fireSpawn(e: IAgentSpawnChatEvent): void { + this._onDidSpawnChat.fire(e); + } + + override dispose(): void { + this._onDidSpawnChat.dispose(); + super.dispose(); + } + } + + test('onDidSpawnChat adds the chat to the catalog with a Tool origin from its parent', async () => { + const agent = disposables.add(new SpawnChannelAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const parentChat = URI.parse(buildDefaultChatUri(session.toString())); + const spawned = URI.parse(buildChatUri(session, 'spawned-1')); + agent.fireSpawn({ + session, + chat: spawned, + parent: { chat: parentChat, toolCallId: 'tc-task-1' }, + title: 'Explore', + }); + + const chatState = service.stateManager.getChatState(spawned.toString()); + const sessionChats = (service.stateManager.getSessionState(session.toString())?.chats ?? []).map(c => c.resource); + assert.deepStrictEqual({ + title: chatState?.title, + origin: chatState?.origin, + inCatalog: sessionChats.includes(spawned.toString()), + }, { + title: 'Explore', + origin: { kind: ChatOriginKind.Tool, chat: parentChat.toString(), toolCallId: 'tc-task-1' }, + inCatalog: true, + }); + }); + + test('onDidSpawnChat without a parent adds the chat with no tool origin', async () => { + const agent = disposables.add(new SpawnChannelAgent('copilot')); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + + const spawned = URI.parse(buildChatUri(session, 'spawned-2')); + agent.fireSpawn({ session, chat: spawned }); + + const chatState = service.stateManager.getChatState(spawned.toString()); + assert.deepStrictEqual({ + origin: chatState?.origin, + inCatalog: chatState !== undefined, + }, { + origin: undefined, + inCatalog: true, + }); + }); + }); + + // ---- subagent membership sequencing (DR1: unified spawn channel) ---- + + suite('subagent membership sequencing', () => { + + /** Fires a parent turn on the session's default chat. */ + function startParentTurn(session: URI, turnId: string): void { + service.dispatchAction( + buildDefaultChatUri(session.toString()), + { type: ActionType.ChatTurnStarted, turnId, message: { text: 'go', origin: { kind: MessageKind.User } } }, + 'client-test', 1, + ); + } + + test('a subagent_started signal yields exactly one catalog entry with the parent origin, title, and a started turn', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + taskDescription: 'Review package.json structure', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const chatState = service.stateManager.getChatState(subagentUri); + const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + assert.deepStrictEqual({ + catalogEntries: matching.length, + title: chatState?.title, + origin: chatState?.origin, + interactivity: chatState?.interactivity, + hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + catalogEntries: 1, + // The concise per-task description names the tab (distinct even for + // two subagents of the same type), not the agent-type display name. + title: 'Review package.json structure', + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' }, + interactivity: 'read-only', + hasStartedTurn: true, + }); + }); + + test('the spawned catalog chat is resolvable from the inline pill resource via parseChatUri (the Open-Subagent contract)', async () => { + // The inline subagent pill (`ToolResultSubagentContent.resource`) and + // the catalog chat are both built from `buildSubagentChatUri`, and the + // Agents window resolves the pill to its tab by matching + // `parseChatUri(pillResource).chatId` against the catalog chat's + // parsed chatId (see `findSubagentChat`/`matchesResource` in + // `openSubagentChat.ts`). If the two ever desync, the pill shows the + // fallback "Open Subagent" label and clicking it no-ops. Guard the + // round-trip so the pill stays resolvable. + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + // The resource the inline pill carries for this subagent. + const pillResource = buildSubagentChatUri(session.toString(), 'tc-sub'); + const pillChatId = parseChatUri(pillResource)?.chatId; + const catalog = service.stateManager.getSessionState(session.toString())?.chats ?? []; + const resolvedByPill = catalog.filter(c => parseChatUri(c.resource)?.chatId === pillChatId); + assert.deepStrictEqual({ + pillChatId, + resolvedCatalogEntries: resolvedByPill.length, + }, { + pillChatId: 'subagent/tc-sub', + resolvedCatalogEntries: 1, + }); + }); + + test('a subagent_started signal without a taskDescription falls back to the agent display name for the tab title', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + assert.strictEqual(service.stateManager.getChatState(subagentUri)?.title, 'Explore'); + }); + + test('membership stays a single entry when the agent also mirrors the subagent onto onDidSpawnChat, regardless of order', async () => { + // Mirror the real copilot/claude agents, which ALSO bridge their + // subagent signals onto onDidSpawnChat. The orchestrator's + // progress sequencer and the agent's spawn bridge both funnel to the + // idempotent _onChatSpawned, so the catalog must gain exactly + // one entry no matter which listener runs first. + class BridgingSubagentAgent extends MockAgent { + private readonly _onDidSpawnChat = new Emitter<IAgentSpawnChatEvent>(); + readonly onDidSpawnChat = this._onDidSpawnChat.event; + private readonly _bridge = this.onDidSessionProgress(signal => { + const e = SubagentChatSignal.toSpawnEvent(signal); + if (e) { + this._onDidSpawnChat.fire(e); + } + }); + + override dispose(): void { + this._bridge.dispose(); + this._onDidSpawnChat.dispose(); + super.dispose(); + } + } + + const agent = new BridgingSubagentAgent('copilot'); + disposables.add(toDisposable(() => agent.dispose())); + service.registerProvider(agent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + agent.fireProgress({ + kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', + agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores', + }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const matching = (service.stateManager.getSessionState(session.toString())?.chats ?? []).filter(c => c.resource === subagentUri); + assert.deepStrictEqual({ + catalogEntries: matching.length, + origin: service.stateManager.getChatState(subagentUri)?.origin, + hasStartedTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + catalogEntries: 1, + origin: { kind: ChatOriginKind.Tool, chat: parentChat, toolCallId: 'tc-sub' }, + hasStartedTurn: true, + }); + }); + + test('an inner tool call arriving before subagent_started is buffered and drained onto the subagent chat', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + // Parent task tool starts. + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-sub', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-sub', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Inner tool arrives BEFORE subagent_started (buffered). + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'inner-1', toolName: 'read', displayName: 'Read', contributor: undefined, _meta: { toolKind: undefined, language: undefined } } }); + copilotAgent.fireProgress({ kind: 'action', resource: URI.parse(parentChat), parentToolCallId: 'tc-sub', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'inner-1', invocationMessage: 'Reading...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // subagent_started arrives and drains the buffer. + copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + const subState = service.stateManager.getSessionState(subagentUri); + const innerOnSubagent = subState?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); + const innerOnParent = service.stateManager.getSessionState(session.toString())?.activeTurn?.responseParts.some(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === 'inner-1'); + assert.deepStrictEqual({ innerOnSubagent, innerOnParent }, { innerOnSubagent: true, innerOnParent: false }); + }); + + test('a subagent chat survives subagent_completed (stays live and subscribable, its turn completed)', async () => { + service.registerProvider(copilotAgent); + const session = await service.createSession({ provider: 'copilot' }); + const parentChat = buildDefaultChatUri(session.toString()); + startParentTurn(session, 'turn-1'); + + copilotAgent.fireProgress({ kind: 'subagent_started', chat: URI.parse(parentChat), toolCallId: 'tc-sub', agentName: 'explore', agentDisplayName: 'Explore', agentDescription: 'Explores' }); + const subagentUri = buildSubagentChatUri(session.toString(), 'tc-sub'); + assert.ok(service.stateManager.getChatState(subagentUri), 'precondition: subagent chat present after start'); + + copilotAgent.fireProgress({ kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId: 'tc-sub' }); + + const stillInCatalog = (service.stateManager.getSessionState(session.toString())?.chats ?? []).some(c => c.resource === subagentUri); + assert.deepStrictEqual({ + hasChatState: service.stateManager.getChatState(subagentUri) !== undefined, + stillInCatalog, + hasActiveTurn: service.stateManager.getActiveTurnId(subagentUri) !== undefined, + }, { + hasChatState: true, + stillInCatalog: true, + hasActiveTurn: false, + }); + }); + }); + + // ---- peer-chat catalog persistence (B2: orchestrator-owned) --------- + + suite('peer chat catalog persistence', () => { + + /** Polls the persisted peer-chat catalog blob until it appears or times out. */ + async function readCatalog(db: TestSessionDatabase): Promise<{ uri: string; providerData?: string }[]> { + for (let i = 0; i < 50; i++) { + const raw = await db.getMetadata('peerChats'); + if (raw !== undefined) { + return JSON.parse(raw); + } + await timeout(0); + } + return []; + } + + test('createChat persists providerData; restore re-materializes from the orchestrator catalog before reading history', async () => { + const materializeOrder: { call: string; uri: string; providerData?: string }[] = []; + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'blob-1' }; + } + async materializeChat(chat: URI, providerData: string | undefined): Promise<void> { + materializeOrder.push({ call: 'materialize', uri: chat.toString(), providerData }); + } + override async getSessionMessages(session: URI): Promise<readonly Turn[]> { + if (session.scheme === 'ahp-chat') { + materializeOrder.push({ call: 'getMessages', uri: session.toString() }); + return [{ + id: 'peer-turn-1', + state: TurnState.Complete, + message: { text: 'hi peer', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }]; + } + return []; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + await readCatalog(db); + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + const peerChatState = localService.stateManager.getChatState(peerUri.toString()); + assert.deepStrictEqual({ + order: materializeOrder.map(o => o.call), + materializedWith: materializeOrder.find(o => o.call === 'materialize')?.providerData, + inCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()), + restoredProviderData: localService.stateManager.getChatProviderData(peerUri.toString()), + peerTurnIds: peerChatState?.turns.map(t => t.id) ?? [], + }, { + // The default chat is read first; peer materialize must precede + // the peer history read on restore. + order: ['getMessages', 'materialize', 'getMessages'], + materializedWith: 'blob-1', + inCatalog: true, + restoredProviderData: 'blob-1', + peerTurnIds: ['peer-turn-1'], + }); + }); + + test('onDidChangeChatData re-persists the updated providerData blob', async () => { + const onDidChangeChatData = disposables.add(new Emitter<IAgentChatDataChange>()); + class MultiChatAgent extends MockAgent { + readonly onDidChangeChatData = onDidChangeChatData.event; + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'v1' }; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + const afterCreate = await readCatalog(db); + + onDidChangeChatData.fire({ chat: peerUri, providerData: 'v2' }); + // Wait for the re-persist write to flush. + let updated = afterCreate; + for (let i = 0; i < 50; i++) { + updated = await readCatalog(db); + if (updated.find(e => e.uri === peerUri.toString())?.providerData === 'v2') { + break; + } + await timeout(0); + } + + assert.deepStrictEqual({ + afterCreate: afterCreate.find(e => e.uri === peerUri.toString())?.providerData, + afterChange: updated.find(e => e.uri === peerUri.toString())?.providerData, + }, { + afterCreate: 'v1', + afterChange: 'v2', + }); + }); + + test('disposeChat removes the chat from the persisted catalog', async () => { + class MultiChatAgent extends MockAgent { + override async createChat(_session: URI, _chat: URI): Promise<{ providerData?: string }> { + return { providerData: 'blob-1' }; + } + override async disposeChat(_session: URI, _chat: URI): Promise<void> { } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new MultiChatAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + const afterCreate = await readCatalog(db); + + await localService.disposeChat(session, peerUri); + let afterDispose = afterCreate; + for (let i = 0; i < 50; i++) { + afterDispose = await readCatalog(db); + if (!afterDispose.some(e => e.uri === peerUri.toString())) { + break; + } + await timeout(0); + } + + assert.deepStrictEqual({ + afterCreate: afterCreate.map(e => e.uri), + afterDispose: afterDispose.map(e => e.uri), + }, { + afterCreate: [peerUri.toString()], + afterDispose: [], + }); + }); + + // ---- BC1: one-time legacy `*.chats` migration on restore ---------- + + test('legacy *.chats with no peerChats catalog migrates once into the orchestrator catalog', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + override async createChat(): Promise<IAgentCreateChatResult | void> { } + async materializeChat(): Promise<void> { } + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { + this.listLegacyCallCount++; + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + ]; + } + override async getSessionMessages(session: URI): Promise<readonly Turn[]> { + if (session.scheme === 'ahp-chat') { + return [{ + id: `${parseChatUri(session)?.chatId}-turn`, + state: TurnState.Complete, + message: { text: 'legacy hi', origin: { kind: MessageKind.User } }, + responseParts: [], + usage: undefined, + }]; + } + return []; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Seed a persisted title for one legacy chat so we can assert + // history + title are restored. + const legacyAUri = URI.parse(buildChatUri(session, 'legacy-a')); + const legacyBUri = URI.parse(buildChatUri(session, 'legacy-b')); + await db.setMetadata(`customChatTitle:${legacyAUri.toString()}`, 'Legacy A Title'); + + // No peerChats key exists (undefined catalog) -> migration runs. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalogAfterFirst = await readCatalog(db); + + // Second restore: catalog now present -> legacy read not consulted again. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const stateA = localService.stateManager.getChatState(legacyAUri.toString()); + const stateB = localService.stateManager.getChatState(legacyBUri.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + catalog: catalogAfterFirst.map(e => ({ uri: e.uri, providerData: e.providerData })), + aTitle: stateA?.title, + aTurns: stateA?.turns.map(t => t.id) ?? [], + aProviderData: localService.stateManager.getChatProviderData(legacyAUri.toString()), + bTurns: stateB?.turns.map(t => t.id) ?? [], + bProviderData: localService.stateManager.getChatProviderData(legacyBUri.toString()), + }, { + legacyCalls: 1, + catalog: [ + { uri: legacyAUri.toString(), providerData: 'lp-a' }, + { uri: legacyBUri.toString(), providerData: 'lp-b' }, + ], + aTitle: 'Legacy A Title', + aTurns: ['legacy-a-turn'], + aProviderData: 'lp-a', + bTurns: ['legacy-b-turn'], + bProviderData: 'lp-b', + }); + }); + + test('an empty ([]) peerChats catalog does not resurrect legacy chats', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { + this.listLegacyCallCount++; + return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Known-empty catalog must be treated as "no peer chats", never migrated. + await db.setMetadata('peerChats', '[]'); + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + peerChats: (state?.chats ?? []).map(c => parseChatUri(c.resource)?.chatId).filter(id => id !== 'default'), + }, { + legacyCalls: 0, + peerChats: [], + }); + }); + + test('a valid new-format peerChats catalog restores without consulting legacy chats', async () => { + class LegacyAgent extends MockAgent { + listLegacyCallCount = 0; + override async createChat(): Promise<IAgentCreateChatResult | void> { + return { providerData: 'new-blob' }; + } + async materializeChat(): Promise<void> { } + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { + this.listLegacyCallCount++; + return [{ uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + const peerUri = URI.parse(buildChatUri(session, 'peer-1')); + await localService.createChat(session, peerUri); + await readCatalog(db); + + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + + const state = localService.stateManager.getSessionState(session.toString()); + assert.deepStrictEqual({ + legacyCalls: agent.listLegacyCallCount, + peerInCatalog: !!state?.chats.some(c => c.resource.toString() === peerUri.toString()), + legacyInCatalog: state?.chats.some(c => parseChatUri(c.resource)?.chatId === 'legacy-a') ?? false, + }, { + legacyCalls: 0, + peerInCatalog: true, + legacyInCatalog: false, + }); + }); + // ---- RV-1: legacy migration persists the catalog atomically ---------- + + test('legacy migration persists the whole set in one write (never a subset, even across a re-restore)', async () => { + class LegacyAgent extends MockAgent { + override async createChat(): Promise<IAgentCreateChatResult | void> { } + async materializeChat(): Promise<void> { } + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + { uri: URI.parse(buildChatUri(session, 'legacy-c')), providerData: 'lp-c' }, + ]; + } + } + const db = new TestSessionDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // Absent peerChats key => migration runs and must write the full set once. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalog = await readCatalog(db); + + const restoredIds = (localService.stateManager.getSessionState(session.toString())?.chats ?? []) + .map(c => parseChatUri(c.resource)?.chatId) + .filter(id => id !== 'default'); + assert.deepStrictEqual({ + catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId), + restoredIds, + }, { + catalogIds: ['legacy-a', 'legacy-b', 'legacy-c'], + restoredIds: ['legacy-a', 'legacy-b', 'legacy-c'], + }); + }); + + test('a rejected migration write leaves the catalog absent (not a subset) so migration re-runs', async () => { + class FailingCatalogDatabase extends TestSessionDatabase { + failPeerChatsWrites = 1; + override async setMetadata(key: string, value: string): Promise<void> { + if (key === 'peerChats' && this.failPeerChatsWrites > 0) { + this.failPeerChatsWrites--; + throw new Error('simulated catalog write failure'); + } + return super.setMetadata(key, value); + } + } + class LegacyAgent extends MockAgent { + override async createChat(): Promise<IAgentCreateChatResult | void> { } + async materializeChat(): Promise<void> { } + async listLegacyChats(session: URI): Promise<readonly IAgentLegacyChat[]> { + return [ + { uri: URI.parse(buildChatUri(session, 'legacy-a')), providerData: 'lp-a' }, + { uri: URI.parse(buildChatUri(session, 'legacy-b')), providerData: 'lp-b' }, + ]; + } + } + const db = new FailingCatalogDatabase(); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(db), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new LegacyAgent('copilot')); + localService.registerProvider(agent); + const session = await localService.createSession({ provider: 'copilot' }); + + // First restore: the single catalog write is rejected. Because the write + // is all-or-nothing, the key must stay absent (never a proper subset). + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalogAfterFailedWrite = await db.getMetadata('peerChats'); + + // Second restore: catalog still absent => migration re-runs and now + // persists the complete set. + localService.stateManager.deleteSession(session.toString()); + await localService.restoreSession(session); + const catalog = await readCatalog(db); + + assert.deepStrictEqual({ + catalogAfterFailedWrite, + catalogIds: catalog.map(e => parseChatUri(URI.parse(e.uri))?.chatId), + }, { + catalogAfterFailedWrite: undefined, + catalogIds: ['legacy-a', 'legacy-b'], + }); + }); }); suite('subscriber refcount eviction', () => { @@ -2608,7 +3621,9 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'active-turn session must not be evicted'); }); - test('a restored idle session is evicted when its last subscriber drops', async () => { + test('a restored idle session is NOT evicted when its last subscriber drops', async () => { + // Idle-session eviction is currently disabled; the cached state should + // remain in memory after the last subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2623,10 +3638,12 @@ suite('AgentService (node dispatcher)', () => { service.unsubscribe(sessionResource, 'client-1'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'restored idle session should be evicted'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'restored idle session should stay cached while eviction is disabled'); }); - test('multiple subscribers keep a restored session alive until all drop', async () => { + test('restored session stays cached after all subscribers drop', async () => { + // With idle eviction disabled, the session state remains cached even + // after every subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2644,7 +3661,7 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'still subscribed by client-2'); service.unsubscribe(sessionResource, 'client-2'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'evicted after last subscriber'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'session stays cached after all subscribers drop while eviction is disabled'); }); test('subagent subscriber pins the parent session against eviction', async () => { @@ -2674,10 +3691,10 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent must stay while child is subscribed'); assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child still present'); - // Child drops — both can now be evicted + // Child drops — with idle eviction disabled, both stay cached. service.unsubscribe(childUri, 'client-child'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'parent evicted after subagent drops'); - assert.strictEqual(service.stateManager.getSessionState(childUri.toString()), undefined, 'child also evicted with parent'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'parent stays cached after subagent drops while eviction is disabled'); + assert.ok(service.stateManager.getSessionState(childUri.toString()), 'child stays cached after subagent drops while eviction is disabled'); }); test('nested subagent subscriber pins ancestor session against eviction', async () => { @@ -2709,10 +3726,9 @@ suite('AgentService (node dispatcher)', () => { assert.ok(service.stateManager.getSessionState(childUri.toString()), 'intermediate child still present'); }); - test('depth-2 subagent eviction evicts the root session state', async () => { - // Regression: when a depth-2 subagent URI unsubscribes the eviction - // must reach all the way to the root, not stop at the intermediate - // parent and leave root state cached indefinitely. + test('depth-2 subagent unsubscribe does NOT evict the root session state', async () => { + // Idle-session eviction is currently disabled, so the root state + // should remain cached after the depth-2 subscriber drops. service.registerProvider(copilotAgent); const { session } = await copilotAgent.createSession(); const sessions = await copilotAgent.listSessions(); @@ -2730,7 +3746,7 @@ suite('AgentService (node dispatcher)', () => { service.addSubscriber(nestedUri, 'client-nested'); service.unsubscribe(nestedUri, 'client-nested'); - assert.strictEqual(service.stateManager.getSessionState(sessionResource.toString()), undefined, 'root state must be evicted when no subscribers remain'); + assert.ok(service.stateManager.getSessionState(sessionResource.toString()), 'root state stays cached when no subscribers remain while eviction is disabled'); }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 82218bf7fad264..2d63bf755c0281 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -17,14 +17,14 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; -import { AgentSession, IAgent } from '../../common/agentService.js'; -import { buildDefaultChangesetCatalogue } from '../../common/changesetUri.js'; +import { AgentSession, IAgent, SubagentChatSignal } from '../../common/agentService.js'; +import { buildDefaultChangesetCatalog } from '../../common/changesetUri.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import type { RootConfigChangedAction } from '../../common/state/protocol/actions.js'; -import { ChangesSummary, CustomizationType } from '../../common/state/protocol/state.js'; +import { ChangesSummary, ChatOriginKind, CustomizationType, SessionInputRequestKind } from '../../common/state/protocol/state.js'; import { ActionType, ActionEnvelope, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js'; -import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { buildSubagentChatUri, buildChatUri, buildDefaultChatUri, CustomizationLoadStatus, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, SessionInputResponseKind, SessionStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, customizationId, type ClientPluginCustomization, type Customization, type PluginCustomization } from '../../common/state/sessionState.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; @@ -36,10 +36,13 @@ import { IAgentHostChangesetService, StaticChangesetKind } from '../../common/ag import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentService } from '../../node/agentService.js'; import { AgentSideEffects, IAgentSideEffectsOptions } from '../../node/agentSideEffects.js'; +import { AgentHostLocalTurns } from '../../node/agentHostLocalTurns.js'; +import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; -import { createNoopGitService, createNullSessionDataService, createSessionDataService } from '../common/sessionTestHelpers.js'; +import { createNoopGitService, createNullSessionDataService, createSessionDataService, TestSessionDatabase } from '../common/sessionTestHelpers.js'; import { MockAgent } from './mockAgent.js'; +import { TestAgentHostTerminalManager } from './testAgentHostTerminalManager.js'; // ---- Tests ------------------------------------------------------------------ @@ -60,6 +63,7 @@ class FakeChangesetService implements IAgentHostChangesetService { isStaticChangesetComputeActive(): boolean { return false; } getListMetadataKeys(_sessionUri: string): Record<string, true> | undefined { return undefined; } computeListEntryChanges(_sessionUri: string, _metadata: Record<string, string | undefined>): ChangesSummary | undefined { return undefined; } + refreshChangesetCatalog(session: string): void { /* no-op */ } refreshBranchChangeset(): void { /* no-op */ } refreshSessionChangeset(): void { /* no-op */ } onWorkingDirectoryAvailable(): void { /* no-op */ } @@ -94,10 +98,11 @@ class FakeChangesetService implements IAgentHostChangesetService { function createTestSideEffects( disposables: DisposableStore, stateManager: AgentHostStateManager, - options: IAgentSideEffectsOptions, + options: Omit<IAgentSideEffectsOptions, 'localTurns'> & { localTurns?: AgentHostLocalTurns }, _gitService?: IAgentHostGitService, telemetryService: ITelemetryService = NullTelemetryService, changesets: IAgentHostChangesetService = new FakeChangesetService(), + terminalManager: IAgentHostTerminalManager = disposables.add(new TestAgentHostTerminalManager()), ): AgentSideEffects { const logService = new NullLogService(); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); @@ -107,8 +112,14 @@ function createTestSideEffects( [IAgentHostChangesetService, changesets], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [ITelemetryService, telemetryService], + [IAgentHostTerminalManager, terminalManager], + [ISessionDataService, options.sessionDataService], ), /*strict*/ true)); - return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, options)); + const resolvedOptions: IAgentSideEffectsOptions = { + ...options, + localTurns: options.localTurns ?? new AgentHostLocalTurns(options.sessionDataService, logService), + }; + return disposables.add(instantiationService.createInstance(AgentSideEffects, stateManager, resolvedOptions)); } class TestTelemetryService implements ITelemetryService { @@ -156,7 +167,7 @@ suite('AgentSideEffects', () => { project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectory, }); - stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalogue(sessionUri.toString())); + stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); } @@ -224,6 +235,21 @@ suite('AgentSideEffects', () => { sessionDataService: createNullSessionDataService(), onTurnComplete: () => { }, }, undefined, disposables.add(new AgentHostTelemetryService(telemetryService))); + + // Mimic the orchestrator's spawn channel: in production AgentService adds + // a subagent's chat to the catalog (via _onChatSpawned) before + // AgentSideEffects starts its turn. Registered here (ahead of each test's + // registerProgressListener) so the subagent chat exists first. addChat is + // idempotent, matching the real spawn-channel/side-effects overlap. + disposables.add(agent.onDidSessionProgress(signal => { + const spawn = SubagentChatSignal.toSpawnEvent(signal); + if (spawn) { + stateManager.addChat(spawn.session.toString(), spawn.chat.toString(), { + title: spawn.title, + origin: spawn.parent ? { kind: ChatOriginKind.Tool, chat: spawn.parent.chat.toString(), toolCallId: spawn.parent.toolCallId } : undefined, + }); + } + })); }); teardown(() => { @@ -249,6 +275,26 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(agent.sendMessageCalls, [{ session: URI.parse(sessionUri.toString()), prompt: 'hello world', attachments: undefined, chat: URI.parse(defaultChatUri) }]); }); + test('passes the dispatching client id to sendMessage', async () => { + setupSession(); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'hello world', origin: { kind: MessageKind.User } }, + }; + sideEffects.handleAction(defaultChatUri, action, 'client-B'); + + await waitForSendMessageCalls(1); + + assert.deepStrictEqual(agent.sendMessageCalls, [{ + session: URI.parse(sessionUri.toString()), + prompt: 'hello world', + attachments: undefined, + chat: URI.parse(defaultChatUri), + senderClientId: 'client-B', + }]); + }); + test('logs telemetry when sending a direct user message', () => { setupSession(); const activeClientAction: SessionAction = { @@ -446,6 +492,208 @@ suite('AgentSideEffects', () => { }); }); + // ---- handleAction: generic ! terminal command ------------------------ + + suite('handleAction — ! terminal command', () => { + + function createBangSideEffects(terminalManager: TestAgentHostTerminalManager): AgentSideEffects { + return createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createNullSessionDataService(), + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + } + + test('runs a ! message as a terminal command and completes the turn without calling the agent', async () => { + setupSession('file:///work'); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createBangSideEffects(terminalManager); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + // Mirror production: the reducer opens the turn, then side effects run. + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + // Wait until the command is running (its completion listener is + // registered), then signal that the command finished. + await terminalManager.commandFinishedListenerRegistered.p; + const terminalUri = terminalManager.created[0].channel; + terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' }); + + // Wait for the turn to be closed out. + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + + assert.deepStrictEqual(agent.sendMessageCalls, []); + const state = stateManager.getSessionState(sessionUri.toString()); + const part = state?.turns.at(-1)?.responseParts[0]; + assert.strictEqual(part?.kind, ResponsePartKind.ToolCall); + const toolCall = part?.kind === ResponsePartKind.ToolCall ? part.toolCall : undefined; + assert.strictEqual(toolCall?.status, ToolCallStatus.Completed); + assert.strictEqual(toolCall?.status === ToolCallStatus.Completed ? toolCall.success : undefined, true); + assert.ok(toolCall?.status === ToolCallStatus.Completed + && toolCall.content?.some(c => c.type === ToolResultContentType.Terminal && c.resource === terminalUri)); + assert.strictEqual(terminalManager.created.length, 1); + assert.ok(terminalManager.sentTexts.some(s => s.data.includes('echo hi'))); + }); + + test('a lone ! is forwarded to the agent instead of running a command', async () => { + setupSession(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createBangSideEffects(terminalManager); + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + await waitForSendMessageCalls(1); + + assert.strictEqual(agent.sendMessageCalls[0].prompt, '!'); + assert.strictEqual(terminalManager.created.length, 0); + }); + + test('records the completed bang turn as a local turn, stripped of the live terminal reference', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const localTurns = new AgentHostLocalTurns(createSessionDataService(db), new NullLogService()); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const bangSideEffects = createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService: createSessionDataService(db), + localTurns, + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + + const action: ChatAction = { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: 1 }); + bangSideEffects.handleAction(defaultChatUri, action); + + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ commandId: '1', command: 'echo hi', exitCode: 0, output: 'hi\n' }); + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + + // The turn with no preceding real turn has no anchor. + assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'turn-1'), undefined); + const persisted = await db.getLocalTurns(); + assert.strictEqual(persisted.length, 1); + const payload = JSON.parse(persisted[0].payload) as { responseParts: { kind: string; toolCall?: { content?: { type: string }[] } }[] }; + const toolCallPart = payload.responseParts.find(p => p.kind === ResponsePartKind.ToolCall); + // Live terminal reference is stripped; text output is retained. + assert.ok(toolCallPart?.toolCall?.content?.every(c => c.type !== ToolResultContentType.Terminal)); + assert.ok(toolCallPart?.toolCall?.content?.some(c => c.type === ToolResultContentType.Text)); + }); + }); + + // ---- local turn persistence: anchoring + truncate resolution --------- + + suite('local turn persistence', () => { + + let clientSeq: number; + + setup(() => { + clientSeq = 0; + }); + + /** Drives a normal (SDK-backed) turn into `turns[]` via the reducer. */ + function seedRealTurn(turnId: string, text: string): void { + stateManager.dispatchClientAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, turnId, message: { text, origin: { kind: MessageKind.User } }, + }, { clientId: 'test', clientSeq: ++clientSeq }); + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnComplete, turnId }); + } + + async function runBang(se: AgentSideEffects, terminalManager: TestAgentHostTerminalManager, turnId: string): Promise<void> { + const action: ChatAction = { + type: ActionType.ChatTurnStarted, turnId, message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(defaultChatUri, action, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, action); + await terminalManager.commandFinishedListenerRegistered.p; + terminalManager.fireCommandFinished({ commandId: turnId, command: 'echo hi', exitCode: 0, output: 'hi\n' }); + await waitForState(stateManager, () => stateManager.getActiveTurnId(sessionUri.toString()) === undefined ? true : undefined); + } + + let localTurns: AgentHostLocalTurns; + + function createLocalTurnSideEffects(db: TestSessionDatabase, terminalManager: TestAgentHostTerminalManager): AgentSideEffects { + const sessionDataService = createSessionDataService(db); + localTurns = new AgentHostLocalTurns(sessionDataService, new NullLogService()); + return createTestSideEffects(disposables, stateManager, { + getAgent: () => agent, + agents: agentList, + sessionDataService, + localTurns, + onTurnComplete: () => { }, + }, undefined, undefined, undefined, terminalManager); + } + + test('anchors a bang turn to the preceding concrete turn', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + assert.strictEqual(localTurns.resolveConcreteTurnId(defaultChatUri, 'local-1'), 'real-1'); + const persisted = await db.getLocalTurns(); + assert.deepStrictEqual(persisted.map(r => ({ turnId: r.turnId, chatUri: r.chatUri, anchorTurnId: r.anchorTurnId })), [ + { turnId: 'local-1', chatUri: defaultChatUri, anchorTurnId: 'real-1' }, + ]); + }); + + test('truncating at a local turn redirects the SDK truncation to the concrete anchor', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + // Truncate at the local turn (keep it). Reducer keeps [real-1, local-1]. + stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' }, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'local-1' }); + + // The SDK is told to keep up to the concrete turn before the local one. + const truncateCall = agent.truncateSessionCalls.at(-1); + assert.strictEqual(truncateCall?.session.toString(), sessionUri.toString()); + assert.strictEqual(truncateCall?.turnId, 'real-1'); + }); + + test('truncating at a real turn drops the trailing local turn', async () => { + setupSession('file:///work'); + const db = new TestSessionDatabase(); + const terminalManager = disposables.add(new TestAgentHostTerminalManager()); + const se = createLocalTurnSideEffects(db, terminalManager); + + seedRealTurn('real-1', 'hello'); + await runBang(se, terminalManager, 'local-1'); + + // Truncate at the real turn (drop the local turn after it). + stateManager.dispatchClientAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' }, { clientId: 'test', clientSeq: ++clientSeq }); + se.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'real-1' }); + + assert.strictEqual(agent.truncateSessionCalls.at(-1)?.turnId, 'real-1'); + // The local turn is dropped from memory synchronously and from the DB async. + assert.strictEqual(localTurns.isLocal(defaultChatUri, 'local-1'), false); + await new Promise(r => setTimeout(r, 10)); + assert.deepStrictEqual(await db.getLocalTurns(), []); + }); + }); + // ---- immediate title on first turn ----------------------------------- suite('immediate title on first turn', () => { @@ -835,6 +1083,34 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.setPendingMessagesCalls.length, 1); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].steeringMessage, { id: 'steer-1', message: { text: 'focus on tests', origin: { kind: MessageKind.User } } }); assert.deepStrictEqual(agent.setPendingMessagesCalls[0].queuedMessages, []); + // Default chat: no `chat` arg, so the agent routes to the session's default chat. + assert.strictEqual(agent.setPendingMessagesCalls[0].chat, undefined); + }); + + test('syncs a peer chat steering message with the peer chat URI as the `chat` arg', () => { + setupSession(); + const peerChatUri = URI.parse(buildChatUri(sessionUri.toString(), 'peer-steer')); + stateManager.addChat(sessionUri.toString(), peerChatUri.toString()); + + const action = { + type: ActionType.ChatPendingMessageSet as const, + kind: PendingMessageKind.Steering, + id: 'steer-peer', + message: { text: 'steer the peer', origin: { kind: MessageKind.User } }, + }; + stateManager.dispatchClientAction(peerChatUri.toString(), action, { clientId: 'test', clientSeq: 1 }); + sideEffects.handleAction(peerChatUri.toString(), action); + + assert.strictEqual(agent.setPendingMessagesCalls.length, 1); + assert.deepStrictEqual({ + session: agent.setPendingMessagesCalls[0].session.toString(), + chat: agent.setPendingMessagesCalls[0].chat?.toString(), + steeringId: agent.setPendingMessagesCalls[0].steeringMessage?.id, + }, { + session: sessionUri.toString(), + chat: peerChatUri.toString(), + steeringId: 'steer-peer', + }); }); test('syncs queued message to agent on ChatPendingMessageSet', async () => { @@ -1110,7 +1386,7 @@ suite('AgentSideEffects', () => { // Idle on the peer chat → the queued message drains to the parent // session URI with the chat channel passed as the `chat` argument - // so the harness routes it to the right peer SDK conversation. + // so the harness routes it to the right peer SDK chat. agent.fireProgress({ kind: 'action', resource: chatUri, action: { type: ActionType.ChatTurnComplete, turnId: 'pturn-1' }, @@ -1901,6 +2177,35 @@ suite('AgentSideEffects', () => { [{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-peer' }], ); }); + + test('forwards parent peer chat URI for a subagent-chat completion', () => { + setupSession(); + const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-subagent-parent'); + stateManager.addChat(sessionUri.toString(), peerChatUri); + startTurn('turn-peer', peerChatUri); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'subagent_started', + chat: URI.parse(peerChatUri), + toolCallId: 'tc-parent', + agentName: 'explore', + agentDisplayName: 'Explore', + }); + + const subagentChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); + sideEffects.handleAction(subagentChatUri, { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-subagent', + toolCallId: 'tc-inner', + result: { success: true, pastTenseMessage: 'done' }, + }); + + assert.deepStrictEqual( + agent.clientToolCallCompleteCalls.map(c => ({ session: c.session.toString(), chat: c.chat?.toString(), toolCallId: c.toolCallId })), + [{ session: sessionUri.toString(), chat: peerChatUri, toolCallId: 'tc-inner' }], + ); + }); }); // ---- Session-level auto-approve (config) ---------------------------- @@ -2558,6 +2863,40 @@ suite('AgentSideEffects', () => { assert.strictEqual(state!.title, 'Restored Title'); }); + test('restore interleaves a persisted local turn after its anchor', async () => { + const sessionDataService = createSessionDataService(sessionDb); + const localAgent = new MockAgent(); + disposables.add(toDisposable(() => localAgent.dispose())); + const localService = disposables.add(new AgentService(new NullLogService(), fileService, sessionDataService, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + localService.registerProvider(localAgent); + + const { session } = await localAgent.createSession(); + const sessions = await localAgent.listSessions(); + const sessionResource = sessions[0].session; + + // The SDK transcript yields a single real turn keyed by the first + // user message id (`buildTurnsFromHistory`). + localAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'real-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'a-1', content: 'Hi', toolRequests: [] }, + ]; + + // A host-injected local turn recorded against that real turn. + const localTurn = { + id: 'local-1', + message: { text: '!echo hi', origin: { kind: MessageKind.User } }, + responseParts: [{ kind: ResponsePartKind.Markdown, id: 'p1', content: 'ran' }], + usage: undefined, + state: 2, // TurnState.Complete + }; + await sessionDb.insertLocalTurn({ turnId: 'local-1', chatUri: buildDefaultChatUri(sessionResource.toString()), anchorTurnId: 'real-1', seq: 1, payload: JSON.stringify(localTurn) }); + + await localService.restoreSession(sessionResource); + + const state = localService.stateManager.getSessionState(sessionResource.toString()); + assert.deepStrictEqual(state?.turns.map(t => t.id), ['real-1', 'local-1']); + }); + test('SessionConfigChanged persists merged config values to the database', async () => { const sessionDataService = createSessionDataService(sessionDb); const localStateManager = disposables.add(new AgentHostStateManager(new NullLogService())); @@ -2657,6 +2996,81 @@ suite('AgentSideEffects', () => { } }); + test('nested subagent_started routes its discovery content block to the immediate parent chat (arbitrary depth)', () => { + // Regression: for a subagent spawned by another subagent, the + // `subagent_started` signal's `chat` is the top-level chat, but + // its spawning tool call lives in the immediate parent's subagent + // chat. The discovery `ChatToolCallContentChanged` must land there + // (resolved via `parentToolCallId`) — dispatching it on the + // top-level chat is a no-op, leaving the nested subagent + // undiscoverable and hanging any client tool it runs. Driven three + // levels deep to prove the resolution is not capped at two: each + // level's block lands on its immediate parent chat via a single + // flat-map lookup, independent of depth. + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + // Level-1 subagent spawned from the default chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l1', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l1', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l1', agentName: 'l1', agentDisplayName: 'L1', agentDescription: 'first' }); + + // Level-2 subagent's spawning tool runs INSIDE the level-1 + // subagent (parentToolCallId = tc-l1), so it lands on the level-1 + // subagent chat rather than the default chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l2', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l1', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l2', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Level-2 subagent starts. Its spawning tool (tc-l2) lives in the + // level-1 subagent chat, so the signal carries parentToolCallId = tc-l1. + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l2', agentName: 'l2', agentDisplayName: 'L2', agentDescription: 'second', parentToolCallId: 'tc-l1' }); + + // Level-3 subagent's spawning tool runs INSIDE the level-2 + // subagent (parentToolCallId = tc-l2), landing on the level-2 chat. + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-l3', toolName: 'task', displayName: 'Task', contributor: undefined, _meta: { toolKind: 'subagent', language: undefined } } }); + agent.fireProgress({ kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-l2', action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-l3', invocationMessage: 'Delegating...', toolInput: undefined, confirmed: ToolCallConfirmationReason.NotNeeded } }); + + // Level-3 subagent starts. Its spawning tool (tc-l3) lives in the + // level-2 subagent chat, so the signal carries parentToolCallId = tc-l2. + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-l3', agentName: 'l3', agentDisplayName: 'L3', agentDescription: 'third', parentToolCallId: 'tc-l2' }); + + const l1ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l1'); + const l2ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l2'); + const l3ChatUri = buildSubagentChatUri(sessionUri.toString(), 'tc-l3'); + + assert.ok(stateManager.getSessionState(l2ChatUri), 'level-2 subagent chat should exist'); + assert.ok(stateManager.getSessionState(l3ChatUri), 'level-3 subagent chat should exist'); + + // Asserts a subagent's discovery block landed on `parentChatUri`'s + // `spawningToolId` tool call, pointing at `childChatUri`. + const assertDiscoveryBlock = (parentChatUri: string, spawningToolId: string, childChatUri: string, label: string) => { + const parentState = stateManager.getSessionState(parentChatUri); + const spawningTool = parentState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && rp.toolCall.toolCallId === spawningToolId); + assert.ok(spawningTool && spawningTool.kind === ResponsePartKind.ToolCall, `${spawningToolId} should live in ${label}`); + const tc = spawningTool.toolCall; + // `content` only exists on the running/completed variants of the + // ToolCallState union; the spawning tool is running here. + assert.strictEqual(tc.status, ToolCallStatus.Running, `${spawningToolId} should be running in ${label}`); + if (tc.status !== ToolCallStatus.Running) { + return; + } + const block = tc.content?.find(c => hasKey(c, { type: true }) && c.type === ToolResultContentType.Subagent); + assert.ok(block, `the discovery block for ${spawningToolId} must land on ${label}`); + assert.strictEqual((block as { resource: string }).resource, childChatUri); + }; + + // Each level's discovery block lands on its immediate parent chat. + assertDiscoveryBlock(l1ChatUri, 'tc-l2', l2ChatUri, 'the level-1 chat'); + assertDiscoveryBlock(l2ChatUri, 'tc-l3', l3ChatUri, 'the level-2 chat'); + + // Nested spawning tools must NOT be misrouted to the top-level + // default chat, where they do not exist. + const defaultState = stateManager.getSessionState(sessionUri.toString()); + const l2ToolInDefault = defaultState?.activeTurn?.responseParts.find(rp => rp.kind === ResponsePartKind.ToolCall && (rp.toolCall.toolCallId === 'tc-l2' || rp.toolCall.toolCallId === 'tc-l3')); + assert.strictEqual(l2ToolInDefault, undefined, 'nested spawning tools must not appear in the top-level chat'); + }); + test('events with parentToolCallId route to subagent session', () => { setupSession(); startTurn('turn-1'); @@ -3063,6 +3477,136 @@ suite('AgentSideEffects', () => { }); }); + // ---- Session inputNeeded production ------------------------------------- + + suite('session inputNeeded production', () => { + + function sessionInputNeeded() { + return stateManager.getSessionState(sessionUri.toString())?.inputNeeded ?? []; + } + + test('chat input request is produced and removed on completion', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputRequested, + request: { id: 'req-1', questions: [] }, + }); + + const produced = sessionInputNeeded(); + assert.deepStrictEqual(produced.map(r => ({ kind: r.kind, chat: r.chat })), [ + { kind: SessionInputRequestKind.ChatInput, chat: defaultChatUri }, + ]); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputCompleted, + requestId: 'req-1', + response: SessionInputResponseKind.Accept, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('tool confirmation is produced while pending and removed once confirmed', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-1', toolName: 'write', displayName: 'Write', + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-1', invocationMessage: 'Write file', confirmationTitle: 'Write file', + }); + + const pending = sessionInputNeeded(); + assert.deepStrictEqual( + pending.map(r => ({ kind: r.kind, chat: r.chat, toolCallId: r.kind === SessionInputRequestKind.ToolConfirmation ? r.toolCall.toolCallId : undefined })), + [{ kind: SessionInputRequestKind.ToolConfirmation, chat: defaultChatUri, toolCallId: 'tc-1' }], + ); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallConfirmed, turnId: 'turn-1', + toolCallId: 'tc-1', approved: true, confirmed: ToolCallConfirmationReason.UserAction, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('client tool execution is produced while running and removed once complete', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: 'tc-client', toolName: 'toolSearch', displayName: 'Search for Tools', + contributor: { kind: ToolCallContributorKind.Client, clientId: 'client-1' }, + }); + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: 'tc-client', invocationMessage: 'Searching', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + const running = sessionInputNeeded(); + assert.deepStrictEqual( + running.map(r => ({ kind: r.kind, chat: r.chat, clientId: r.kind === SessionInputRequestKind.ToolClientExecution ? r.clientId : undefined })), + [{ kind: SessionInputRequestKind.ToolClientExecution, chat: defaultChatUri, clientId: 'client-1' }], + ); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatToolCallComplete, turnId: 'turn-1', + toolCallId: 'tc-client', result: { success: true, pastTenseMessage: 'Searched' }, + }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('ending the turn clears the chat\'s outstanding requests', () => { + setupSession(); + startTurn('turn-1'); + + stateManager.dispatchServerAction(defaultChatUri, { + type: ActionType.ChatInputRequested, + request: { id: 'req-1', questions: [] }, + }); + assert.strictEqual(sessionInputNeeded().length, 1); + + stateManager.dispatchServerAction(defaultChatUri, { type: ActionType.ChatTurnCancelled, turnId: 'turn-1' }); + + assert.deepStrictEqual(sessionInputNeeded(), []); + }); + + test('a blocker inside a subagent is produced against the subagent chat', async () => { + setupSession(); + startTurn('turn-1'); + disposables.add(sideEffects.registerProgressListener(agent)); + + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), + action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-parent', toolName: 'task', displayName: 'Delegate Task' }, + }); + agent.fireProgress({ kind: 'subagent_started', chat: URI.parse(defaultChatUri), toolCallId: 'tc-parent', agentName: 'helper', agentDisplayName: 'Helper' }); + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', + action: { type: ActionType.ChatToolCallStart, turnId: 'turn-1', toolCallId: 'tc-inner', toolName: 'write', displayName: 'Write' }, + }); + agent.fireProgress({ + kind: 'action', resource: URI.parse(defaultChatUri), parentToolCallId: 'tc-parent', + action: { type: ActionType.ChatToolCallReady, turnId: 'turn-1', toolCallId: 'tc-inner', invocationMessage: 'Write file', confirmationTitle: 'Write file' }, + }); + + const subagentUri = buildSubagentChatUri(sessionUri.toString(), 'tc-parent'); + const produced = await waitForState(stateManager, () => { + const entry = sessionInputNeeded().find(r => r.kind === SessionInputRequestKind.ToolConfirmation); + return entry?.kind === SessionInputRequestKind.ToolConfirmation ? entry : undefined; + }); + + assert.deepStrictEqual({ chat: produced.chat, toolCallId: produced.toolCall.toolCallId }, { chat: subagentUri, toolCallId: 'tc-inner' }); + }); + }); + // ---- Session permissions ------------------------------------------------ suite('session permissions', () => { @@ -3381,6 +3925,34 @@ suite('AgentSideEffects', () => { assert.deepStrictEqual(changesets.truncates, [sessionUri.toString()]); }); + + test('truncating a chat forwards that chat to the agent (default and peer)', () => { + setupSession(); + const peerChatUri = buildChatUri(sessionUri.toString(), 'peer-1'); + + // Peer chat: the chat URI is forwarded so the agent targets that + // chat's own backing rather than the session's default chat. + sideEffects.handleAction(peerChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-peer' }); + const peerCall = agent.truncateSessionCalls.at(-1); + + // Default chat: forwarded as the session's default chat URI. + sideEffects.handleAction(defaultChatUri, { type: ActionType.ChatTruncated, turnId: 'turn-default' }); + const defaultCall = agent.truncateSessionCalls.at(-1); + + assert.deepStrictEqual({ + peerSession: peerCall?.session.toString(), + peerTurnId: peerCall?.turnId, + peerChat: peerCall?.chat?.toString(), + defaultTurnId: defaultCall?.turnId, + defaultChat: defaultCall?.chat?.toString(), + }, { + peerSession: sessionUri.toString(), + peerTurnId: 'turn-peer', + peerChat: peerChatUri, + defaultTurnId: 'turn-default', + defaultChat: defaultChatUri, + }); + }); }); }); diff --git a/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts new file mode 100644 index 00000000000000..ac230b7a9c8040 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/buildSessionEvents.test.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { generateUuid, isUUID } from '../../../../base/common/uuid.js'; +import { AgentSession } from '../../common/agentService.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type ToolCallCompletedState, type Turn } from '../../common/state/sessionState.js'; +import { buildSessionEventLogFromTurns, buildSessionEventsFromTurns, serializeSessionEventsToJsonl } from '../../node/copilot/buildSessionEvents.js'; +import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; +import type { SessionEvent } from '@github/copilot-sdk'; + +suite('buildSessionEventsFromTurns — reverse of mapSessionEvents', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const session = AgentSession.uri('copilot', 'test-session'); + const sessionId = 'test-session'; + + function markdown(content: string): ResponsePart { + return { kind: ResponsePartKind.Markdown, id: 'ignored', content }; + } + + function reasoning(content: string): ResponsePart { + return { kind: ResponsePartKind.Reasoning, id: 'ignored', content }; + } + + function toolCallPart(toolCallId: string, toolName: string, toolInput: string, resultText: string, opts?: { success?: boolean; errorMessage?: string }): ResponsePart { + return { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + toolCallId, + toolName, + displayName: toolName, + invocationMessage: '', + toolInput, + success: opts?.success ?? true, + pastTenseMessage: '', + confirmed: ToolCallConfirmationReason.NotNeeded, + content: resultText ? [{ type: ToolResultContentType.Text, text: resultText }] : undefined, + ...(opts?.errorMessage ? { error: { message: opts.errorMessage } } : {}), + } satisfies ToolCallCompletedState, + }; + } + + function userTurn(id: string, text: string, responseParts: ResponsePart[]): Turn { + return { + id, + message: { text, origin: { kind: MessageKind.User } }, + responseParts, + usage: undefined, + state: TurnState.Complete, + }; + } + + function subagentToolCallPart(toolCallId: string, toolName: string, agentName: string, description: string, resultText: string): ResponsePart { + return { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + toolCallId, + toolName, + displayName: agentName, + invocationMessage: '', + toolInput: '', + success: true, + pastTenseMessage: '', + confirmed: ToolCallConfirmationReason.NotNeeded, + content: [ + { type: ToolResultContentType.Text, text: resultText }, + { type: ToolResultContentType.Subagent, resource: `agent-host-subagent:/${toolCallId}`, title: agentName, agentName, description }, + ], + } satisfies ToolCallCompletedState, + }; + } + + /** + * Projection that ignores non-deterministic response-part ids so round-trips + * are comparable. The turn id is preserved (a UUID id round-trips through the + * event log), so it is included. + */ + function project(turns: readonly Turn[]) { + return turns.map(turn => ({ + id: turn.id, + text: turn.message.text, + originKind: turn.message.origin.kind, + state: turn.state, + parts: turn.responseParts.map(part => + part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning + ? { kind: part.kind, content: part.content } + : { kind: part.kind }), + })); + } + + test('round-trips text turns (prompt, markdown, reasoning) preserving UUID turn id, order and state', async () => { + const idA = generateUuid(); + const idB = generateUuid(); + const turns: Turn[] = [ + userTurn(idA, 'What is 2+2?', [markdown('It is 4.')]), + userTurn(idB, 'Explain why.', [reasoning('2 plus 2...'), markdown('Because arithmetic.')]), + ]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + + assert.deepStrictEqual(project(reconstructed), project(turns)); + }); + + test('preserves interleaved markdown/reasoning order by splitting assistant messages', async () => { + const id = generateUuid(); + const turns: Turn[] = [userTurn(id, 'q', [markdown('A'), reasoning('R'), markdown('B')])]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + + // Interleaved reasoning/markdown must not merge into one assistant.message + // (which the reverse mapper would reorder as reasoning-then-content). + assert.deepStrictEqual(events.map(e => e.type), [ + 'session.start', + 'user.message', + 'assistant.message', + 'assistant.message', + 'assistant.message', + ]); + + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + assert.deepStrictEqual(project(reconstructed), project(turns)); + }); + + test('emits an abort for a cancelled turn so it reconstructs as cancelled with its text', async () => { + const id = generateUuid(); + const turns: Turn[] = [{ + id, + message: { text: 'stop', origin: { kind: MessageKind.User } }, + responseParts: [markdown('partial answer')], + usage: undefined, + state: TurnState.Cancelled, + }]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + + // The abort trails the already-flushed assistant content. + assert.deepStrictEqual(events.map(e => e.type), [ + 'session.start', + 'user.message', + 'assistant.message', + 'abort', + ]); + + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + assert.deepStrictEqual(project(reconstructed), project(turns)); + }); + + test('round-trips a completed tool call interleaved with assistant text preserving order and identity', async () => { + const id = generateUuid(); + const toolCallId = generateUuid(); + const turns: Turn[] = [{ + id, + message: { text: 'run it', origin: { kind: MessageKind.User } }, + responseParts: [ + markdown('Let me run the tool.'), + toolCallPart(toolCallId, 'bash', JSON.stringify({ command: 'ls' }), 'file1\nfile2'), + markdown('Done.'), + ], + usage: undefined, + state: TurnState.Complete, + }]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + + // The tool call becomes a start + complete pair, with assistant text + // flushed before and after it as separate assistant.message events. + assert.deepStrictEqual(events.map(e => e.type), [ + 'session.start', + 'user.message', + 'assistant.message', + 'tool.execution_start', + 'tool.execution_complete', + 'assistant.message', + ]); + + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + const projected = reconstructed.map(turn => ({ + id: turn.id, + parts: turn.responseParts.map(part => part.kind === ResponsePartKind.ToolCall + ? { + kind: part.kind, + toolCallId: part.toolCall.toolCallId, + toolName: part.toolCall.toolName, + status: part.toolCall.status, + success: (part.toolCall as ToolCallCompletedState).success, + output: (part.toolCall as ToolCallCompletedState).content?.find(c => c.type === ToolResultContentType.Text)?.text, + } + : { kind: part.kind, content: (part as { content: string }).content }), + })); + + assert.deepStrictEqual(projected, [{ + id, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'Let me run the tool.' }, + { kind: ResponsePartKind.ToolCall, toolCallId, toolName: 'bash', status: ToolCallStatus.Completed, success: true, output: 'file1\nfile2' }, + { kind: ResponsePartKind.Markdown, content: 'Done.' }, + ], + }]); + }); + + test('round-trips a failed tool call preserving the error message', async () => { + const id = generateUuid(); + const toolCallId = generateUuid(); + const turns: Turn[] = [{ + id, + message: { text: 'run it', origin: { kind: MessageKind.User } }, + responseParts: [toolCallPart(toolCallId, 'bash', '{}', '', { success: false, errorMessage: 'boom' })], + usage: undefined, + state: TurnState.Complete, + }]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + const complete = events.find(e => e.type === 'tool.execution_complete'); + assert.ok(complete && complete.type === 'tool.execution_complete'); + assert.strictEqual(complete.data.success, false); + assert.strictEqual(complete.data.error?.message, 'boom'); + + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + const toolPart = reconstructed[0].responseParts.find(p => p.kind === ResponsePartKind.ToolCall); + assert.ok(toolPart && toolPart.kind === ResponsePartKind.ToolCall); + assert.strictEqual((toolPart.toolCall as ToolCallCompletedState).success, false); + assert.strictEqual((toolPart.toolCall as ToolCallCompletedState).error?.message, 'boom'); + }); + + test('emits subagent.started for a sub-agent tool call so the name/description survive the round-trip', async () => { + const id = generateUuid(); + const toolCallId = generateUuid(); + const turns: Turn[] = [userTurn(id, 'delegate', [subagentToolCallPart(toolCallId, 'bash', 'explore', 'Explores the codebase', 'found it')])]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + + // `subagent.started` precedes the tool execution pair so a resume applies + // the sub-agent identity to the parent tool call. + assert.deepStrictEqual(events.map(e => e.type), [ + 'session.start', + 'user.message', + 'subagent.started', + 'tool.execution_start', + 'tool.execution_complete', + ]); + const started = events.find(e => e.type === 'subagent.started'); + assert.ok(started && started.type === 'subagent.started'); + assert.deepStrictEqual( + { toolCallId: started.data.toolCallId, agentName: started.data.agentName, agentDescription: started.data.agentDescription }, + { toolCallId, agentName: 'explore', agentDescription: 'Explores the codebase' }, + ); + + const { turns: reconstructed } = await mapSessionEvents(session, undefined, events); + const toolPart = reconstructed[0].responseParts.find(p => p.kind === ResponsePartKind.ToolCall); + assert.ok(toolPart && toolPart.kind === ResponsePartKind.ToolCall); + const subagentContent = (toolPart.toolCall as ToolCallCompletedState).content?.find(c => c.type === ToolResultContentType.Subagent); + assert.ok(subagentContent && subagentContent.type === ToolResultContentType.Subagent); + assert.deepStrictEqual( + { agentName: subagentContent.agentName, description: subagentContent.description }, + { agentName: 'explore', description: 'Explores the codebase' }, + ); + }); + + test('reuses a UUID turn id as the user.message envelope id, minting UUIDs for non-UUID ids', () => { + const idA = generateUuid(); + const turns: Turn[] = [ + userTurn(idA, 'first', [markdown('r1')]), + userTurn('not-a-uuid', 'second', [markdown('r2')]), + ]; + + const events = buildSessionEventsFromTurns(turns, { sessionId, model: 'gpt-5' }); + + // Shape: session.start, (user.message, assistant.message) x2. + assert.deepStrictEqual(events.map(e => e.type), [ + 'session.start', + 'user.message', + 'assistant.message', + 'user.message', + 'assistant.message', + ]); + + // First event roots the chain; every subsequent event links to its predecessor. + assert.strictEqual(events[0].parentId, null); + for (let i = 1; i < events.length; i++) { + assert.strictEqual(events[i].parentId, events[i - 1].id, `event ${i} must link to its predecessor`); + } + + const userIds = events.filter(e => e.type === 'user.message').map(e => e.id); + // The UUID id is reused verbatim; the non-UUID id is replaced with a minted UUID. + assert.strictEqual(userIds[0], idA); + assert.notStrictEqual(userIds[1], 'not-a-uuid'); + assert.ok(events.every(e => isUUID(e.id)), 'all event ids must be UUIDs'); + + // session.start carries the session id and selected model. + const start = events[0]; + assert.strictEqual(start.type === 'session.start' && start.data.sessionId, sessionId); + assert.strictEqual(start.type === 'session.start' && start.data.selectedModel, 'gpt-5'); + }); + + test('omits the assistant.message for a turn with no response content', async () => { + const turns: Turn[] = [userTurn('turn-empty', 'just a note', [])]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + + assert.deepStrictEqual(events.map(e => e.type), ['session.start', 'user.message']); + }); + + test('serializes to newline-terminated JSONL whose lines parse back to the same events', () => { + const turns: Turn[] = [ + userTurn('turn-a', 'What is 2+2?', [markdown('It is 4.')]), + userTurn('turn-b', 'Explain.', [reasoning('math'), markdown('Because arithmetic.')]), + ]; + + const events = buildSessionEventsFromTurns(turns, { sessionId }); + const jsonl = serializeSessionEventsToJsonl(events); + + // One JSON object per line, terminated by a trailing newline. + assert.ok(jsonl.endsWith('\n'), 'jsonl must be newline-terminated'); + const lines = jsonl.split('\n').filter(line => line.length > 0); + assert.strictEqual(lines.length, events.length); + assert.deepStrictEqual(lines.map(line => JSON.parse(line)), events); + + // Empty input serializes to the empty string. + assert.strictEqual(serializeSessionEventsToJsonl([]), ''); + }); + + test('the on-disk JSONL bytes reconstruct the original turns end to end', async () => { + const turns: Turn[] = [ + userTurn(generateUuid(), 'What is 2+2?', [markdown('It is 4.')]), + userTurn(generateUuid(), 'Explain why.', [reasoning('2 plus 2...'), markdown('Because arithmetic.')]), + ]; + + // Full path a real import takes: turns -> events.jsonl string -> (write to disk) -> + // parse each line -> reconstruct turns. + const jsonl = buildSessionEventLogFromTurns(turns, { sessionId }); + const parsed = jsonl.split('\n').filter(line => line.length > 0).map(line => JSON.parse(line) as SessionEvent); + const { turns: reconstructed } = await mapSessionEvents(session, undefined, parsed); + + assert.deepStrictEqual(project(reconstructed), project(turns)); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts new file mode 100644 index 00000000000000..c3127b222e4c8b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokLmBridgeRegistry.test.ts @@ -0,0 +1,149 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IByokLmBridgeConnection, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; + +/** + * Pins the behaviour of {@link ByokLmBridgeRegistry}: it surfaces the models of a + * single *serving* connection (preferring one that actually has models), routes + * inference there, excludes connections whose enumeration rejects, and notifies + * listeners on model/connection changes. + */ +suite('ByokLmBridgeRegistry', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + /** A scripted bridge connection; `chat` is unused by these tests. */ + function connectionOf(listModels: () => Promise<IByokLmModelInfo[]>, onDidChangeModels?: Emitter<void>): IByokLmBridgeConnection { + return { + chat: async (): Promise<IByokLmChatResult> => ({ content: '' }), + listModels, + ...(onDidChangeModels ? { onDidChangeModels: onDidChangeModels.event } : {}), + }; + } + + /** Resolves once every microtask-queued registry refresh has settled. */ + const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0)); + + test('surfaces the serving window\'s models and routes inference to it; a non-serving window is excluded', async () => { + const registry = new ByokLmBridgeRegistry(); + // A serving window (its listModels resolves) and a window that connected + // without a BYOK handler, whose bridge rejects. + const serving = connectionOf(async () => [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }]); + const nonServing: IByokLmBridgeConnection = { + chat: async (): Promise<IByokLmChatResult> => ({ content: '' }), + listModels: async () => { throw new Error('no BYOK handler in this window'); }, + }; + const regServing = registry.register('editor', serving); + const regNonServing = registry.register('no-handler', nonServing); + + const models = await registry.listModels(); + + assert.deepStrictEqual({ + models, + cached: registry.getModels(), + serving: registry.getServingConnection() === serving, + }, { + models: [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }], + cached: [{ vendor: 'acme', id: 'claude' }, { vendor: 'acme', id: 'gpt' }], + serving: true, + }); + + regServing.dispose(); + regNonServing.dispose(); + }); + + test('a window that answers with an empty list is still a valid serving target', async () => { + const registry = new ByokLmBridgeRegistry(); + const only = connectionOf(async () => []); + const reg = registry.register('client-only', only); + await registry.listModels(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection() === only, + }, { models: [], serving: true }); + + reg.dispose(); + }); + + test('a window that answered empty does not shadow a peer that has models, even when it connected first', async () => { + const registry = new ByokLmBridgeRegistry(); + // The Agents app connects first and answers empty (its BYOK extension has + // not registered models yet); a peer window answers with models. The peer + // must win — an empty-but-serving window must never shadow a populated one. + const empty = connectionOf(async () => []); + const withModels = connectionOf(async () => [{ vendor: 'acme', id: 'claude' }]); + const regEmpty = registry.register('agents', empty); + const regWithModels = registry.register('editor', withModels); + + await registry.listModels(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection() === withModels, + }, { + models: [{ vendor: 'acme', id: 'claude' }], + serving: true, + }); + + regEmpty.dispose(); + regWithModels.dispose(); + }); + + test('unregistering the serving connection drops its models and notifies listeners', async () => { + const registry = new ByokLmBridgeRegistry(); + let changes = 0; + store.add(registry.onDidChangeModels(() => { changes++; })); + + const reg = registry.register('client-a', connectionOf(async () => [{ vendor: 'acme', id: 'claude' }])); + await registry.listModels(); + assert.strictEqual(registry.getModels().length, 1); + + const changesBeforeDispose = changes; + reg.dispose(); + + assert.deepStrictEqual({ + models: registry.getModels(), + serving: registry.getServingConnection(), + firedOnDispose: changes > changesBeforeDispose, + }, { + models: [], + serving: undefined, + firedOnDispose: true, + }); + }); + + test('re-enumerates and notifies when a connection reports onDidChangeModels', async () => { + const registry = new ByokLmBridgeRegistry(); + const onDidChange = store.add(new Emitter<void>()); + let models: IByokLmModelInfo[] = []; + const connection: IByokLmBridgeConnection = { + chat: async (): Promise<IByokLmChatResult> => ({ content: '' }), + listModels: async () => models, + onDidChangeModels: onDidChange.event, + }; + const reg = registry.register('client-a', connection); + await registry.listModels(); + assert.strictEqual(registry.getModels().length, 0); + + let changed = false; + store.add(registry.onDidChangeModels(() => { changed = true; })); + models = [{ vendor: 'acme', id: 'claude' }]; + onDidChange.fire(); + await flush(); + + assert.deepStrictEqual({ changed, models: registry.getModels() }, { + changed: true, + models: [{ vendor: 'acme', id: 'claude' }], + }); + + reg.dispose(); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts new file mode 100644 index 00000000000000..c7eeb608f95f56 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokLmProxyService.test.ts @@ -0,0 +1,294 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { ByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; + +/** + * Exercises the inference path end-to-end without the Copilot SDK runtime: + * the test plays the runtime's role by POSTing OpenAI Chat Completions + * requests at the loopback proxy, and plays the renderer's role with a fake + * {@link IByokLmChatRequest} -> {@link IByokLmChatResult} bridge function. The + * only contract under test is the OpenAI wire format in, the bridge DTO out, + * and the SSE wire format back. + */ +suite('ByokLmProxyService', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + + async function withProxy( + chat: (request: IByokLmChatRequest) => Promise<IByokLmChatResult>, + run: (handle: IByokLmProxyHandle) => Promise<void>, + ): Promise<void> { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat, listModels: async () => [] }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + await run(handle); + } finally { + handle.dispose(); + registration.dispose(); + service.dispose(); + } + } + + function chatUrl(handle: IByokLmProxyHandle, vendor: string): string { + return `${handle.providerBaseUrl(vendor)}/chat/completions`; + } + + function authHeaders(handle: IByokLmProxyHandle): Record<string, string> { + return { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}.${sessionId}` }; + } + + test('serves the unauthenticated health check', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/`); + assert.strictEqual(response.status, 200); + assert.strictEqual(await response.text(), 'ok'); + }, + ); + }); + + test('rejects requests without a valid bearer token', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('rejects a nonce-only bearer token (no session id)', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${handle.nonce}` }, + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 401); + }, + ); + }); + + test('returns 404 for an authenticated but unknown route', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(`${handle.baseUrl}/v/acme/responses`, { + method: 'POST', + headers: authHeaders(handle), + body: '{}', + }); + assert.strictEqual(response.status, 404); + }, + ); + }); + + test('forwards a chat request to the bridge and streams an SSE completion', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { + captured = request; + return { content: 'hello from byok' }; + }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'claude', messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + assert.strictEqual(response.headers.get('content-type'), 'text/event-stream'); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + assert.ok(text.trimEnd().endsWith('data: [DONE]')); + }, + ); + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + assert.deepStrictEqual(captured?.messages, [{ role: 'user', content: 'hi', toolCalls: undefined, toolCallId: undefined }]); + }); + + test('decodes a url-encoded vendor path segment', async () => { + let captured: IByokLmChatRequest | undefined; + await withProxy( + async (request) => { captured = request; return { content: 'ok' }; }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme corp'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 200); + await response.text(); + }, + ); + assert.strictEqual(captured?.vendor, 'acme corp'); + }); + + test('rejects a vendor that decodes to a multi-segment path (%2F)', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + // `encodeURIComponent('a/b')` → `a%2Fb`, which survives the + // pre-decode segment check but decodes back into `a/b`. + const response = await fetch(chatUrl(handle, 'a/b'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 404); + }, + ); + }); + + test('streams assistant tool calls as OpenAI tool_call deltas', async () => { + await withProxy( + async () => ({ content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [{ role: 'user', content: 'weather?' }] }), + }); + const text = await response.text(); + assert.ok(text.includes('"tool_calls"'), `expected tool_calls in SSE: ${text}`); + assert.ok(text.includes('"finish_reason":"tool_calls"'), `expected tool_calls finish reason: ${text}`); + assert.ok(text.includes('getWeather')); + }, + ); + }); + + test('returns a 502 when the bridge reports an error', async () => { + await withProxy( + async () => ({ content: '', error: 'model unavailable' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'model unavailable'); + }, + ); + }); + + test('returns a 502 when the bridge throws', async () => { + await withProxy( + async () => { throw new Error('bridge exploded'); }, + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 502); + const body = await response.json() as { error?: { message?: string } }; + assert.strictEqual(body.error?.message, 'bridge exploded'); + }, + ); + }); + + test('rejects a malformed JSON body with 400', async () => { + await withProxy( + async () => ({ content: 'unused' }), + async (handle) => { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: 'not json', + }); + assert.strictEqual(response.status, 400); + }, + ); + }); + + test('returns a 503 when no renderer bridge is connected', async () => { + const registry = new ByokLmBridgeRegistry(); + const service = new ByokLmProxyService(new NullLogService(), registry); + const handle = await service.start(); + try { + const response = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', + headers: authHeaders(handle), + body: JSON.stringify({ model: 'm', messages: [] }), + }); + assert.strictEqual(response.status, 503); + } finally { + handle.dispose(); + service.dispose(); + } + }); + + test('routes requests to a serving window and excludes a non-serving one', async () => { + const registry = new ByokLmBridgeRegistry(); + const calls: string[] = []; + // The serving window (editor): enumerates models and answers chat. + const regServing = registry.register('editor', { + chat: async () => { calls.push('serving'); return { content: 'from serving' }; }, + listModels: async () => [{ vendor: 'acme', id: 'claude' }], + }); + // A non-serving window (connected without a BYOK handler): its bridge + // rejects, so it must never be picked for routing even though it is connected. + const regNonServing = registry.register('no-handler', { + chat: async () => { calls.push('no-handler'); return { content: 'from non-serving' }; }, + listModels: async () => { throw new Error('no BYOK handler'); }, + }); + const service = new ByokLmProxyService(new NullLogService(), registry); + // Warm the cache so the serving window is known before routing. + await registry.listModels(); + const handle = await service.start(); + try { + const res = await fetch(chatUrl(handle, 'acme'), { + method: 'POST', headers: authHeaders(handle), + body: JSON.stringify({ model: 'claude', messages: [] }), + }); + assert.deepStrictEqual({ + routedToServing: (await res.text()).includes('from serving'), + calls, + }, { routedToServing: true, calls: ['serving'] }); + } finally { + handle.dispose(); + regServing.dispose(); + regNonServing.dispose(); + service.dispose(); + } + }); + + test('rebinds with a fresh nonce after every handle is disposed', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', { chat: async () => ({ content: 'ok' }), listModels: async () => [] }); + const service = new ByokLmProxyService(new NullLogService(), registry); + const first = await service.start(); + const firstNonce = first.nonce; + first.dispose(); + const second = await service.start(); + try { + assert.notStrictEqual(second.nonce, firstNonce); + } finally { + second.dispose(); + registration.dispose(); + service.dispose(); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts new file mode 100644 index 00000000000000..299a27183bd419 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/byokOpenAiTranslation.test.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import type { IByokLmChatResult } from '../../common/agentHostByokLm.js'; +import { + bridgeResultToSseFrames, + openAiRequestToBridge, + OpenAiTranslationError, + type IOpenAiChatRequest, +} from '../../node/copilot/byokOpenAiTranslation.js'; + +suite('byokOpenAiTranslation', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('openAiRequestToBridge', () => { + + test('maps roles, text content, tools and options', () => { + const body: IOpenAiChatRequest = { + model: 'claude-sonnet', + temperature: 0.5, + max_tokens: 256, + messages: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: [{ type: 'text', text: 'hi ' }, { type: 'text', text: 'there' }] }, + { + role: 'assistant', + content: '', + tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }], + }, + { role: 'tool', tool_call_id: 'call_1', content: 'sunny' }, + ], + tools: [{ type: 'function', function: { name: 'getWeather', description: 'weather', parameters: { type: 'object' } } }], + }; + + const result = openAiRequestToBridge('acme', body); + + assert.deepStrictEqual(result, { + vendor: 'acme', + modelId: 'claude-sonnet', + messages: [ + { role: 'system', content: 'be helpful', toolCalls: undefined, toolCallId: undefined }, + { role: 'user', content: 'hi there', toolCalls: undefined, toolCallId: undefined }, + { role: 'assistant', content: '', toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], toolCallId: undefined }, + { role: 'tool', content: 'sunny', toolCalls: undefined, toolCallId: 'call_1' }, + ], + tools: [{ name: 'getWeather', description: 'weather', parametersSchema: { type: 'object' } }], + modelOptions: { temperature: 0.5, max_tokens: 256 }, + }); + }); + + test('throws when model is missing', () => { + assert.throws(() => openAiRequestToBridge('acme', { messages: [] }), OpenAiTranslationError); + }); + + test('throws when an assistant tool call is missing its function name', () => { + assert.throws(() => openAiRequestToBridge('acme', { + model: 'm', + messages: [{ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', type: 'function', function: { arguments: '{}' } }] }], + }), OpenAiTranslationError); + }); + + test('omits tools and options when absent', () => { + const result = openAiRequestToBridge('acme', { model: 'm', messages: [{ role: 'user', content: 'hello' }] }); + assert.strictEqual(result.tools, undefined); + assert.strictEqual(result.modelOptions, undefined); + }); + }); + + suite('bridgeResultToSseFrames', () => { + + function parseFrames(frames: string[]): unknown[] { + return frames + .map(frame => frame.replace(/^data: /, '').trim()) + .filter(payload => payload !== '[DONE]') + .map(payload => JSON.parse(payload)); + } + + test('emits role, content and stop frames terminated by [DONE]', () => { + const result: IByokLmChatResult = { content: 'hello world' }; + const frames = bridgeResultToSseFrames(result, 'm'); + + assert.strictEqual(frames[frames.length - 1], 'data: [DONE]\n\n'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record<string, unknown>; finish_reason: string | null }> }>; + assert.deepStrictEqual(parsed.map(p => p.choices[0].delta), [ + { role: 'assistant' }, + { content: 'hello world' }, + {}, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'stop'); + }); + + test('encodes tool calls and a tool_calls finish reason', () => { + const result: IByokLmChatResult = { + content: '', + toolCalls: [{ id: 'call_1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + }; + const frames = bridgeResultToSseFrames(result, 'm'); + const parsed = parseFrames(frames) as Array<{ choices: Array<{ delta: Record<string, unknown>; finish_reason: string | null }> }>; + + const toolDelta = parsed.find(p => p.choices[0].delta.tool_calls !== undefined); + assert.deepStrictEqual(toolDelta?.choices[0].delta.tool_calls, [ + { index: 0, id: 'call_1', type: 'function', function: { name: 'getWeather', arguments: '{"city":"NYC"}' } }, + ]); + assert.strictEqual(parsed[parsed.length - 1].choices[0].finish_reason, 'tool_calls'); + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index ffc6312df9484d..a951c1e9a30560 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -51,9 +51,11 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { type AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { buildDefaultChatUri, ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolResultContentType, type ClientPluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; @@ -239,6 +241,9 @@ class StubCopilotApiService implements ICopilotApiService { readonly messagesCallCount = { count: 0 }; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + messages( token: string, request: Anthropic.MessageCreateParamsStreaming, @@ -356,6 +361,10 @@ class ProxyRoundTripSdkService implements IClaudeAgentSdkService { return []; } + async canLoadWithoutDownload(): Promise<boolean> { + return true; + } + async getSessionInfo(_sessionId: string): Promise<SDKSessionInfo | undefined> { return undefined; } @@ -488,6 +497,7 @@ class RoundTripQuery implements AsyncGenerator<SDKMessage, void> { setMaxThinkingTokens(): never { throw new Error('not modeled'); } applyFlagSettings(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } @@ -625,6 +635,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, }], [IAgentConfigurationService, configService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], [IAgentHostGitService, createNoopGitService()], ...claudeFileEnvServices(disposables), ); @@ -645,7 +656,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { // First send materializes — drives `startup()`, which performs // the real HTTP round-trip on the real proxy. - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); // Snapshot what flowed through the integration in a single // assertion so the failure surface is the whole pipeline. @@ -755,6 +766,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, }], [IAgentConfigurationService, configService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], [IAgentHostGitService, createNoopGitService()], ...claudeFileEnvServices(disposables), ); @@ -766,7 +778,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { const sessionId = created.session.path.replace(/^\//, ''); sdk.queryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); const startup = sdk.capturedStartupOptions[0]; assert.ok(typeof startup.canUseTool === 'function', 'canUseTool was wired into Options'); @@ -814,6 +826,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { async syncCustomizations(_clientId: string, _customizations: ClientPluginCustomization[]) { return []; }, }], [IAgentConfigurationService, configService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], [IAgentHostGitService, createNoopGitService()], ...claudeFileEnvServices(disposables), ); @@ -856,7 +869,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { } })); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'please read /tmp/x', undefined, 'turn-1'); + await agent.chats.sendMessage(created.session, 'please read /tmp/x', undefined, 'turn-1'); // Snapshot the agent-side emission stream as a single shape so // the failure surface is the whole pipeline. diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 6bd3a8835c28f4..f1cce3421d9cb9 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -9,10 +9,13 @@ import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; +import * as fs from 'fs/promises'; +import * as os from 'os'; import { makeAssistantMessage, makeContentBlockStartText, makeContentBlockStartThinking, + makeContentBlockStartToolUse, makeContentBlockStop, makeMessageStart, makeMessageStop, @@ -21,6 +24,7 @@ import { makeSystemInitMessage, makeTextDelta, makeThinkingDelta, + makeUserToolResultMessage, } from './claudeMapSessionEventsTestUtils.js'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; @@ -40,16 +44,18 @@ import { IFileService } from '../../../files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { Schemas } from '../../../../base/common/network.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; -import { IAgentMaterializeSessionEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; +import { IAgentChatDataChange, IAgentMaterializeSessionEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agentService.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; -import { ActionType } from '../../common/state/sessionActions.js'; -import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildDefaultChatUri, buildSubagentSessionUri, customizationId, parseDefaultChatUri, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; +import { ActionType, type AuthRequiredParams } from '../../common/state/sessionActions.js'; +import { CustomizationLoadStatus, CustomizationType, MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputResponseKind, SessionStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, parseChatUri, parseDefaultChatUri, type ClientPluginCustomization, type PluginCustomization } from '../../common/state/sessionState.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../common/state/sessionProtocol.js'; import { ProtectedResourceMetadata, ChatInputAnswerState, ChatInputAnswerValueKind, ToolCallStatus, type SessionConfigState, type ChatInputRequest, type ToolDefinition } from '../../common/state/protocol/state.js'; import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { ClaudeAgent, fromSdkModelInfo } from '../../node/claude/claudeAgent.js'; import { ClaudeAgentSession } from '../../node/claude/claudeAgentSession.js'; @@ -69,6 +75,25 @@ interface IStartCall { readonly token: string; } +/** + * Enumerate the agent's live peer-chat backings for a session as channel URI + * strings. Replaces the removed `IAgent.getChats` for tests that assert peer + * chat lifecycle at the agent level (the orchestrator now owns the durable + * catalog). + */ +function listPeerChats(agent: ClaudeAgent, session: URI): string[] { + const backings = (agent as unknown as { _chatBackings: Map<string, unknown> })._chatBackings; + const sessionId = AgentSession.id(session); + return [...backings.keys()].filter(key => { + const parsed = parseChatUri(URI.parse(key)); + return !!parsed && AgentSession.id(URI.parse(parsed.session)) === sessionId; + }); +} + +function defaultChatUri(session: URI): URI { + return URI.parse(buildDefaultChatUri(session)); +} + class FakeAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; readonly basePath = URI.from({ scheme: 'inmemory', path: '/agentPlugins' }); @@ -126,6 +151,8 @@ class FakeCopilotApiService implements ICopilotApiService { countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used in ClaudeAgent tests'); } responses(): Promise<Response> { throw new Error('not used in ClaudeAgent tests'); } utilityChatCompletion(): Promise<never> { throw new Error('not used in ClaudeAgent tests'); } + resolveRestrictedTelemetryContext() { return Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }); } + resolveApiEndpoint() { return Promise.resolve(undefined); } } const FakeProductService: IProductService = { @@ -236,6 +263,19 @@ class FakeClaudeAgentSdkService implements IClaudeAgentSdkService { return this.sessionList; } + async canLoadWithoutDownload(): Promise<boolean> { + return this.canLoadWithoutDownloadResult; + } + + /** + * Programmable result for {@link canLoadWithoutDownload}. Defaults to + * `true` (SDK already local). Set to `false` to simulate the cold-start + * case where the SDK isn't downloaded yet — restore-reachable reads + * ({@link listSessions}, {@link getSessionInfo} via `getSessionMetadata`, + * {@link getSessionMessages}) MUST defer rather than trigger a download. + */ + canLoadWithoutDownloadResult = true; + /** * Fake for {@link IClaudeAgentSdkService.getSessionInfo}. Tests stage * `sessionList` and the fake searches it by id; setting @@ -540,6 +580,7 @@ class FakeQuery implements AsyncGenerator<SDKMessage, void> { setMaxThinkingTokens(): never { throw new Error('FakeQuery: setMaxThinkingTokens not modeled'); } async applyFlagSettings(s: Settings): Promise<void> { this.recordedFlagSettings.push(s); } initializationResult(): never { throw new Error('FakeQuery: initializationResult not modeled'); } + reinitialize(): never { throw new Error('FakeQuery: reinitialize not modeled'); } supportedCommands(): never { return Promise.resolve(this._sdk.supportedCommandsResult) as never; @@ -718,7 +759,7 @@ class CapturingLogService extends NullLogService { function createTestContext( disposables: Pick<DisposableStore, 'add'>, - overrides?: { logService?: ILogService; database?: TestSessionDatabase; rootConfig?: Record<string, unknown> }, + overrides?: { logService?: ILogService; database?: TestSessionDatabase; rootConfig?: Record<string, unknown>; userHome?: URI; gitHubEndpointService?: IAgentHostGitHubEndpointService }, ): ITestContext { const proxy = new FakeClaudeProxyService(); const api = new FakeCopilotApiService(); @@ -740,7 +781,7 @@ function createTestContext( const services = new ServiceCollection( [IFileService, fileService], - [INativeEnvironmentService, { userHome: URI.file('/mock-home') } as INativeEnvironmentService], + [INativeEnvironmentService, { userHome: overrides?.userHome ?? URI.file('/mock-home') } as INativeEnvironmentService], [ILogService, logService], [ICopilotApiService, api], [IClaudeProxyService, proxy], @@ -750,6 +791,7 @@ function createTestContext( [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, overrides?.gitHubEndpointService ?? createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); // Phase 19: seed root config (e.g. `claudeUseCopilotProxy`) BEFORE the agent @@ -788,7 +830,9 @@ function forkSourceMessages(sourceId: string): SessionMessage[] { function stubAgentSdkDownloader(): IAgentSdkDownloader { return { _serviceBrand: undefined, + onDidDownloadProgress: Event.None, isAvailable: () => false, + isSdkResolvableWithoutDownload: async () => false, loadSdkRoot: () => { throw new Error('test stub: downloader.loadSdkRoot should not be called'); }, }; } @@ -852,6 +896,25 @@ suite('ClaudeAgent', () => { }]); }); + test('enterprise: getProtectedResources + authenticate use the computed enterprise resource', async () => { + const { agent } = createTestContext(disposables, { + gitHubEndpointService: createTestGitHubEndpointService('https://ghe.acme.com'), + }); + + assert.deepStrictEqual({ + resources: agent.getProtectedResources().map(r => ({ resource: r.resource, servers: r.authorization_servers })), + acceptsEnterpriseCopilot: await agent.authenticate('https://ghe.acme.com/api/v3', 'tok'), + rejectsDotCom: await agent.authenticate('https://api.github.com', 'tok'), + }, { + resources: [ + { resource: 'https://ghe.acme.com/api/v3', servers: ['https://ghe.acme.com/login/oauth'] }, + { resource: 'https://ghe.acme.com/api/v3/repos', servers: ['https://ghe.acme.com/login/oauth'] }, + ], + acceptsEnterpriseCopilot: true, + rejectsDotCom: false, + }); + }); + test('models observable is empty before authenticate', () => { const { agent } = createTestContext(disposables); assert.deepStrictEqual(agent.models.get(), []); @@ -941,6 +1004,69 @@ suite('ClaudeAgent', () => { assert.deepStrictEqual({ accepted, proxyStarts: proxy.startCalls.length }, { accepted: true, proxyStarts: 0 }); }); + test('transport flip native→proxy with no proxy handle emits auth/required once', () => { + const { agent, configService } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + const events: Omit<AuthRequiredParams, 'channel'>[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + configService.updateRootConfig({ claudeUseCopilotProxy: true }); + + assert.deepStrictEqual(events, [{ resource: 'https://api.github.com', reason: 'required' }]); + }); + + test('transport flip does not emit auth/required when a proxy handle already exists', async () => { + const { agent, proxy, configService } = createTestContext(disposables); + await agent.authenticate('https://api.github.com', 'tok'); + await tick(); + assert.strictEqual(proxy.startCalls.length, 1); + + const events: Omit<AuthRequiredParams, 'channel'>[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + configService.updateRootConfig({ claudeUseCopilotProxy: false }); // → native + configService.updateRootConfig({ claudeUseCopilotProxy: true }); // → proxy; handle persists + + assert.deepStrictEqual(events, []); + }); + + test('transport flip proxy→native does not emit auth/required', () => { + const { agent, configService } = createTestContext(disposables); + const events: Omit<AuthRequiredParams, 'channel'>[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + configService.updateRootConfig({ claudeUseCopilotProxy: false }); + + assert.deepStrictEqual(events, []); + }); + + test('construction in proxy mode does not emit auth/required', async () => { + const { agent } = createTestContext(disposables); + const events: Omit<AuthRequiredParams, 'channel'>[] = []; + disposables.add(agent.onDidRequireAuth(e => events.push(e))); + + await tick(); + + assert.deepStrictEqual(events, []); + }); + + test('proxy-mode authenticate with an unchanged token starts the proxy when no handle exists', async () => { + // Native mode records the Copilot token without starting the proxy. After + // a flip to proxy the agent has a token but no handle; re-authenticating + // with the SAME token must still start the proxy rather than short- + // circuiting on the "token unchanged" path. + const { agent, proxy, configService } = createTestContext(disposables, { rootConfig: { claudeUseCopilotProxy: false } }); + await agent.authenticate('https://api.github.com', 'T'); + assert.strictEqual(proxy.startCalls.length, 0); + + configService.updateRootConfig({ claudeUseCopilotProxy: true }); + await agent.authenticate('https://api.github.com', 'T'); + await tick(); + + assert.deepStrictEqual({ + startTokens: proxy.startCalls.map(c => c.token), + disposeCount: proxy.disposeCount, + }, { startTokens: ['T'], disposeCount: 0 }); + }); + test('createSession before authenticate throws ProtocolError(AHP_AUTH_REQUIRED) with protected resources', async () => { const { agent } = createTestContext(disposables); @@ -1215,6 +1341,7 @@ suite('ClaudeAgent', () => { [IAgentPluginManager, new FakeAgentPluginManager()], [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -1278,6 +1405,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -1293,15 +1421,16 @@ suite('ClaudeAgent', () => { test('phase-stub graduation: abortSession + changeModel no longer throw', async () => { // Phase 9 graduation: both methods land in this phase. They are - // idempotent on unknown session URIs (no-op rather than throw) + // idempotent on unknown default chat URIs (no-op rather than throw) // because the workbench may race a session dispose with these // calls; matching CopilotAgent's permissive surface keeps the // AgentSideEffects.handleAction `.catch()` path quiet on common // paths. Behavior on known sessions is exercised by the dedicated // Phase 9 suites below. const { agent } = createTestContext(disposables); - await agent.abortSession(URI.parse('claude:/unknown')); - await agent.changeModel(URI.parse('claude:/unknown'), { id: 'claude-opus-4.6' }); + const chat = defaultChatUri(URI.parse('claude:/unknown')); + await agent.chats.abort(chat); + await agent.chats.changeModel(chat, { id: 'claude-opus-4.6' }); }); test('AgentService surfaces the registered ClaudeAgent in the providers map', () => { @@ -1347,6 +1476,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -1403,12 +1533,38 @@ suite('ClaudeAgent', () => { }); }); + test('createSession without a workingDirectory materializes in a shared scratch dir (workspace-less quick chat)', async () => { + // Regression: a workspace-less quick chat gave Claude no cwd, so it + // threw "workingDirectory is required" at materialize. The scratch-dir + // fallback is now shared with the Copilot agent. + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/claude-qc-home-`)); + const { agent, sdk } = createTestContext(disposables, { userHome }); + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({}); + const sessionId = AgentSession.id(created.session); + const expected = URI.joinPath(userHome, '.copilot', 'chats', sessionId); + assert.strictEqual(created.workingDirectory?.fsPath, expected.fsPath); + await fs.access(expected.fsPath); + + // Drive materialize via the first send; before the fix this rejected + // with "workingDirectory is required". + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + await agent.chats.sendMessage(created.session, 'hi', undefined, 'turn-1'); + assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.cwd, expected.fsPath); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + } + }); + test('createProvisional creates a session without SDK startup contact', async () => { const { sdk, instantiationService } = createTestContext(disposables); const session = disposables.add(ClaudeAgentSession.createProvisional( 'test-session', AgentSession.uri('claude', 'test-session'), + URI.parse(buildDefaultChatUri(AgentSession.uri('claude', 'test-session'))), URI.file('/workspace'), undefined, undefined, @@ -1436,6 +1592,7 @@ suite('ClaudeAgent', () => { const session = disposables.add(ClaudeAgentSession.createProvisional( 'test-session', AgentSession.uri('claude', 'test-session'), + URI.parse(buildDefaultChatUri(AgentSession.uri('claude', 'test-session'))), URI.file('/workspace'), undefined, undefined, @@ -1473,19 +1630,19 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work-resume'), model: initialModel }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); // Phase 2: user changes the model post-materialize — this hits the // runtime path inside session.setModel and rewrites the overlay. const updatedModel = { id: 'claude-opus-4.6', config: { thinkingLevel: 'medium' } }; - await agent.changeModel(created.session, updatedModel); + await agent.chats.changeModel(defaultChatUri(created.session), updatedModel); // Phase 3: simulate cross-window resume by tearing the in-memory // entry down and forcing the resume branch on the next send. await agent.disposeSession(created.session); sdk.sessionList = [{ sessionId, cwd: '/work-resume', summary: '', lastModified: Date.now() }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn 2', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'turn 2', undefined, 'turn-2'); // Phase 4: confirm the resume started the SDK with the updated model // from Phase 2. Model selection is no longer surfaced on @@ -1512,7 +1669,7 @@ suite('ClaudeAgent', () => { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); const expected = AgentSession.uri('claude', 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); - const result = await agent.createSession({ session: expected }); + const result = await agent.createSession({ session: expected, workingDirectory: URI.file('/work') }); assert.deepStrictEqual({ session: result.session.toString(), @@ -1555,7 +1712,7 @@ suite('ClaudeAgent', () => { // First send resumes the forked file: the Query starts with `resume`. sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; - await agent.sendMessage(newUri, URI.parse(buildDefaultChatUri(newUri)), 'next', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(newUri), 'next', undefined, 'turn-1'); assert.deepStrictEqual({ atForkTime, @@ -1610,9 +1767,9 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); await agent.truncateSession(created.session, 'u1'); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, @@ -1638,7 +1795,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); // Unload the session from memory; the transcript stays resumable. await agent.disposeSession(created.session); @@ -1647,7 +1804,7 @@ suite('ClaudeAgent', () => { await agent.truncateSession(created.session, 'u1'); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ @@ -1668,7 +1825,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.sessionMessagesById.set(sessionId, forkSourceMessages(sessionId)); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); await assert.rejects(() => agent.truncateSession(created.session, 'no-such-turn'), /turn no-such-turn not found/); }); @@ -1699,12 +1856,12 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); await agent.truncateSession(created.session); // The next turn materializes FRESH (non-resume) on the SAME id. - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ deleted: sdk.deleteSessionCalls, @@ -1732,7 +1889,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); // Block the live query's teardown (models `transport.waitForExit()` — // the subprocess not yet exited / still flushing the transcript). @@ -1758,7 +1915,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); // Unload the session from memory; the transcript stays on disk. The // remove-all path then has no live `existing` and must read the cwd @@ -1774,7 +1931,7 @@ suite('ClaudeAgent', () => { await agent.truncateSession(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); const last = sdk.capturedStartupOptions.at(-1); assert.deepStrictEqual({ deleted: sdk.deleteSessionCalls, @@ -1817,7 +1974,7 @@ suite('ClaudeAgent', () => { // Fork defers the Query; materialize it via the first send. The resume // path reads the inherited overlay into `Options.permissionMode`. sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.permissionMode, 'plan'); }); @@ -1841,7 +1998,7 @@ suite('ClaudeAgent', () => { // started with on its first send. const forkedId = AgentSession.id(result.session); sdk.nextQueryMessages = [makeSystemInitMessage(forkedId), makeResultSuccess(forkedId)]; - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.model, 'claude-opus-4-6'); }); @@ -1937,7 +2094,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -1971,7 +2128,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'my-real-agent'); }); @@ -1988,7 +2145,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'Explore'); }); @@ -2013,7 +2170,7 @@ suite('ClaudeAgent', () => { assert.ok(agent.onDidMaterializeSession); disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.strictEqual(events.length, 1, 'event fires exactly once'); const ev = events[0]; @@ -2051,7 +2208,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -2086,7 +2243,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ model: sdk.capturedStartupOptions[0]?.model, @@ -2132,7 +2289,7 @@ suite('ClaudeAgent', () => { ]; // First turn — materializes; resolves on result(idx=1). - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn-1', undefined, 'turn-id-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'turn-1', undefined, 'turn-id-1'); // Snapshot before the second send so we can assert the second send // did NOT call startup() again. @@ -2140,7 +2297,7 @@ suite('ClaudeAgent', () => { const queryCallsAfterTurn1 = sdk.warmQueries[0]?.queryCallCount ?? -1; // Second turn — pushes onto the existing Query. - const p2 = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'turn-2', undefined, 'turn-id-2'); + const p2 = agent.chats.sendMessage(defaultChatUri(created.session), 'turn-2', undefined, 'turn-id-2'); // Drain microtasks so `await entry.setPermissionMode(...)` resolves // and `entry.send(...)` synchronously pushes the second prompt onto // the in-flight queue BEFORE we release the iterator gate. Otherwise @@ -2199,7 +2356,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -2268,7 +2425,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const actionSignals = signals.filter(s => s.kind === 'action'); const partActions = actionSignals @@ -2347,7 +2504,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const tail = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2410,7 +2567,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const usage = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2449,7 +2606,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2512,7 +2669,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const responsePartCount = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2527,6 +2684,79 @@ suite('ClaudeAgent', () => { }); }); + test('D3: subagent spawn mirrors onto onDidSpawnChat while the subagent signals still flow', async () => { + // The membership channel (onDidSpawnChat) is derived from the + // subagent_started signal the agent already emits on + // onDidSessionProgress — the orchestrator records the spawn edge on the + // unified chat catalog. A completed subagent chat stays live and + // subscribable (removed only on session teardown). The + // signals must STILL be forwarded verbatim so the existing + // AgentSideEffects subagent handling (turn lifecycle + parent tool-call + // content) is preserved. + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + const PARENT = 'toolu_parent_sa'; + + const innerAssistant = makeAssistantMessage(sessionId, [ + { type: 'text', text: 'searching the workspace', citations: null }, + ]); + innerAssistant.parent_tool_use_id = PARENT; + + sdk.nextQueryMessages = [ + makeSystemInitMessage(sessionId), + // Stream the spawning Task tool_use so the registry records the spawn. + makeStreamEvent(sessionId, makeMessageStart()), + makeStreamEvent(sessionId, makeContentBlockStartToolUse(0, PARENT, 'Task')), + makeStreamEvent(sessionId, makeContentBlockStop(0)), + makeStreamEvent(sessionId, makeMessageStop()), + // Canonical Task assistant records subagent metadata (subagent_type). + makeAssistantMessage(sessionId, [ + { type: 'tool_use', id: PARENT, name: 'Task', input: { description: 'Count files', subagent_type: 'Explore', prompt: 'go' } }, + ]), + // Inner subagent content (parent_tool_use_id set) prepends subagent_started. + innerAssistant, + // Parent tool result completes the foreground subagent. + makeUserToolResultMessage(sessionId, PARENT, 'done'), + makeResultSuccess(sessionId), + ]; + + const signals: AgentSignal[] = []; + disposables.add(agent.onDidSessionProgress(s => signals.push(s))); + const spawned: IAgentSpawnChatEvent[] = []; + disposables.add(agent.onDidSpawnChat!(e => spawned.push(e))); + + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); + + const subagentChatUri = buildSubagentChatUri(created.session, PARENT); + + assert.deepStrictEqual({ + startedSignals: signals.filter(s => s.kind === 'subagent_started').length, + completedSignals: signals.filter(s => s.kind === 'subagent_completed').length, + spawned: spawned.map(e => ({ + session: e.session.toString(), + chat: e.chat.toString(), + parentChat: e.parent?.chat.toString(), + parentToolCallId: e.parent?.toolCallId, + title: e.title, + })), + }, { + startedSignals: 1, + completedSignals: 1, + spawned: [{ + session: created.session.toString(), + chat: subagentChatUri, + parentChat: buildDefaultChatUri(created.session), + parentToolCallId: PARENT, + // Titled by the Task tool's concise `description` input, not the + // agent-type name (`subagent_type: 'Explore'`). + title: 'Count files', + }], + }); + }); + test('canonical SDKAssistantMessage with text content does not double-emit signals already produced by stream_event partials (Phase 6.1 / Cycle F)', async () => { // CONTEXT.md M8:875 — partials are advisory, final // `SDKAssistantMessage` is canonical. With `includePartialMessages: @@ -2556,7 +2786,7 @@ suite('ClaudeAgent', () => { const signals: AgentSignal[] = []; disposables.add(agent.onDidSessionProgress(s => signals.push(s))); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const partActions = signals .map(s => s.kind === 'action' ? s.action : undefined) @@ -2593,7 +2823,7 @@ suite('ClaudeAgent', () => { sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; // Snapshot before the SDK has streamed any messages. - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session); assert.ok(session, 'session is materialized'); @@ -2634,7 +2864,7 @@ suite('ClaudeAgent', () => { // queued by `entry.send`). Without this we'd race materialize. const materialized = Event.toPromise(agent.onDidMaterializeSession); - const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2711,6 +2941,7 @@ suite('ClaudeAgent', () => { [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent: ClaudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -2725,7 +2956,7 @@ suite('ClaudeAgent', () => { // Kick off the materialize. It will pass the post-startup abort // gate, create the wrapper, then park inside `setMetadata`. - const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -2781,7 +3012,7 @@ suite('ClaudeAgent', () => { // Materializing now requires a provisional record; without it // the sequencer task throws synchronously inside the queued fn. - const sendErr = await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1') + const sendErr = await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2831,7 +3062,7 @@ suite('ClaudeAgent', () => { const events: IAgentMaterializeSessionEvent[] = []; disposables.add(agent.onDidMaterializeSession(e => events.push(e))); - await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1'); assert.deepStrictEqual({ startupCallCount: sdk.startupCallCount, @@ -2870,7 +3101,7 @@ suite('ClaudeAgent', () => { const sessionUri = AgentSession.uri('claude', 'ghost-session-id'); // sdk.sessionList stays empty — getSessionInfo resolves undefined. - const sendErr = await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1') + const sendErr = await agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1') .then(() => undefined, err => err); assert.deepStrictEqual({ @@ -2894,7 +3125,7 @@ suite('ClaudeAgent', () => { // `sessionAdded` for createSession-spawned sessions), so // `_readSessionPermissionMode` returned `undefined`, the // fallback `'default'` won, and a plan-mode session silently - // downgraded to default mode mid-conversation. + // downgraded to default mode mid-chat. // // The fix: only forward `setPermissionMode` when the live config // actually carries a value, leaving the session's seeded @@ -2922,10 +3153,10 @@ suite('ClaudeAgent', () => { cwd: URI.file('/work').fsPath, }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'turn-1', undefined, 't1'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'turn-1', undefined, 't1'); sdk.nextQueryMessages = [makeResultSuccess(sessionId)]; - await agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'turn-2', undefined, 't2'); + await agent.chats.sendMessage(defaultChatUri(sessionUri), 'turn-2', undefined, 't2'); const fakeQuery = sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -2961,7 +3192,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(AgentSession.id(matCreated.session)), makeResultSuccess(AgentSession.id(matCreated.session)), ]; - await agent.sendMessage(matCreated.session, URI.parse(buildDefaultChatUri(matCreated.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(matCreated.session), 'hi', undefined, 'turn-1'); // Leave a second session provisional. const provCreated = await agent.createSession({ workingDirectory: URI.file('/work-prov') }); @@ -2992,10 +3223,10 @@ suite('ClaudeAgent', () => { matWarmAsyncDisposed: matWarm.asyncDisposeCount > asyncDisposeBefore, // A post-shutdown sendMessage to the provisional URI must // fail because the provisional record was cleared. - provDropped: await agent.sendMessage(provCreated.session, URI.parse(buildDefaultChatUri(provCreated.session)), 'late', undefined, 'turn-late') + provDropped: await agent.chats.sendMessage(defaultChatUri(provCreated.session), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), // Same for the materialized URI. - matDropped: await agent.sendMessage(matCreated.session, URI.parse(buildDefaultChatUri(matCreated.session)), 'late', undefined, 'turn-late') + matDropped: await agent.chats.sendMessage(defaultChatUri(matCreated.session), 'late', undefined, 'turn-late') .then(() => false, err => err instanceof Error && /unknown session/i.test(err.message)), }, { memoized: true, @@ -3054,7 +3285,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const deltas = observed.flatMap(s => s.kind === 'action' && s.action.type === ActionType.ChatDelta @@ -3089,7 +3320,7 @@ suite('ClaudeAgent', () => { makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-explicit'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-explicit'); const drained = sdk.warmQueries[0]?.produced?.drainedPrompts ?? []; assert.deepStrictEqual({ @@ -3122,7 +3353,7 @@ suite('ClaudeAgent', () => { const fileUri = URI.file('/work/src/foo.ts'); const dirUri = URI.file('/work/src/bar'); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'review please', [ + await agent.chats.sendMessage(defaultChatUri(created.session), 'review please', [ { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, { type: MessageAttachmentKind.Resource, uri: dirUri.toString(), label: 'bar', displayKind: 'directory' }, ], 'turn-1'); @@ -3241,8 +3472,8 @@ suite('ClaudeAgent', () => { // key, and the agent does not surface a double-dispose error. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const r1 = await agent.createSession({}); - await agent.createSession({}); + const r1 = await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectory: URI.file('/work') }); const p1 = agent.disposeSession(r1.session); const p2 = agent.shutdown(); @@ -3269,7 +3500,7 @@ suite('ClaudeAgent', () => { // into SDK-side or DB-side deletion. const { agent, sdk } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - const created = await agent.createSession({}); + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); // Make the SDK report the just-created session as if its // metadata had been written by an earlier `query()` turn — // that's the steady state once Phase 6 sendMessage lands. @@ -3341,6 +3572,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, sdk], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3413,6 +3645,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, sdk], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3456,6 +3689,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, sdk], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3504,6 +3738,7 @@ suite('ClaudeAgent', () => { [IClaudeAgentSdkService, sdk], [IAgentPluginManager, new FakeAgentPluginManager()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -3557,6 +3792,58 @@ suite('ClaudeAgent', () => { }); }); + test('restore-reachable SDK reads defer (no download) when the SDK is not yet local (preselection premature-download fix)', async () => { + // Regression: when a materialized Claude session is restored on + // startup (the renderer subscribes to the last-active session), the + // host's restore path calls `getSessionMetadata` -> `getSessionInfo` + // and `getSessionMessages`, both of which dynamically import the SDK. + // Before the fix that eagerly triggered a cold SDK download (with no + // progress interest registered, so no notification) purely from + // preselecting/restoring Claude — the download must only start on the + // first user message. `listSessions` was already guarded; this locks + // in the matching guard on the two other restore-reachable reads. + const sdk = new FakeClaudeAgentSdkService(); + sdk.canLoadWithoutDownloadResult = false; + sdk.sessionList = [ + { sessionId: 'materialized', summary: 'Materialized Session', lastModified: 5000, createdAt: 4900, cwd: '/work' }, + ]; + sdk.sessionMessagesById.set('materialized', forkSourceMessages('materialized')); + + const services = new ServiceCollection( + [ILogService, new NullLogService()], + [IAgentConfigurationService, createTestAgentConfigService(disposables)], + [ICopilotApiService, new FakeCopilotApiService()], + [IClaudeProxyService, new FakeClaudeProxyService()], + [ISessionDataService, createNullSessionDataService()], + [IClaudeAgentSdkService, sdk], + [IAgentPluginManager, new FakeAgentPluginManager()], + [IProductService, FakeProductService], + ); + const instantiationService = disposables.add(new InstantiationService(services)); + const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + + const sessionUri = AgentSession.uri('claude', 'materialized'); + const metadata = await agent.getSessionMetadata!(sessionUri); + const messages = await agent.getSessionMessages(sessionUri); + const sessions = await agent.listSessions(); + + assert.deepStrictEqual({ + metadata, + messages, + sessions, + // The SDK must never be touched — no `getSessionInfo` / + // `getSessionMessages` calls => no dynamic import => no download. + getSessionInfoCalls: sdk.getSessionInfoCalls, + getSessionMessagesCalls: sdk.getSessionMessagesCalls, + }, { + metadata: undefined, + messages: [], + sessions: [], + getSessionInfoCalls: [], + getSessionMessagesCalls: [], + }); + }); + test('shutdown is idempotent and returns the same memoized promise on concurrent calls', async () => { // Phase 6+ INVARIANT: the SDK Query subprocess for each live // session is aborted inside `shutdown()`. If two callers race @@ -3568,8 +3855,8 @@ suite('ClaudeAgent', () => { // Mirror of `CopilotAgent.shutdown()` at copilotAgent.ts:1246. const { agent } = createTestContext(disposables); await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); - await agent.createSession({}); - await agent.createSession({}); + await agent.createSession({ workingDirectory: URI.file('/work') }); + await agent.createSession({ workingDirectory: URI.file('/work') }); const first = agent.shutdown(); const second = agent.shutdown(); @@ -3821,13 +4108,15 @@ suite('ClaudeAgent', () => { [ISessionDataService, createNullSessionDataService()], [IClaudeAgentSdkService, new FakeClaudeAgentSdkService()], [IAgentPluginManager, new FakeAgentPluginManager()], + [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); await agent.authenticate('https://api.github.com', 'tok'); - await agent.createSession({}); + await agent.createSession({ workingDirectory: URI.file('/work') }); agent.dispose(); assert.strictEqual(proxyDisposed, true); @@ -3876,6 +4165,7 @@ suite('ClaudeAgent', () => { [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent: ClaudeAgent = instantiationService.createInstance(ClaudeAgent); @@ -3885,7 +4175,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const settle: { rejected?: unknown } = {}; const sendDone = send.then(() => { settle.rejected = false; }, err => { settle.rejected = err; }); @@ -3935,7 +4225,7 @@ suite('ClaudeAgent', () => { agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = tools; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); const opts = sdk.capturedStartupOptions[0]; assert.ok(opts.mcpServers, 'mcpServers populated'); @@ -3963,13 +4253,13 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); agent.getOrCreateActiveClient(created.session, { clientId: 'client-1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.queryAdvance = undefined; advance.complete(); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); const lastBuild = sdk.createSdkMcpServerCalls[sdk.createSdkMcpServerCalls.length - 1]; assert.deepStrictEqual({ @@ -3997,7 +4287,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); // Stage a pending truncation anchor, then send again. The pending anchor @@ -4005,7 +4295,7 @@ suite('ClaudeAgent', () => { await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); sdk.queryAdvance = undefined; advance.complete(); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, @@ -4029,12 +4319,12 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); // A later tool-driven rebind must NOT resurrect the consumed anchor. agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'third', undefined, 'turn-3'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'third', undefined, 'turn-3'); const anchored = sdk.capturedStartupOptions.filter(o => o.resumeSessionAt === 'anchor-uuid'); assert.deepStrictEqual({ @@ -4056,17 +4346,17 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); await agent.getSessionForTesting(created.session)!.truncateToTurn('turn-1', 'anchor-uuid'); // The anchor-carrying rebuild fails at startup (one-shot). The anchor // must NOT be cleared — losing it would silently proceed without // `resumeSessionAt`, undoing the checkpoint restore. sdk.startupRejection = new Error('transient startup failure'); - await assert.rejects(() => agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2')); + await assert.rejects(() => agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2')); // Retry: the staged anchor is re-applied on the next (now-succeeding) send. - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second-retry', undefined, 'turn-2b'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second-retry', undefined, 'turn-2b'); assert.strictEqual(sdk.capturedStartupOptions.at(-1)?.resumeSessionAt, 'anchor-uuid'); }); @@ -4077,7 +4367,7 @@ suite('ClaudeAgent', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session)!; await session.truncateToTurn('turn-1', 'anchor-uuid'); @@ -4104,12 +4394,12 @@ suite('ClaudeAgent', () => { const tools: ToolDefinition[] = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = tools; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1, 'first materialize'); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', description: 'e', inputSchema: { type: 'object' } }]; advance.complete(); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 1, 'equal snapshot should NOT yield-restart'); }); @@ -4128,7 +4418,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); // Completion for an unknown tool_use_id is a benign no-op (no parked // handler in this test path because we don't drive the real MCP @@ -4164,7 +4454,7 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); await assert.doesNotReject(agent.disposeSession(created.session)); }); @@ -4175,11 +4465,11 @@ suite('ClaudeAgent', () => { const sessionId = AgentSession.id(created.session); agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); // Change tools to force a rebind path (must use yield-restart, NOT Query.setMcpServers). agent.getOrCreateActiveClient(created.session, { clientId: 'c1' }).tools = [{ name: 'echo2', inputSchema: { type: 'object' } }]; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); // If `Query.setMcpServers` had been called, `FakeQuery.setMcpServers` would have thrown. assert.strictEqual(sdk.startupCallCount, 2, 'rebind path used yield-restart, not setMcpServers'); }); @@ -4204,7 +4494,7 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'go', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(created.session), 'go', undefined, 'turn-1'); // Wait until the materializer has snapshotted ['first'] into the diff // and is paused inside `sdk.startup`. THEN inject the update. await startupReached.p; @@ -4252,7 +4542,7 @@ suite('ClaudeAgent', () => { }; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - const send = agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'hi', undefined, 'turn-1'); + const send = agent.chats.sendMessage(defaultChatUri(sessionUri), 'hi', undefined, 'turn-1'); // Wait until the resume's `sdk.startup` is in flight, then inject the // update. Pre-fix the call hit the silent-drop branch because no // provisional was registered for the resume. @@ -4285,7 +4575,7 @@ suite('ClaudeAgent', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Stage a rebind whose startup will reject. @@ -4293,7 +4583,7 @@ suite('ClaudeAgent', () => { sdk.startupRejection = new Error('simulated rebind startup failure'); sdk.queryAdvance = undefined; advance.complete(); - await assert.rejects(agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2')); + await assert.rejects(agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2')); // Pre-fix: `_buildClientMcpServers` consumed the diff, but the SDK // startup that followed rejected without re-marking dirty, so the next @@ -4302,7 +4592,7 @@ suite('ClaudeAgent', () => { // send retries the rebind and succeeds. sdk.startupRejection = undefined; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'third', undefined, 'turn-3'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'third', undefined, 'turn-3'); assert.deepStrictEqual({ startupCount: sdk.startupCallCount, lastSnapshot: sdk.createSdkMcpServerCalls.at(-1)?.toolNames, @@ -4343,6 +4633,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { const session = disposables.add(ClaudeAgentSession.createProvisional( 'session-id', URI.parse('claude:/session-id'), + URI.parse(buildDefaultChatUri('claude:/session-id')), URI.file('/workspace'), undefined, undefined, @@ -4424,7 +4715,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { values: { ...(seedConfig ?? {}) }, }; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); @@ -4536,7 +4827,7 @@ suite('ClaudeAgent (Phase 7 §3.4 — _handleCanUseTool)', () => { // with `deny` and clears the entry. const { ctx, canUseTool, sessionUri } = await materialize(); - const session = ctx.agent['_sessions'].get(AgentSession.id(sessionUri))?.session; + const session = ctx.agent['_sessions'].get(AgentSession.id(sessionUri))?.defaultChat; assert.ok(session, 'session is materialized'); const ac = new AbortController(); @@ -4706,7 +4997,7 @@ suite('ClaudeAgent (Phase 7 §3.5 — INTERACTIVE_CLAUDE_TOOLS)', () => { } })); - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool callback was wired into Options'); return { ctx, canUseTool, inputRequests, sessionUri: created.session }; @@ -4919,9 +5210,9 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = makeResultSuccess(sessionId), ]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); ctx.configService.updateSessionConfig(created.session.toString(), { permissionMode: 'acceptEdits' }); - const p2 = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi-2', undefined, 'turn-2'); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi-2', undefined, 'turn-2'); // Drain microtasks so `await entry.setPermissionMode('acceptEdits')` // resolves and the second prompt lands in the in-flight queue before // the iterator yields its `result(idx=2)` (see the multi-turn reuse @@ -4965,7 +5256,7 @@ suite('ClaudeAgent (Phase 7 §3.6 / §3.8 — permissionMode propagation)', () = }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const fakeQuery = ctx.sdk.warmQueries.at(-1)?.produced; assert.deepStrictEqual({ @@ -4993,7 +5284,7 @@ suite('ClaudeAgent (Phase 7 §3.7 — onElicitation cancel stub)', () => { const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const onElicitation = ctx.sdk.capturedStartupOptions[0]?.onElicitation; assert.ok(onElicitation, 'onElicitation callback was wired into Options'); @@ -5021,7 +5312,7 @@ suite('ClaudeAgent (Phase 8 — file edit tracking via SDK message stream)', () const created = await ctx.agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); return { ctx, sessionId, sessionUri: created.session }; } @@ -5096,7 +5387,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sessionId), ...(opts?.extraMessages ?? [makeResultSuccess(sessionId)]), ]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const warm = ctx.sdk.warmQueries[0]; const query = warm.produced!; return { ctx, sessionUri: created.session, sessionId, warm, query, advance }; @@ -5111,12 +5402,12 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { model: { id: 'claude-opus-4.6' }, }); - await ctx.agent.changeModel(created.session, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'medium' } }); + await ctx.agent.chats.changeModel(defaultChatUri(created.session), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'medium' } }); assert.strictEqual(ctx.sdk.startupCallCount, 0); const sid = AgentSession.id(created.session); ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const opts = ctx.sdk.capturedStartupOptions[0]; assert.deepStrictEqual({ model: opts.model, effort: opts.effort }, { model: 'claude-sonnet-4-6', effort: 'medium' }); }); @@ -5124,8 +5415,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { test('changeModel on a materialized session queues a model+effort bundle that drains at the next yield boundary', async () => { const { ctx, sessionUri, query, advance } = await materialize(); - await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5143,8 +5434,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const log = new CapturingLogService(); const { ctx, sessionUri, query, advance } = await materialize({ logService: log }); - await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6', config: { thinkingLevel: 'max' } }); - const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-opus-4.6', config: { thinkingLevel: 'max' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5156,8 +5447,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { test('changeModel with same id and unchanged effort skips the SDK setters', async () => { const { ctx, sessionUri, query, advance } = await materialize(); - await ctx.agent.changeModel(sessionUri, { id: 'claude-opus-4.6' }); - const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'next', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-opus-4.6' }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'next', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5174,7 +5465,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Start a long turn that parks at the gate so steering has // something to steer into. - const longSend = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-1', message: { text: 'switch topic', origin: { kind: MessageKind.User } } }, []); @@ -5210,7 +5501,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { const signals: AgentSignal[] = []; disposables.add(ctx.agent.onDidSessionProgress(s => signals.push(s))); - const longSend = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'long task', undefined, 'turn-2'); + const longSend = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'long task', undefined, 'turn-2'); await tick(); ctx.agent.setPendingMessages!(sessionUri, { id: 'pending-9', message: { text: 'steer', origin: { kind: MessageKind.User } } }, []); @@ -5247,10 +5538,10 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 0) { await stall.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); await tick(); - await ctx.agent.abortSession(created.session); + await ctx.agent.chats.abort(defaultChatUri(created.session)); await assert.rejects(inFlight, (err: unknown) => isCancellationError(err)); // Unblock the (now-aborted) iterator so it terminates cleanly. @@ -5261,7 +5552,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Next sendMessage rebuilds via resume mode. const startupBefore = ctx.sdk.startupCallCount; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'next', undefined, 'turn-2'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'next', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'rebind called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; @@ -5280,7 +5571,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Materialize the session by driving one full turn so canUseTool is wired into Options. ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'); const canUseTool = ctx.sdk.capturedStartupOptions[0]?.canUseTool; assert.ok(canUseTool, 'canUseTool was wired into Options'); @@ -5291,7 +5582,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { }); await tick(); - await ctx.agent.abortSession(created.session); + await ctx.agent.chats.abort(defaultChatUri(created.session)); const result = await permissionPromise; assert.deepStrictEqual(result, { behavior: 'deny', message: 'User declined' }); }); @@ -5308,7 +5599,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { throw new Error('subprocess crashed'); } }; await assert.rejects( - ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'hi', undefined, 'turn-1'), + ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'hi', undefined, 'turn-1'), (err: Error) => err.message.includes('subprocess crashed'), ); @@ -5316,7 +5607,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid), makeResultSuccess(sid)]; const startupBefore = ctx.sdk.startupCallCount; - await ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'recover', undefined, 'turn-2'); + await ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'recover', undefined, 'turn-2'); assert.strictEqual(ctx.sdk.startupCallCount, startupBefore + 1, 'crash recovery called startup again'); const resumeOpts = ctx.sdk.capturedStartupOptions[ctx.sdk.startupCallCount - 1]; assert.strictEqual(resumeOpts.resume, sid); @@ -5327,8 +5618,8 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Hot-swap model + effort on the live query so the bijective // cache picks up the new values. - await ctx.agent.changeModel(sessionUri, { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); - const p2 = ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'apply', undefined, 'turn-2'); + await ctx.agent.chats.changeModel(defaultChatUri(sessionUri), { id: 'claude-sonnet-4.6', config: { thinkingLevel: 'high' } }); + const p2 = ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'apply', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5336,10 +5627,10 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { // Now abort and resend; the rebound query MUST receive the same // model + effort via the rebind's re-apply pass. - await ctx.agent.abortSession(sessionUri); + await ctx.agent.chats.abort(defaultChatUri(sessionUri)); ctx.sdk.queryAdvance = undefined; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await ctx.agent.sendMessage(sessionUri, URI.parse(buildDefaultChatUri(sessionUri)), 'after-abort', undefined, 'turn-3'); + await ctx.agent.chats.sendMessage(defaultChatUri(sessionUri), 'after-abort', undefined, 'turn-3'); const reboundQuery = ctx.sdk.warmQueries[1].produced!; assert.deepStrictEqual({ @@ -5370,7 +5661,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { ctx.sdk.queryAdvance = async (i) => { if (i === 1) { await advance.p; } }; ctx.sdk.nextQueryMessages = [makeSystemInitMessage(sid)]; - const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'long task', undefined, 'turn-1'); await tick(); // Subscribe BEFORE injecting steering so we capture the @@ -5438,7 +5729,7 @@ suite('ClaudeAgent (Phase 9 — runtime mutation surface)', () => { makeResultSuccess(sid), // final (unblocked by advance2) ]; - const inFlight = ctx.agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'long task', undefined, 'turn-1'); + const inFlight = ctx.agent.chats.sendMessage(defaultChatUri(created.session), 'long task', undefined, 'turn-1'); let inFlightResolved = false; void inFlight.then(() => { inFlightResolved = true; }, () => { inFlightResolved = true; }); await tick(); @@ -5620,6 +5911,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { [IAgentHostGitService, createNoopGitService()], [IAgentConfigurationService, configService], [IProductService, FakeProductService], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -5744,7 +6036,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.startupCallCount, 1); // Customization sync flips dirty; the next sendMessage's @@ -5754,7 +6046,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); const firstQuery = sdk.warmQueries[0].produced!; - const p2 = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + const p2 = agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); await tick(); advance.complete(); await p2; @@ -5782,7 +6074,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { ]; pm.syncResult = [makeSyncedRef('https://x', '/p/x')]; await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://x', 'X')]); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const session = agent.getSessionForTesting(created.session)!; // First-turn materialize consumed the dirty bit from the sync // above (plugin path baked into `Options.plugins` of the @@ -5795,7 +6087,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const gate = new DeferredPromise<void>(); sdk.queryAdvance = async (i: number) => { if (i === 2) { await gate.p; } }; - const inflight = agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + const inflight = agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); await new Promise(r => setImmediate(r)); // Toggle a SYNCED customization during the in-flight turn. The @@ -5824,7 +6116,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; await agent.syncClientCustomizations(created.session, 'c', [makeClientCustomization('https://a', 'A')]); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); // SDK snapshot failed → `sdk` stays undefined → unfiltered fallback: @@ -5850,7 +6142,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { sdk.supportedAgentsResult = []; sdk.mcpServerStatusResult = []; sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); assert.strictEqual(customizations.length, 1); @@ -5893,7 +6185,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const init = makeSystemInitMessage(sessionId); init.plugins = [{ name: 'tg', path: root }]; sdk.nextQueryMessages = [init, makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); const customizations = await agent.getSessionCustomizations!(created.session); assert.deepStrictEqual( @@ -5911,11 +6203,11 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const created = await agent.createSession({ workingDirectory: URI.file('/work') }); const sessionId = AgentSession.id(created.session); - await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/code-reviewer.md' }); + await agent.chats.changeAgent(defaultChatUri(created.session), { uri: 'file:///foo/agents/code-reviewer.md' }); assert.strictEqual(sdk.startupCallCount, 0, 'no SDK startup from changeAgent on provisional'); sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'code-reviewer', 'agent name resolved from file URI basename'); }); @@ -5932,13 +6224,13 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, undefined, 'no agent on first startup'); // Mid-session agent change: flips dirty, next send rebinds // (SDK has no working runtime hook to swap the agent in place). - await agent.changeAgent!(created.session, { uri: 'file:///foo/agents/planner.md' }); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.changeAgent(defaultChatUri(created.session), { uri: 'file:///foo/agents/planner.md' }); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2, 'rebind on agent change'); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, 'planner', 'agent baked into rebuilt Options'); @@ -5959,15 +6251,483 @@ suite('ClaudeAgent — Phase 11 customizations', () => { makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), makeSystemInitMessage(sessionId), makeResultSuccess(sessionId), ]; - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'first', undefined, 'turn-1'); + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, 'turn-1'); assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); - await agent.changeAgent!(created.session, undefined); - await agent.sendMessage(created.session, URI.parse(buildDefaultChatUri(created.session)), 'second', undefined, 'turn-2'); + await agent.chats.changeAgent(defaultChatUri(created.session), undefined); + await agent.chats.sendMessage(defaultChatUri(created.session), 'second', undefined, 'turn-2'); assert.strictEqual(sdk.startupCallCount, 2); assert.strictEqual(sdk.capturedStartupOptions[1]?.agent, undefined, 'cleared agent omitted from rebuilt Options'); }); + + // #region Multi-chat — additional (non-default) peer chats + + test('createChat persists a peer chat; getChats lists it; disposeChat removes it', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + + await agent.chats.createChat(chatUri); + const afterCreate = listPeerChats(agent, created.session); + + // Idempotent re-create must not duplicate the catalog entry. + await agent.chats.createChat(chatUri); + const afterRecreate = listPeerChats(agent, created.session); + + await agent.chats.disposeChat(chatUri); + const afterDispose = listPeerChats(agent, created.session); + + assert.deepStrictEqual({ afterCreate, afterRecreate, afterDispose }, { + afterCreate: [chatUri.toString()], + afterRecreate: [chatUri.toString()], + afterDispose: [], + }); + }); + + test('createChat / disposeChat on the default chat URI are no-ops', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const defaultChat = URI.parse(buildChatUri(created.session.toString(), 'default')); + + await agent.chats.createChat(defaultChat); + await agent.chats.disposeChat(defaultChat); + + assert.deepStrictEqual(listPeerChats(agent, created.session), []); + }); + + test('createChat({ fork }) forks the source chat; the peer chat resumes its own forked SDK session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + // Parent session with a two-turn transcript; fork the peer chat at u1. + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + const forkCall = sdk.forkSessionCalls[0]; + + // Sending to the peer chat resumes ITS forked chat, not the parent's. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'next', undefined, 'turn-1'); + + assert.deepStrictEqual({ + forkCall, + chats: listPeerChats(agent, created.session), + startupResume: sdk.capturedStartupOptions[0]?.resume, + }, { + forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } }, + chats: [chatUri.toString()], + startupResume: 'forked-1', + }); + }); + + test('createChat({ fork }) with an unknown turn falls back to a fresh chat', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'does-not-exist' }); + + assert.deepStrictEqual({ + forked: sdk.forkSessionCalls.length, + chats: listPeerChats(agent, created.session), + }, { + forked: 0, + chats: [chatUri.toString()], + }); + }); + + test('sendMessage to a peer chat targets a chat distinct from the parent session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + // The peer chat's startup resumed `forked-1`; the parent session was + // never materialized (no fresh `sessionId` startup for the parent). + assert.deepStrictEqual({ + startupCount: sdk.capturedStartupOptions.length, + resume: sdk.capturedStartupOptions[0]?.resume, + parentMaterialized: sdk.capturedStartupOptions.some(o => o.sessionId === parentId), + }, { + startupCount: 1, + resume: 'forked-1', + parentMaterialized: false, + }); + }); + + test('changeModel on a peer chat persists in the catalog so a later resume picks it up', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Change the peer chat's model before it is materialized. + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + // First send materializes (resumes) the chat with the changed model. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.model, 'claude-opus-4-6'); + }); + + test('disposing the parent session disposes its peer chats', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.createChat(chatUri); + + await agent.disposeSession(created.session); + + // The persisted catalog still records the chat (dispose tears down live + // state, not on-disk history), but no in-memory chat survives — + // re-disposing the chat is a clean no-op. + await agent.chats.disposeChat(chatUri); + assert.deepStrictEqual(listPeerChats(agent, created.session), []); + }); + + test('setPendingMessages routes steering to a materialized peer chat, warns for an unknown one', async () => { + const logService = new CapturingLogService(); + const { agent, sdk } = createTestContext(disposables, { logService }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + // Known materialized peer chat: resolved via the `chat` arg, no warning. + logService.warns.length = 0; + agent.setPendingMessages!(created.session, { id: 'p1', message: { text: 'steer', origin: { kind: MessageKind.User } } }, [], chatUri); + const warnAfterKnown = logService.warns.filter(w => w.includes('setPendingMessages')); + + // Unknown peer chat URI: not found, warns. + const unknownChat = URI.parse(buildChatUri(created.session.toString(), 'chat-missing')); + agent.setPendingMessages!(created.session, undefined, [], unknownChat); + const warnAfterUnknown = logService.warns.filter(w => w.includes('setPendingMessages')); + + assert.deepStrictEqual({ knownWarns: warnAfterKnown.length, unknownWarns: warnAfterUnknown.length }, { knownWarns: 0, unknownWarns: 1 }); + }); + + test('changeAgent on a peer chat persists to its overlay so a later resume picks it up', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Select a custom agent for the peer chat before it is materialized; the + // selection lands on the chat's own overlay (mirrors changeModel). + await agent.chats.changeAgent(chatUri, { uri: 'file:///foo/agents/planner.md' }); + + // First send materializes (resumes) the chat with the selected agent. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'planner'); + }); + + test('sendMessage routes each peer chat to its own forked chat', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + + // Two peer chats, each forked from a different turn into its own SDK + // chat. Staging distinct fork results pins per-chat identity. + const chatA = URI.parse(buildChatUri(created.session.toString(), 'chat-a')); + sdk.forkSessionResult = { sessionId: 'forked-a' }; + await agent.chats.fork(chatA, { source: created.session, turnId: 'u1' }); + + const chatB = URI.parse(buildChatUri(created.session.toString(), 'chat-b')); + sdk.forkSessionResult = { sessionId: 'forked-b' }; + await agent.chats.fork(chatB, { source: created.session, turnId: 'u2' }); + + sdk.sessionList = [ + { sessionId: 'forked-a', summary: 'a', lastModified: 1, cwd: URI.file('/work').fsPath }, + { sessionId: 'forked-b', summary: 'b', lastModified: 1, cwd: URI.file('/work').fsPath }, + ]; + + // Each send must resume the chat backing THAT chat, never the + // other and never the (un-materialized) parent session. + sdk.nextQueryMessages = [makeSystemInitMessage('forked-a'), makeResultSuccess('forked-a')]; + await agent.chats.sendMessage(chatA, 'to a', undefined, 'turn-a'); + sdk.nextQueryMessages = [makeSystemInitMessage('forked-b'), makeResultSuccess('forked-b')]; + await agent.chats.sendMessage(chatB, 'to b', undefined, 'turn-b'); + + assert.deepStrictEqual({ + chats: listPeerChats(agent, created.session).sort(), + resumeA: sdk.capturedStartupOptions[0]?.resume, + resumeB: sdk.capturedStartupOptions[1]?.resume, + parentMaterialized: sdk.capturedStartupOptions.some(o => o.sessionId === parentId), + }, { + chats: [chatA.toString(), chatB.toString()].sort(), + resumeA: 'forked-a', + resumeB: 'forked-b', + parentMaterialized: false, + }); + }); + + test('restart round-trip: a forked peer chat re-materializes from the orchestrator\'s providerData on a fresh agent backed by the same database', async () => { + const database = new TestSessionDatabase(); + + // --- First "process": create a forked peer chat with a model override. + // `createChat` hands the orchestrator an opaque `providerData` blob to + // persist. --- + const ctxA = createTestContext(disposables, { database }); + await ctxA.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await ctxA.agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + ctxA.sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + ctxA.sdk.forkSessionResult = { sessionId: 'forked-1' }; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await ctxA.agent.chats.fork(chatUri, { source: created.session, turnId: 'u1' }, { + model: { id: 'claude-opus-4.6' }, + }); + const providerData = createResult?.providerData; + const catalogBefore = listPeerChats(ctxA.agent, created.session); + + // --- Simulate a restart: a brand-new agent over the SAME database. + // Nothing carries over in memory; the parent + forked transcripts + // survive on disk, staged in the fresh SDK's session list. The + // orchestrator hands the persisted `providerData` back via + // `materializeChat` to re-attach the chat's backing. --- + const ctxB = createTestContext(disposables, { database }); + await ctxB.agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + ctxB.sdk.sessionList = [ + { sessionId: parentId, summary: 'parent', lastModified: 1, cwd: URI.file('/work').fsPath }, + { sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }, + ]; + + await ctxB.agent.materializeChat!(chatUri, providerData); + // Catalog reappears from the re-attached live backing without SDK contact. + const catalogAfter = listPeerChats(ctxB.agent, created.session); + + // First send on the restored chat resumes its forked chat with + // the persisted model override — history + per-chat model both came back. + ctxB.sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await ctxB.agent.chats.sendMessage(chatUri, 'after restart', undefined, 'turn-1'); + + assert.deepStrictEqual({ + providerData: providerData && JSON.parse(providerData), + catalogBefore, + catalogAfter, + resume: ctxB.sdk.capturedStartupOptions[0]?.resume, + model: ctxB.sdk.capturedStartupOptions[0]?.model, + }, { + providerData: { sdkSessionId: 'forked-1', model: { id: 'claude-opus-4.6' } }, + catalogBefore: [chatUri.toString()], + catalogAfter: [chatUri.toString()], + resume: 'forked-1', + model: 'claude-opus-4-6', + }); + }); + + test('changeModel on a peer chat fires onDidChangeChatData with the refreshed providerData', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await agent.chats.createChat(chatUri); + const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; + + const changes: IAgentChatDataChange[] = []; + disposables.add(agent.onDidChangeChatData!(e => changes.push(e))); + + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + assert.deepStrictEqual(changes.map(c => ({ chat: c.chat.toString(), providerData: JSON.parse(c.providerData) })), [ + { chat: chatUri.toString(), providerData: { sdkSessionId, model: { id: 'claude-opus-4.6' } } }, + ]); + }); + + // #endregion + + // #region Multi-chat — chat surface (G-C1 adoption) + + test('createSession mints a provisional session and disposeSession tears it down', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + // Tearing the session down must not throw and must be idempotent. + await agent.disposeSession(created.session); + await agent.disposeSession(created.session); + + assert.deepStrictEqual({ + scheme: created.session.scheme, + provisional: created.provisional, + }, { + scheme: 'claude', + provisional: true, + }); + }); + + test('chats.createChat persists a peer chat; getChats lists it; chats.disposeChat removes it', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + + await agent.chats!.createChat(chatUri); + const afterCreate = listPeerChats(agent, created.session); + + await agent.chats!.disposeChat(chatUri); + const afterDispose = listPeerChats(agent, created.session); + + assert.deepStrictEqual({ afterCreate, afterDispose }, { + afterCreate: [chatUri.toString()], + afterDispose: [], + }); + }); + + test('chats.fork forks the source chat; chats.sendMessage resumes the peer chat\'s own forked SDK session', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats!.fork(chatUri, { source: created.session, turnId: 'u1' }); + + const forkCall = sdk.forkSessionCalls[0]; + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats!.sendMessage(chatUri, 'next', undefined, 'turn-1'); + + assert.deepStrictEqual({ + forkCall, + chats: listPeerChats(agent, created.session), + startupResume: sdk.capturedStartupOptions[0]?.resume, + }, { + forkCall: { sessionId: parentId, options: { upToMessageId: 'a1' } }, + chats: [chatUri.toString()], + startupResume: 'forked-1', + }); + }); + + test('chats addresses the default chat by the default chat URI (sendMessage routes to the default chat; getMessages mirrors getSessionMessages)', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const sessionId = AgentSession.id(created.session); + sdk.nextQueryMessages = [makeSystemInitMessage(sessionId), makeResultSuccess(sessionId)]; + + const chat = defaultChatUri(created.session); + await agent.chats!.sendMessage(chat, 'hi', undefined, 'turn-1'); + + const viaChats = await agent.chats!.getMessages(chat); + const viaLegacy = await agent.getSessionMessages(created.session); + + assert.deepStrictEqual({ + startupSessionId: sdk.capturedStartupOptions[0]?.sessionId, + resume: sdk.capturedStartupOptions[0]?.resume, + messagesMatchLegacy: JSON.stringify(viaChats) === JSON.stringify(viaLegacy), + }, { + startupSessionId: sessionId, + resume: undefined, + messagesMatchLegacy: true, + }); + }); + + test('chats.changeModel on a peer fires onDidChangeChatData with the refreshed providerData (parity with legacy changeModel)', async () => { + const { agent } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + const createResult = await agent.chats!.createChat(chatUri); + const sdkSessionId = JSON.parse(createResult!.providerData!).sdkSessionId as string; + + const changes: IAgentChatDataChange[] = []; + disposables.add(agent.onDidChangeChatData!(e => changes.push(e))); + + await agent.chats.changeModel(chatUri, { id: 'claude-opus-4.6' }); + + assert.deepStrictEqual(changes.map(c => ({ chat: c.chat.toString(), providerData: JSON.parse(c.providerData) })), [ + { chat: chatUri.toString(), providerData: { sdkSessionId, model: { id: 'claude-opus-4.6' } } }, + ]); + }); + + test('chats.changeAgent on a peer persists to its overlay so a later resume picks it up (parity with legacy changeAgent)', async () => { + const { agent, sdk } = createTestContext(disposables); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + + const created = await agent.createSession({ workingDirectory: URI.file('/work') }); + const parentId = AgentSession.id(created.session); + sdk.sessionMessagesById.set(parentId, forkSourceMessages(parentId)); + sdk.forkSessionResult = { sessionId: 'forked-1' }; + sdk.sessionList = [{ sessionId: 'forked-1', summary: 'fork', lastModified: 1, cwd: URI.file('/work').fsPath }]; + + const chatUri = URI.parse(buildChatUri(created.session.toString(), 'chat-1')); + await agent.chats!.fork(chatUri, { source: created.session, turnId: 'u1' }); + + // Select a custom agent for the peer chat before it is materialized. + await agent.chats!.changeAgent(chatUri, { uri: 'file:///foo/agents/reviewer.md' }); + + sdk.nextQueryMessages = [makeSystemInitMessage('forked-1'), makeResultSuccess('forked-1')]; + await agent.chats!.sendMessage(chatUri, 'hi', undefined, 'turn-1'); + + assert.strictEqual(sdk.capturedStartupOptions[0]?.agent, 'reviewer'); + }); + + // #endregion }); // #endregion diff --git a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts index e2b57acf3a9a28..f0d3ac51c4e424 100644 --- a/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeMapSessionEvents.test.ts @@ -12,6 +12,7 @@ import { ActionType } from '../../common/state/sessionActions.js'; import { ResponsePartKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { ToolCallConfirmationReason, ToolCallContributorKind } from '../../common/state/protocol/state.js'; import { ClaudeMapperState, mapSDKMessageToAgentSignals } from '../../node/claude/claudeMapSessionEvents.js'; +import { CLAUDE_USER_DECLINED_MESSAGE } from '../../node/claude/claudeToolDenial.js'; import { encodeForwardedChatError, PROXY_ERROR_PREFIX } from '../../node/shared/forwardedChatError.js'; import { SubagentRegistry } from '../../node/claude/claudeSubagentRegistry.js'; import { @@ -271,6 +272,31 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { assert.deepStrictEqual(log.warns, []); }); + test('Test 10b — a tool denied by the user maps to result.error.code = denied', () => { + const log = new NullLogService(); + const state = new ClaudeMapperState(); + const resolver = r(); + + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStartToolUse(0, 'tu_d', 'Bash')), SESSION, TURN_ID, state, log, resolver); + mapSDKMessageToAgentSignals(makeStreamEvent(SESSION_ID, makeContentBlockStop(0)), SESSION, TURN_ID, state, log, resolver); + + const signals = mapSDKMessageToAgentSignals( + makeUserToolResultMessage(SESSION_ID, 'tu_d', CLAUDE_USER_DECLINED_MESSAGE, { isError: true }), + SESSION, + TURN_ID, + state, + log, + r(), + ); + + const signal = signals[0]; + if (signal.kind !== 'action' || signal.action.type !== ActionType.ChatToolCallComplete) { + throw new Error(`expected a ChatToolCallComplete action, got ${signal.kind}`); + } + assert.strictEqual(signal.action.result.success, false); + assert.deepStrictEqual(signal.action.result.error, { message: CLAUDE_USER_DECLINED_MESSAGE, code: 'denied' }); + }); + test('Test 9 — input_json_delta emits ChatToolCallDelta scoped to the open tool_use block', () => { const log = new NullLogService(); const state = new ClaudeMapperState(); @@ -409,6 +435,10 @@ suite('claudeMapSessionEvents — direct mapper tests', () => { const complete = signals[0]; assert.ok(complete.kind === 'action' && complete.action.type === ActionType.ChatToolCallComplete); assert.strictEqual(complete.action.result.success, false); + // A genuine failure whose message is not one of the known deny strings + // must NOT be classified as a cancellation: no `error.code` is set, so + // telemetry reports `error` rather than `userCancelled`. + assert.strictEqual(complete.action.result.error?.code, undefined); }); test('tool_result content as TextBlock array unwraps to ToolResultTextContent[]', () => { diff --git a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts index 1624281974adb9..b0cba0e0f2d91e 100644 --- a/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeProxyService.test.ts @@ -56,6 +56,9 @@ type MessagesResult = class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + messagesResult: MessagesResult = { kind: 'error', error: new Error('not configured') }; modelsResult: { kind: 'value'; value: CCAModel[] } | { kind: 'error'; error: Error } = { kind: 'value', value: [] }; @@ -1252,6 +1255,8 @@ suite('ClaudeProxyService', () => { models: () => Promise.resolve([]), responses: () => Promise.reject(new Error('not used')), utilityChatCompletion: () => Promise.reject(new Error('not used')), + resolveRestrictedTelemetryContext: () => Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }), + resolveApiEndpoint: () => Promise.resolve(undefined), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); @@ -1321,6 +1326,8 @@ suite('ClaudeProxyService', () => { models: fake.models.bind(fake), responses: fake.responses.bind(fake), utilityChatCompletion: fake.utilityChatCompletion.bind(fake), + resolveRestrictedTelemetryContext: fake.resolveRestrictedTelemetryContext.bind(fake), + resolveApiEndpoint: fake.resolveApiEndpoint.bind(fake), }; const service = new ClaudeProxyService(new NullLogService(), wrapped); const handle = await service.start(TOKEN); diff --git a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts index fa9b03d05c4ce8..0851e0f9f58014 100644 --- a/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSdkPipeline.test.ts @@ -69,6 +69,7 @@ class ImmediatelyDoneQuery implements Query { async [Symbol.asyncDispose](): Promise<void> { /* not exercised here */ } setMaxThinkingTokens(): never { throw new Error('not modeled'); } initializationResult(): never { throw new Error('not modeled'); } + reinitialize(): never { throw new Error('not modeled'); } supportedCommands(): never { throw new Error('not modeled'); } supportedModels(): never { throw new Error('not modeled'); } supportedAgents(): never { throw new Error('not modeled'); } diff --git a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts index f1ac66bd442b7e..378cd8319a087a 100644 --- a/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeSubagentResolver.test.ts @@ -42,6 +42,7 @@ class FakeSdkService implements IClaudeAgentSdkService { getSubagentMessagesCalls: { sessionId: string; agentId: string }[] = []; async listSessions(): Promise<readonly SDKSessionInfo[]> { return []; } + async canLoadWithoutDownload(): Promise<boolean> { return true; } async getSessionInfo(_id: string): Promise<SDKSessionInfo | undefined> { return undefined; } async startup(_p: { options: Options; initializeTimeoutMs?: number }): Promise<WarmQuery> { throw new Error('not used'); } async query(_params: { prompt: string | AsyncIterable<SDKUserMessage>; options?: Options }): Promise<Query> { throw new Error('not used'); } diff --git a/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts b/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts new file mode 100644 index 00000000000000..6037092ad0cd78 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/claudeToolDenial.test.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { + CLAUDE_PLAN_DECLINED_MESSAGE, + CLAUDE_QUESTION_CANCELLED_MESSAGE, + CLAUDE_USER_DECLINED_MESSAGE, + claudeToolDenialCode, +} from '../../node/claude/claudeToolDenial.js'; + +suite('claudeToolDenial', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('claudeToolDenialCode classifies every known deny message and nothing else', () => { + // Each known deny `message` returned from `canUseTool` maps to its + // cancellation code; any other (genuine tool-error) message stays + // unclassified so telemetry reports `error` rather than `userCancelled`. + const classified = { + userDeclined: claudeToolDenialCode(CLAUDE_USER_DECLINED_MESSAGE), + planDeclined: claudeToolDenialCode(CLAUDE_PLAN_DECLINED_MESSAGE), + questionCancelled: claudeToolDenialCode(CLAUDE_QUESTION_CANCELLED_MESSAGE), + genuineError: claudeToolDenialCode('permission denied'), + empty: claudeToolDenialCode(''), + }; + assert.deepStrictEqual(classified, { + userDeclined: 'denied', + planDeclined: 'denied', + questionCancelled: 'cancelled', + genuineError: undefined, + empty: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts index 38588162104647..fb251fdc5112ca 100644 --- a/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeToolDisplay.test.ts @@ -232,8 +232,8 @@ suite('claudeToolDisplay — §4 mapping table', () => { ['TodoWrite', undefined, undefined, 'Updating todo list', 'Updated todo list', '"Update todo list" failed', '{\n "todos": []\n}'], ['WebFetch', undefined, undefined, { markdown: 'Fetching [https://example.com](https://example.com)' }, { markdown: 'Fetched [https://example.com](https://example.com)' }, '"Fetch URL" failed', '{\n "url": "https://example.com"\n}'], ['Task', 'subagent', { toolKind: 'subagent' }, 'find the bug', 'Ran subagent', '"Run subagent task" failed', '{\n "description": "find the bug",\n "subagent_type": "Explore"\n}'], - ['ExitPlanMode', undefined, undefined, 'Ready to code?', 'Used "Ready to code?"', '"Ready to code?" failed', '{\n "plan": "..."\n}'], - ['AskUserQuestion', undefined, undefined, 'Ask user a question', 'Used "Ask user a question"', '"Ask user a question" failed', '{\n "question": "why?"\n}'], + ['ExitPlanMode', undefined, undefined, 'Ready to code?', 'Ready to code?', '"Ready to code?" failed', '{\n "plan": "..."\n}'], + ['AskUserQuestion', undefined, undefined, 'Ask user a question', 'Ask user a question', '"Ask user a question" failed', '{\n "question": "why?"\n}'], ]); }); diff --git a/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts b/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts index 94f8900c0e3928..ef18fa3bbcc00f 100644 --- a/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts +++ b/src/vs/platform/agentHost/test/node/clientTools/claudeSessionClientToolsModel.test.ts @@ -120,6 +120,21 @@ suite('SessionClientToolsDiff', () => { assert.strictEqual(diff.model.ownerOf('shared'), 'c1', 'first-inserted client wins the shared name'); }); + test('ownerOf prefers the requested client when it provides the shared tool', () => { + const diff = disposables.add(new SessionClientToolsDiff()); + diff.model.setTools('c1', [tool({ name: 'shared', description: 'from c1' })]); + diff.model.setTools('c2', [tool({ name: 'shared', description: 'from c2' })]); + assert.deepStrictEqual({ + defaultOwner: diff.model.ownerOf('shared'), + preferredOwner: diff.model.ownerOf('shared', 'c2'), + missingPreferredOwner: diff.model.ownerOf('shared', 'missing'), + }, { + defaultOwner: 'c1', + preferredOwner: 'c2', + missingPreferredOwner: 'c1', + }); + }); + test('removeClient drops that client and re-flips dirty when the merged set changes', () => { const diff = disposables.add(new SessionClientToolsDiff()); diff.model.setTools('c1', [tool({ name: 'a' })]); diff --git a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts index 3409f81ea884e8..ddf3819b8213c9 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexMapAppServerEvents.test.ts @@ -394,6 +394,114 @@ suite('codexMapAppServerEvents', () => { }); }); + test('mcpToolCall start carries an MCP contributor when the server has a customization', () => { + const state = createCodexSessionMapState(); + state.mcpCustomizationIds.set('github', 'cust-gh'); + const startActions = mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_c', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const start = startActions[0]; + if (start.type !== ActionType.ChatToolCallStart) { + throw new Error('expected a ChatToolCallStart action'); + } + assert.deepStrictEqual(start.contributor, { kind: ToolCallContributorKind.MCP, customizationId: 'cust-gh' }); + }); + + test('mcpToolCall start carries no contributor when the server has no customization', () => { + const state = createCodexSessionMapState(); + // mcpCustomizationIds is empty: the agent has not applied an MCP + // inventory yet, so the start must not stamp a (bogus) MCP contributor — + // the tool then reports the default `agentHost` source. + const startActions = mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_n', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const start = startActions[0]; + if (start.type !== ActionType.ChatToolCallStart) { + throw new Error('expected a ChatToolCallStart action'); + } + assert.strictEqual(start.contributor, undefined); + }); + + test('a host-declined commandExecution reports result.error.code = denied', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'commandExecution', id: 'cmd_d', + command: 'rm file', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'inProgress' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const entry = state.itemToToolCall.get('cmd_d'); + if (!entry) { + throw new Error('expected a tracked tool call'); + } + // The host declined the approval (recorded by respondToPermissionRequest). + state.declinedToolCalls.add(entry.toolCallId); + const actions = mapItemCompleted(state, { + item: { + type: 'commandExecution', id: 'cmd_d', + command: 'rm file', cwd: '/tmp', processId: null, + source: 'agent' as never, status: 'failed' as never, + commandActions: [], aggregatedOutput: null, + exitCode: null, durationMs: 1, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + const complete = actions[0]; + if (complete.type !== ActionType.ChatToolCallComplete) { + throw new Error('expected a ChatToolCallComplete action'); + } + assert.strictEqual(complete.result.success, false); + assert.strictEqual(complete.result.error?.code, 'denied'); + }); + + test('a host-declined mcpToolCall reports result.error.code = denied', () => { + const state = createCodexSessionMapState(); + mapItemStarted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_d', server: 'github', tool: 'search', + status: 'inProgress', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: null, + } as never, + threadId: 'thr_1', turnId: 'turn_a', startedAtMs: 0, + }); + const entry = state.itemToToolCall.get('mcp_d'); + if (!entry) { + throw new Error('expected a tracked tool call'); + } + // The host declined the approval (recorded by respondToPermissionRequest). + // The decline is drained once in the shared completion prologue, so a + // non-command tool type is classified as a denial just like a command. + state.declinedToolCalls.add(entry.toolCallId); + const actions = mapItemCompleted(state, { + item: { + type: 'mcpToolCall', id: 'mcp_d', server: 'github', tool: 'search', + status: 'failed', arguments: {}, mcpAppResourceUri: undefined, + pluginId: null, result: null, error: null, durationMs: 1, + } as never, + threadId: 'thr_1', turnId: 'turn_a', completedAtMs: 0, + }); + const complete = actions[0]; + if (complete.type !== ActionType.ChatToolCallComplete) { + throw new Error('expected a ChatToolCallComplete action'); + } + assert.strictEqual(complete.result.success, false); + assert.strictEqual(complete.result.error?.code, 'denied'); + }); + test('dynamicToolCall item carries a Client contributor when a client owns the tool', () => { const toolSet = new ActiveClientToolSet(); toolSet.set('win-7', [{ name: 'get_magic_word' }]); @@ -556,12 +664,14 @@ suite('codexMapAppServerEvents', () => { state.itemToPartId.set('i1', 'p1'); state.itemToToolCall.set('i2', { toolCallId: 'tc', turnId: 'turn_a', toolName: 'shell', output: '' }); state.itemToReasoningPartId.set('i3', 'r1'); + state.declinedToolCalls.add('tc-stale'); resetCodexTurnMapState(state); assert.deepStrictEqual({ currentTurnId: state.currentTurnId, parts: state.itemToPartId.size, toolCalls: state.itemToToolCall.size, reasoning: state.itemToReasoningPartId.size, - }, { currentTurnId: 'turn_a', parts: 0, toolCalls: 0, reasoning: 0 }); + declined: state.declinedToolCalls.size, + }, { currentTurnId: 'turn_a', parts: 0, toolCalls: 0, reasoning: 0, declined: 0 }); }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts index 39d1bd24011cb8..fce6ecdb7880cf 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexProxyService.test.ts @@ -24,6 +24,9 @@ interface IResponsesCall { class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } + readonly responsesCalls: IResponsesCall[] = []; messages(): never { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 1e8ae1925bcc22..ae388ec87063b2 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { CopilotClient, CopilotSession, ModelInfo, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; +import type { CopilotClient, CopilotSession, ModelInfo, SessionEvent, SessionEventHandler, SessionEventPayload, SessionEventType, TypedSessionEventHandler } from '@github/copilot-sdk'; import type Anthropic from '@anthropic-ai/sdk'; import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import * as fs from 'fs/promises'; import * as os from 'os'; import { VSBuffer } from '../../../../base/common/buffer.js'; -import { DeferredPromise } from '../../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; import { isCancellationError } from '../../../../base/common/errors.js'; import { Disposable, type DisposableStore, type IDisposable, type IReference } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; @@ -26,14 +26,16 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; import { ILogService, NullLogService } from '../../../log/common/log.js'; +import { IAgentHostProxyResolver } from '../../node/agentHostProxyResolver.js'; import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; -import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; +import { AgentHostPreferLongContextEnabledConfigKey } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentSessionMetadata } from '../../common/agentService.js'; +import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentActionSignal, type IAgentCreateChatForkSource, type IAgentSessionMetadata, type IAgentSpawnChatEvent } from '../../common/agentService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { buildDefaultChatUri, buildSubagentSessionUri, buildChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; -import { CustomizationType, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; +import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, customizationId, type ClientPluginCustomization, type MarkdownResponsePart, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js'; +import { CustomizationType, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import { ActionType, type ChatAction, type IDeltaAction, type SessionAction } from '../../common/state/sessionActions.js'; import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js'; @@ -43,17 +45,83 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; -import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotWorktreeName, getCopilotWorktreesRoot, migrateEnablementKeys, rebaseUnder } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, CopilotSessionEntry, getCopilotWorktreeDirectoryName, getCopilotWorktreesRoot, migrateEnablementKeys, rebaseUnder } from '../../node/copilot/copilotAgent.js'; +import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; +import { IAgentHostReviewService, NULL_REVIEW_SERVICE } from '../../common/agentHostReviewService.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { CopilotBranchNameGenerator, ICopilotBranchNameGenerator, getCopilotBranchNameHintFromMessage, normalizeCopilotBranchName } from '../../node/copilot/copilotBranchNameGenerator.js'; import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; import { ShellManager } from '../../node/copilot/copilotShellTools.js'; +import { registerPendingEditContentProvider } from '../../node/copilot/pendingEditContentStore.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { createNullSessionDataService } from '../common/sessionTestHelpers.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; import { ICopilotApiService, type ICopilotApiServiceRequestOptions, type ICopilotUtilityChatCompletionRequest } from '../../node/shared/copilotApiService.js'; +/** + * Test helpers for the single `_sessions` container. All chats (default + peers) + * live inside the owning session's {@link CopilotSessionEntry}, keyed by chat URI + * string; the default chat is the entry's `defaultChat`. These wrap that + * structure so tests can inject/observe fakes without reaching into private + * container internals. + */ +function sessionsMap(agent: CopilotAgent): Map<string, CopilotSessionEntry> { + return (agent as unknown as { _sessions: Map<string, CopilotSessionEntry> })._sessions; +} + +function defaultChatUri(session: URI): URI { + return URI.parse(buildDefaultChatUri(session)); +} + +/** Inject (or replace) a session's default-chat stub. */ +function setDefaultSessionStub(agent: CopilotAgent, sessionId: string, stub: unknown): void { + const sessions = sessionsMap(agent); + const defaultChatKey = buildDefaultChatUri(AgentSession.uri('copilotcli', sessionId).toString()); + let entry = sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + sessions.set(sessionId, entry); + } + entry.setDefaultChat(defaultChatKey, new CopilotSessionEntry(stub as CopilotAgentSession)); +} + +/** Inject a peer-chat stub into its owning session's entry (creating the entry if needed). */ +function setPeerChatStub(agent: CopilotAgent, chatUri: URI, stub: unknown): void { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + const sessions = sessionsMap(agent); + let entry = sessions.get(sessionId); + if (!entry) { + entry = new CopilotSessionEntry(); + sessions.set(sessionId, entry); + } + entry.registerPeerChat(chatUri.toString(), new CopilotSessionEntry(stub as CopilotAgentSession)); +} + +/** Resolve a peer-chat stub from its owning session's entry. */ +function getPeerChatStub(agent: CopilotAgent, chatUri: URI): CopilotAgentSession | undefined { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + return sessionsMap(agent).get(sessionId)?.getPeerChat(chatUri.toString()); +} + +/** True when a peer chat is tracked in its owning session's entry. */ +function hasPeerChatStub(agent: CopilotAgent, chatUri: URI): boolean { + const sessionId = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chatUri))); + return sessionsMap(agent).get(sessionId)?.hasPeerChat(chatUri.toString()) ?? false; +} + +/** Total number of peer chats tracked across all sessions. */ +function peerChatCount(agent: CopilotAgent): number { + let count = 0; + for (const entry of sessionsMap(agent).values()) { + count += entry.peerChatKeys().length; + } + return count; +} + class TestAgentPluginManager implements IAgentPluginManager { declare readonly _serviceBrand: undefined; @@ -111,6 +179,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async revParse(_repositoryRoot: URI, expression: string): Promise<string | undefined> { return expression === 'HEAD' ? this.headCommit : undefined; } + async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; } + async overlayPathIntoTree(): Promise<string | undefined> { return undefined; } + async diffTreePaths(): Promise<string[] | undefined> { return undefined; } async computeFileDiffsBetweenRefs(): Promise<undefined> { return undefined; } } @@ -151,6 +222,8 @@ class TestCopilotApiService implements ICopilotApiService { async countTokens(): Promise<Anthropic.MessageTokensCount> { throw new Error('not used'); } async models(): Promise<CCAModel[]> { return []; } async responses(): Promise<Response> { throw new Error('not used'); } + async resolveRestrictedTelemetryContext() { return { restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }; } + async resolveApiEndpoint() { return undefined; } async utilityChatCompletion(githubToken: string, request: ICopilotUtilityChatCompletionRequest, options?: ICopilotApiServiceRequestOptions): Promise<string> { this.utilityCalls.push({ token: githubToken, request, options }); if (this.error) { @@ -307,11 +380,38 @@ interface IFakeAgentSession { class MockCopilotSession { readonly sessionId = 'test-session-1'; + private readonly _handlers = new Set<SessionEventHandler>(); + private readonly _typedHandlers = new Map<SessionEventType, Set<(event: SessionEventPayload<SessionEventType>) => void>>(); on(_handler: SessionEventHandler): () => void; on<K extends SessionEventType>(_eventType: K, _handler: TypedSessionEventHandler<K>): () => void; - on<K extends SessionEventType>(_eventTypeOrHandler: K | SessionEventHandler, _handler?: TypedSessionEventHandler<K>): () => void { - return () => { }; + on<K extends SessionEventType>(eventTypeOrHandler: K | SessionEventHandler, handler?: TypedSessionEventHandler<K>): () => void { + if (typeof eventTypeOrHandler === 'function') { + this._handlers.add(eventTypeOrHandler); + return () => this._handlers.delete(eventTypeOrHandler); + } + if (!handler) { + throw new Error(`Missing handler for ${eventTypeOrHandler}`); + } + let handlers = this._typedHandlers.get(eventTypeOrHandler); + if (!handlers) { + handlers = new Set(); + this._typedHandlers.set(eventTypeOrHandler, handlers); + } + const typedHandler = handler as (event: SessionEventPayload<SessionEventType>) => void; + handlers.add(typedHandler); + return () => handlers.delete(typedHandler); + } + + emit<K extends SessionEventType>(event: SessionEventPayload<K>): void { + const sessionEvent = event as SessionEvent; + for (const handler of this._handlers) { + handler(sessionEvent); + } + const typedEvent = event as SessionEventPayload<SessionEventType>; + for (const handler of this._typedHandlers.get(event.type) ?? []) { + handler(typedEvent); + } } async send(): Promise<string> { return ''; } @@ -341,6 +441,18 @@ class MockAgentHostOTelService implements IAgentHostOTelService { } } +class TestProxyResolver implements IAgentHostProxyResolver { + declare readonly _serviceBrand: undefined; + + register(): IDisposable { + return Disposable.None; + } + + async resolveProxy(): Promise<string | undefined> { + return undefined; + } +} + class ResumePathCopilotAgent extends CopilotAgent { constructor( private readonly _copilotClient: ITestCopilotClient, @@ -351,8 +463,12 @@ class ResumePathCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); + super(logService, instantiationService, sessionDataService, gitService, configurationService, createTestGitHubEndpointService(), new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService, proxyResolver); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -378,8 +494,12 @@ class TestableCopilotAgent extends CopilotAgent { @IAgentConfigurationService configurationService: IAgentConfigurationService, @ICopilotBranchNameGenerator branchNameGenerator: ICopilotBranchNameGenerator, @IAgentHostCompletions completions: IAgentHostCompletions, + @INativeEnvironmentService environmentService: INativeEnvironmentService, + @IByokLmBridgeRegistry byokBridgeRegistry: IByokLmBridgeRegistry, + @IAgentHostProxyResolver proxyResolver: IAgentHostProxyResolver, + @ICopilotApiService copilotApiService: ICopilotApiService, ) { - super(logService, instantiationService, sessionDataService, gitService, configurationService, new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE); + super(logService, instantiationService, sessionDataService, gitService, configurationService, createTestGitHubEndpointService(), new MockAgentHostOTelService(), branchNameGenerator, completions, NULL_CHECKPOINT_SERVICE, NULL_REVIEW_SERVICE, environmentService, byokBridgeRegistry, NullTelemetryService, copilotApiService, proxyResolver); this._enablePlanModeOnClient(this._copilotClient as CopilotClient); } @@ -429,7 +549,7 @@ class TestableCopilotAgent extends CopilotAgent { } } -function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { +function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; userHome?: URI }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; fileService: FileService } { const services = new ServiceCollection(); const logService = new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -438,9 +558,11 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio services.set(ILogService, logService); services.set(IFileService, fileService); services.set(IAgentConfigurationService, configService); + services.set(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); services.set(ISessionDataService, options?.sessionDataService ?? createNullSessionDataService()); services.set(IAgentPluginManager, options?.pluginManager ?? new TestAgentPluginManager()); services.set(IAgentHostGitService, options?.gitService ?? new TestAgentHostGitService()); + services.set(IAgentHostReviewService, NULL_REVIEW_SERVICE); services.set(IAgentHostTerminalManager, new TestAgentHostTerminalManager()); services.set(IAgentHostOTelService, { _serviceBrand: undefined, @@ -449,6 +571,8 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio flush: async () => undefined, }); services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); + services.set(IAgentHostProxyResolver, new TestProxyResolver()); + services.set(IByokLmBridgeRegistry, new ByokLmBridgeRegistry()); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); services.set(ICopilotBranchNameGenerator, new CopilotBranchNameGenerator(copilotApiService, logService)); @@ -456,7 +580,7 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio if (options?.environmentServiceRegistration !== 'none') { const environmentService = { _serviceBrand: undefined, - userHome: URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), + userHome: options?.userHome ?? URI.from({ scheme: Schemas.inMemory, path: '/mock-home' }), } as INativeEnvironmentService; services.set(INativeEnvironmentService, environmentService); } @@ -468,38 +592,41 @@ function createTestAgentContext(disposables: Pick<DisposableStore, 'add'>, optio return { agent, instantiationService, configurationService: configService, fileService }; } -function createTestAgent(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; copilotApiService?: ICopilotApiService }): CopilotAgent { +function createTestAgent(disposables: Pick<DisposableStore, 'add'>, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; userHome?: URI }): CopilotAgent { return createTestAgentContext(disposables, options).agent; } type CopilotCreateSessionOptions = Parameters<CopilotClient['createSession']>[0]; -function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { +function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationService: IInstantiationService, options?: { readonly mockSession?: MockCopilotSession; readonly activeClientToolSet?: ActiveClientToolSet; readonly snapshot?: IActiveClientSnapshot }): { readonly session: CopilotAgentSession; readonly createOptions: () => CopilotCreateSessionOptions | undefined } { const sessionUri = AgentSession.uri('copilotcli', 'test-session-1'); const shellManager = instantiationService.createInstance(ShellManager, sessionUri, undefined); let createOptions: CopilotCreateSessionOptions | undefined; + const mockSession = options?.mockSession ?? new MockCopilotSession(); const launchPlan: CopilotSessionLaunchPlan = { kind: 'create', client: { createSession: async options => { createOptions = options; - return new MockCopilotSession() as unknown as CopilotSession; + return mockSession as unknown as CopilotSession; }, - resumeSession: async () => new MockCopilotSession() as unknown as CopilotSession, + resumeSession: async () => mockSession as unknown as CopilotSession, }, - activeClientToolSet: new ActiveClientToolSet(), + activeClientToolSet: options?.activeClientToolSet ?? new ActiveClientToolSet(), sessionId: 'test-session-1', workingDirectory: undefined, resolvedAgentName: undefined, - snapshot: { tools: [], plugins: [], mcpServers: {} }, + snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} }, shellManager, githubToken: 'token', model: undefined, }; - const session = (agent as unknown as { - _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined) => CopilotAgentSession; - })._createAgentSession(launchPlan, undefined); - return { session, createOptions: () => createOptions }; + const agentInternals = (agent as unknown as { + _getOrCreateActiveClient: (session: URI, directory: URI | undefined) => unknown; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown) => CopilotAgentSession; + }); + const activeClient = agentInternals._getOrCreateActiveClient(sessionUri, undefined); + return { session: agentInternals._createAgentSession(launchPlan, undefined, activeClient), createOptions: () => createOptions }; } function withoutUndefinedProperties(metadata: IAgentSessionMetadata): Record<string, unknown> { @@ -541,13 +668,63 @@ suite('CopilotAgent', () => { assert.deepStrictEqual(agent.getDescriptor(), { provider: 'copilotcli', displayName: 'Copilot', - description: 'Copilot SDK agent running in a dedicated process', + description: 'Copilot SDK agent running in the local agent host process', + capabilities: { multipleChats: { fork: true } }, }); } finally { await disposeAgent(agent); } }); + suite('spawned chat channel', () => { + function fireSignal(agent: CopilotAgent, signal: AgentSignal): void { + (agent as unknown as { _onDidSessionProgress: { fire(s: AgentSignal): void } })._onDidSessionProgress.fire(signal); + } + + test('mirrors subagent_started onto onDidSpawnChat; subagent_completed leaves the chat live', async () => { + const agent = createTestAgent(disposables); + const spawned: IAgentSpawnChatEvent[] = []; + disposables.add(agent.onDidSpawnChat(e => spawned.push(e))); + try { + const sessionUri = AgentSession.uri('copilotcli', 'spawn-session'); + const parentChat = buildDefaultChatUri(sessionUri.toString()); + const toolCallId = 'tool-42'; + const expectedChat = buildSubagentChatUri(parseRequiredSessionUriFromChatUri(parentChat), toolCallId); + + fireSignal(agent, { + kind: 'subagent_started', + chat: URI.parse(parentChat), + toolCallId, + agentName: 'researcher', + agentDisplayName: 'Researcher', + agentDescription: 'Looks things up', + }); + // Unrelated signals must not produce spawn events. + fireSignal(agent, { kind: 'action', resource: sessionUri, action: { type: ActionType.SessionTitleChanged, title: 'x' } }); + // A completed subagent chat stays live (removed only on session teardown). + fireSignal(agent, { kind: 'subagent_completed', chat: URI.parse(parentChat), toolCallId }); + + assert.deepStrictEqual({ + spawned: spawned.map(e => ({ + session: e.session.toString(), + chat: e.chat.toString(), + parent: e.parent ? { chat: e.parent.chat.toString(), toolCallId: e.parent.toolCallId } : undefined, + title: e.title, + })), + }, { + spawned: [{ + session: sessionUri.toString(), + chat: expectedChat, + parent: { chat: parentChat, toolCallId }, + title: 'Researcher', + }], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + test('uses the Copilot CLI sibling worktrees root convention', () => { assert.strictEqual( getCopilotWorktreesRoot(URI.file('/Users/me/src/vscode')).fsPath, @@ -587,8 +764,30 @@ suite('CopilotAgent', () => { }); }); + test('prepends the branch prefix ahead of the built-in agents/ prefix', async () => { + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'add-agent-host-config'; + const generator = new CopilotBranchNameGenerator(copilotApiService, new NullLogService()); + + assert.deepStrictEqual({ + withPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchPrefix: 'users/alice/' }), + emptyPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', message: 'Add agent host config', githubToken: 'token', branchPrefix: '' }), + fallbackWithPrefix: await generator.generateBranchName({ sessionId: '12345678-aaaa-bbbb-cccc-123456789abc', branchPrefix: 'users/alice/' }), + }, { + withPrefix: 'users/alice/agents/add-agent-host-config', + emptyPrefix: 'agents/add-agent-host-config', + fallbackWithPrefix: 'users/alice/agents/12345678-aaaa-bbbb-cccc-123456789abc', + }); + }); + test('uses Git extension branch-derived worktree folder names', () => { - assert.strictEqual(getCopilotWorktreeName('agents/add-agent-host-config-12345678'), 'add-agent-host-config-12345678'); + assert.deepStrictEqual({ + noPrefix: getCopilotWorktreeDirectoryName('agents/add-agent-host-config-12345678'), + withPrefix: getCopilotWorktreeDirectoryName('users/alice/agents/add-agent-host-config-12345678', 'users/alice/'), + }, { + noPrefix: 'add-agent-host-config-12345678', + withPrefix: 'add-agent-host-config-12345678', + }); }); test('keeps generated branch names short', async () => { @@ -883,9 +1082,9 @@ suite('CopilotAgent', () => { } }); - test('createSession falls back to an empty temp directory when workingDirectory is omitted', async () => { - const agent = createTestAgent(disposables); - let createdWorkingDirectory: URI | undefined; + test('createSession infers workspace-less from an omitted workingDirectory and uses a stable scratch dir', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const agent = createTestAgent(disposables, { userHome }); try { await agent.authenticate('https://api.github.com', 'token'); @@ -895,18 +1094,64 @@ suite('CopilotAgent', () => { assert.strictEqual(result.provisional, true); assert.ok(result.workingDirectory); - createdWorkingDirectory = result.workingDirectory; - assert.strictEqual(createdWorkingDirectory.scheme, Schemas.file); - assert.strictEqual(createdWorkingDirectory.fsPath.toLowerCase().startsWith(os.tmpdir().toLowerCase()), true); - assert.deepStrictEqual(await fs.readdir(createdWorkingDirectory.fsPath), []); + const expected = URI.joinPath(userHome, '.copilot', 'chats', 'temp-fallback'); + assert.strictEqual(result.workingDirectory.scheme, Schemas.file); + assert.strictEqual(result.workingDirectory.fsPath, expected.fsPath); + assert.deepStrictEqual(await fs.readdir(result.workingDirectory.fsPath), []); + // Tagged workspace-less purely from inference (no input flag). + const provisional = (agent as unknown as { _provisionalSessions: Map<string, { workspaceless?: boolean }> })._provisionalSessions.get('temp-fallback'); + assert.strictEqual(provisional?.workspaceless, true); } finally { - if (createdWorkingDirectory) { - await fs.rm(createdWorkingDirectory.fsPath, { recursive: true, force: true }); - } + await fs.rm(userHome.fsPath, { recursive: true, force: true }); await disposeAgent(agent); } }).timeout(30_000); + suite('quick chat scratch directory', () => { + test('resume recreates a reaped quick chat scratch dir (ensure-exists on restore)', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const sessionId = 'qc-resume'; + const session = AgentSession.uri('copilotcli', sessionId); + const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', sessionId); + const sessionDataService = disposables.add(new TestSessionDataService()); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.workingDirectory', scratchDir.toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + const client = new TestCopilotClient([sdkSession(sessionId, scratchDir.fsPath)]); + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, userHome }); + const internals = agent as unknown as { _resumeSession: (id: string) => Promise<unknown> }; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await assert.rejects(() => fs.access(scratchDir.fsPath)); + // The stubbed SDK can't finish initializing the resumed session, but + // the scratch dir is ensured before that point. + await internals._resumeSession(sessionId).catch(() => undefined); + await fs.access(scratchDir.fsPath); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + + test('disposeSession cleans up the quick chat scratch dir', async () => { + const userHome = URI.file(await fs.mkdtemp(`${os.tmpdir()}/qc-home-`)); + const agent = createTestAgent(disposables, { userHome }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'qc-dispose'); + const result = await agent.createSession({ session }); + const scratchDir = URI.joinPath(userHome, '.copilot', 'chats', 'qc-dispose'); + await fs.access(scratchDir.fsPath); + await agent.disposeSession(result.session); + await assert.rejects(() => fs.access(scratchDir.fsPath)); + } finally { + await fs.rm(userHome.fsPath, { recursive: true, force: true }); + await disposeAgent(agent); + } + }).timeout(30_000); + }); + suite('restart on startup config change', () => { class StopCountingClient extends TestCopilotClient { @@ -925,7 +1170,7 @@ suite('CopilotAgent', () => { // Force the client to start so a subsequent config change has something to restart. await agent.listSessions(); - configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); + configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); await Promise.resolve(); assert.strictEqual(client.stopCount, 1); @@ -942,10 +1187,9 @@ suite('CopilotAgent', () => { await agent.listSessions(); let disposed = false; - const sessions = (agent as unknown as { _sessions: { set(k: string, v: { dispose(): void }): void } })._sessions; - sessions.set('active', { dispose() { disposed = true; } }); + setDefaultSessionStub(agent, 'active', { dispose() { disposed = true; } }); - configurationService.updateRootConfig({ [AgentHostConfigKey.RubberDuck]: true }); + configurationService.updateRootConfig({ [CopilotCliConfigKey.RubberDuck]: true }); await Promise.resolve(); assert.strictEqual(client.stopCount, 1); @@ -962,7 +1206,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); await agent.listSessions(); - configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: true }); + configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: true }); await Promise.resolve(); assert.strictEqual(client.stopCount, 0); @@ -1031,6 +1275,7 @@ suite('CopilotAgent', () => { cacheCost: 1, outputCost: 15, longContextInputCost: 6, + longContextCacheCost: 1, longContextOutputCost: 22.5, priceCategory: 'medium', }); @@ -1120,7 +1365,7 @@ suite('CopilotAgent', () => { } }); - test('configSchema shows only long context option when long_context tier has no surcharge', async () => { + test('configSchema shows both context options by default when long_context tier has no surcharge', async () => { const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([], [{ id: 'free-long-context', @@ -1139,6 +1384,36 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const models = await waitForState(agent.models, models => models.length > 0); + const contextSize = models[0].configSchema?.properties?.contextSize; + assert.strictEqual(contextSize?.type, 'number'); + assert.deepStrictEqual(contextSize?.enum, [200_000, 1_000_000]); + assert.strictEqual(contextSize?.default, 200_000); + assert.deepStrictEqual(contextSize?.enumLabels, ['200K', '1M']); + } finally { + await disposeAgent(agent); + } + }); + + test('configSchema shows only long context option when long_context tier has no surcharge and preferLongContext is enabled', async () => { + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: new TestCopilotClient([], [{ + id: 'free-long-context', + name: 'Free Long Context', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000 }, + }, + }, + }]), + }); + try { + configurationService.updateRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: true }); + await agent.authenticate('https://api.github.com', 'token'); + const models = await waitForState(agent.models, models => models.length > 0); + const contextSize = models[0].configSchema?.properties?.contextSize; assert.strictEqual(contextSize?.type, 'number'); assert.deepStrictEqual(contextSize?.enum, [1_000_000]); @@ -1163,7 +1438,7 @@ suite('CopilotAgent', () => { }, }; - async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[]): Promise<CopilotCreateSessionOptions | undefined> { + async function captureSessionConfig(model: ModelSelection | undefined, models: readonly ITestCopilotModelInfo[], preferLongContext?: boolean): Promise<CopilotCreateSessionOptions | undefined> { const sessionDataService = disposables.add(new TestSessionDataService()); const client = new TestCopilotClient([], models); let capturedConfig: CopilotCreateSessionOptions | undefined; @@ -1171,8 +1446,11 @@ suite('CopilotAgent', () => { capturedConfig = config; return new MockCopilotSession() as unknown as CopilotSession; }; - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); try { + if (preferLongContext) { + configurationService.updateRootConfig({ [AgentHostPreferLongContextEnabledConfigKey]: true }); + } await agent.authenticate('https://api.github.com', 'token'); await waitForState(agent.models, m => m.length > 0); const result = await agent.createSession({ @@ -1180,7 +1458,7 @@ suite('CopilotAgent', () => { workingDirectory: URI.file('/workspace'), ...(model ? { model } : {}), }); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); return capturedConfig; } finally { await disposeAgent(agent); @@ -1214,7 +1492,7 @@ suite('CopilotAgent', () => { assert.strictEqual(config.contextTier, 'long_context'); }); - test('uses long_context when model has no surcharge and no explicit selection', async () => { + test('leaves the SDK on its default tier when model has no surcharge and no explicit selection', async () => { const freeLongContextModel: ITestCopilotModelInfo = { id: 'free-long-ctx', name: 'Free Long Ctx', @@ -1229,6 +1507,24 @@ suite('CopilotAgent', () => { }; const config = await captureSessionConfig({ id: 'free-long-ctx' }, [freeLongContextModel]); assert.ok(config); + assert.strictEqual(config.contextTier, undefined); + }); + + test('uses long_context when model has no surcharge, no explicit selection and preferLongContext is enabled', async () => { + const freeLongContextModel: ITestCopilotModelInfo = { + id: 'free-long-ctx', + name: 'Free Long Ctx', + capabilities: { limits: { max_context_window_tokens: 200_000 } }, + billing: { + multiplier: 1, + tokenPrices: { + contextMax: 200_000, + longContext: { contextMax: 1_000_000 }, + }, + }, + }; + const config = await captureSessionConfig({ id: 'free-long-ctx' }, [freeLongContextModel], true); + assert.ok(config); assert.strictEqual(config.contextTier, 'long_context'); }); }); @@ -1266,6 +1562,96 @@ suite('CopilotAgent', () => { } }); + test('client tool call contributor prefers the message sender when it provides the tool', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, instantiationService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); + const actions: (SessionAction | ChatAction)[] = []; + disposables.add(agent.onDidSessionProgress(signal => { + if (signal.kind === 'action') { + actions.push(signal.action); + } + })); + const activeClientToolSet = new ActiveClientToolSet(); + const sharedTool: ToolDefinition = { name: 'shared', description: 'Shared tool', inputSchema: { type: 'object', properties: {} } }; + activeClientToolSet.set('client-A', [sharedTool]); + activeClientToolSet.set('client-B', [sharedTool]); + const mockSession = new MockCopilotSession(); + const createdSession = createAgentSessionThroughAgent(agent, instantiationService, { + mockSession, + activeClientToolSet, + snapshot: { tools: activeClientToolSet.merged(), plugins: [], mcpServers: {} }, + }); + const agentSession = disposables.add(createdSession.session); + try { + await agentSession.initializeSession(); + agentSession.resetTurnState('turn-1', 'client-B'); + + mockSession.emit({ + type: 'tool.execution_start', + data: { toolCallId: 'tool-1', toolName: 'shared', arguments: {} }, + } as SessionEventPayload<'tool.execution_start'>); + + const toolStart = actions.find(action => action.type === ActionType.ChatToolCallStart); + assert.deepStrictEqual(toolStart?.type === ActionType.ChatToolCallStart ? toolStart.contributor : undefined, { + kind: ToolCallContributorKind.Client, + clientId: 'client-B', + }); + } finally { + await disposeAgent(agent); + } + }); + + test('client tool completion unblocks a pending permission request', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent, instantiationService, fileService } = createTestAgentContext(disposables, { environmentServiceRegistration: 'native', sessionDataService }); + disposables.add(registerPendingEditContentProvider(fileService)); + const createdSession = createAgentSessionThroughAgent(agent, instantiationService); + const agentSession = disposables.add(createdSession.session); + const pendingEditContentUri = new DeferredPromise<URI>(); + disposables.add(agent.onDidSessionProgress(signal => { + if (signal.kind === 'pending_confirmation') { + const uri = signal.state.edits?.items[0]?.after?.content.uri; + if (uri) { + pendingEditContentUri.complete(URI.parse(uri)); + } + } + })); + try { + await agentSession.initializeSession(); + const onPermissionRequest = createdSession.createOptions()?.onPermissionRequest; + assert.ok(onPermissionRequest); + + const permissionRequestResult = onPermissionRequest({ + kind: 'write', + toolCallId: 'tool-1', + canOfferSessionApproval: false, + diff: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1 @@\n+after', + fileName: URI.file('/workspace/file.txt').fsPath, + intention: 'write file', + newFileContents: 'after', + }, { sessionId: 'test-session-1' }); + const editContentUri = await pendingEditContentUri.p; + + agentSession.handleClientToolCallComplete('tool-1', { + success: false, + pastTenseMessage: 'Client tool failed', + content: [{ type: ToolResultContentType.Text, text: 'failed before approval' }], + error: { message: 'failed before approval' }, + }); + await timeout(0); + + assert.deepStrictEqual({ + permissionResult: await permissionRequestResult, + pendingEditContentExists: await fileService.exists(editContentUri), + }, { + permissionResult: { kind: 'approve-once' }, + pendingEditContentExists: false, + }); + } finally { + await disposeAgent(agent); + } + }); + test('listSessions only returns sessions with a database', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const ownedSession = AgentSession.uri('copilotcli', 'owned'); @@ -1306,6 +1692,34 @@ suite('CopilotAgent', () => { } }); + test('listSessions does not itself re-emit the workspaceless tag (AgentService overlays it centrally)', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'quick'); + const db = sessionDataService.openDatabase(session); + // A committed quick chat persists a scratch cwd AND the AH-owned + // workspace-less marker. The marker is surfaced onto `_meta` by + // `AgentService.listSessions` (see agentService.test.ts), not by the agent + // itself — the agent only reads it for the resume system prompt / cleanup. + await db.object.setMetadata('copilot.workingDirectory', URI.file('/scratch/quick').toString()); + await db.object.setMetadata('agentHost.workspaceless', 'true'); + db.dispose(); + + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([sdkSession('quick')]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + + assert.deepStrictEqual((await agent.listSessions()).map(withoutUndefinedProperties), [{ + session, + startTime: 1000, + modifiedTime: 2000, + summary: 'SDK quick', + workingDirectory: URI.file('/scratch/quick'), + }]); + } finally { + await disposeAgent(agent); + } + }); + test('getSessionMetadata reads one SDK session and stored metadata without listing sessions', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const session = AgentSession.uri('copilotcli', 'target'); @@ -1563,7 +1977,7 @@ suite('CopilotAgent', () => { // All discovery roots are returned, even if empty or non-existing // Workspace root is included because AGENTS.md was created - assert.strictEqual(discoveredDirectories.length, 13); + assert.strictEqual(discoveredDirectories.length, 14); const expectedUris = [ // workspace roots workspace.toString(), @@ -1578,6 +1992,7 @@ suite('CopilotAgent', () => { // user home roots URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), ]; @@ -1736,6 +2151,7 @@ suite('CopilotAgent', () => { // user home roots URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/agents' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.agents/skills' }).toString(), + URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/skills' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/instructions' }).toString(), URI.from({ scheme: Schemas.inMemory, path: '/mock-home/.copilot/hooks' }).toString(), ]; @@ -1868,7 +2284,7 @@ suite('CopilotAgent', () => { workingDirectory: URI.file('/workspace'), }); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.strictEqual(capturedConfig?.sessionId, 'prov-default-chat'); } finally { @@ -1977,11 +2393,14 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.ok(capturedConfig, 'SDK createSession should be called during provisional materialization'); const systemMessage = capturedConfig.systemMessage; - assert.deepStrictEqual(systemMessage, COPILOT_AGENT_HOST_SYSTEM_MESSAGE); + assert.deepStrictEqual(systemMessage, { + ...COPILOT_AGENT_HOST_SYSTEM_MESSAGE, + content: COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS, + }); if (!systemMessage || systemMessage.mode !== 'customize') { assert.fail('Expected customize-mode system message'); } @@ -2014,7 +2433,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.strictEqual(capturedConfig?.gitHubToken, 'gh-token-abc', 'createSession should receive the GitHub token at session level so the SDK can resolve a per-session GitHub identity'); @@ -2035,7 +2454,7 @@ suite('CopilotAgent', () => { const { agent, configurationService } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client }); try { await agent.authenticate('https://api.github.com', 'token'); - configurationService.updateRootConfig({ [AgentHostConfigKey.EnableCustomTerminalTool]: false }); + configurationService.updateRootConfig({ [CopilotCliConfigKey.EnableCustomTerminalTool]: false }); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'sdk-terminal-defaults'), @@ -2043,7 +2462,7 @@ suite('CopilotAgent', () => { }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); assert.deepStrictEqual(capturedConfig?.tools?.map(tool => tool.name), []); } finally { @@ -2067,8 +2486,7 @@ suite('CopilotAgent', () => { }, dispose() { }, }; - const sessions = (agent as unknown as { _sessions: Map<string, unknown> })._sessions; - sessions.set(sessionId, stub); + setDefaultSessionStub(agent, sessionId, stub); return { calls }; } @@ -2088,50 +2506,6 @@ suite('CopilotAgent', () => { } }); - test('routes a subagent session URI to its parent session entry', async () => { - // Regression: client-tool completions for tools running inside a - // subagent are dispatched against the subagent session URI by - // the renderer. The agent must resolve that to the parent - // session entry — only the parent owns the SDK session and the - // pending deferred for the tool call. - const agent = createTestAgent(disposables); - try { - const parentUri = AgentSession.uri('copilotcli', 'session-parent'); - const defaultChat = URI.parse(buildDefaultChatUri(parentUri)); - const { calls } = installStubSession(agent, AgentSession.id(parentUri)); - - const subagentUri = URI.parse(buildSubagentSessionUri(parentUri.toString(), 'tc-parent')); - const result: ToolCallResult = { success: true, pastTenseMessage: 'subagent tool done' }; - agent.onClientToolCallComplete(subagentUri, defaultChat, 'tc-inner', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); - } finally { - await disposeAgent(agent); - } - }); - - test('routes a nested subagent session URI (depth > 1) to the root session entry', async () => { - // Regression for depth > 1: a nested subagent URI like - // `copilot:/root/subagent/tc1/subagent/tc2` must walk all the way - // to the root session entry in `_sessions`, not stop at the - // intermediate parent `copilot:/root/subagent/tc1`. - const agent = createTestAgent(disposables); - try { - const rootUri = AgentSession.uri('copilotcli', 'session-root'); - const defaultChat = URI.parse(buildDefaultChatUri(rootUri)); - const { calls } = installStubSession(agent, AgentSession.id(rootUri)); - - const subagentUri = URI.parse(buildSubagentSessionUri(rootUri.toString(), 'tc-parent')); - const nestedUri = URI.parse(buildSubagentSessionUri(subagentUri.toString(), 'tc-nested')); - const result: ToolCallResult = { success: true, pastTenseMessage: 'nested done' }; - agent.onClientToolCallComplete(nestedUri, defaultChat, 'tc-inner', result); - - assert.deepStrictEqual(calls, [{ toolCallId: 'tc-inner', result }]); - } finally { - await disposeAgent(agent); - } - }); - test('is a no-op when no session entry exists for the resolved id', async () => { const agent = createTestAgent(disposables); try { @@ -2147,9 +2521,8 @@ suite('CopilotAgent', () => { test('routes a peer chat URI to its chat-session entry', async () => { // Client-tool completions for tools running inside an additional // (non-default) chat carry both the owning session URI and the - // chat channel URI. The agent must route by the chat URI to the - // `_chatSessions` entry, which is keyed by the chat URI string - // rather than a session id. + // chat channel URI. The agent must route by the chat URI to the peer + // chat hosted on the owning session's entry. const agent = createTestAgent(disposables); try { const sessionUri = AgentSession.uri('copilotcli', 'session-with-peer'); @@ -2159,7 +2532,7 @@ suite('CopilotAgent', () => { handleClientToolCallComplete(toolCallId: string, result: ToolCallResult) { calls.push({ toolCallId, result }); }, dispose() { }, }; - (agent as unknown as { _chatSessions: Map<string, unknown> })._chatSessions.set(chatUri.toString(), stub); + setPeerChatStub(agent, chatUri, stub); const result: ToolCallResult = { success: true, pastTenseMessage: 'peer done' }; agent.onClientToolCallComplete(sessionUri, chatUri, 'tc-peer', result); @@ -2170,9 +2543,9 @@ suite('CopilotAgent', () => { } }); test('routes the default chat URI to the session entry, not a chat-session', async () => { - // The default chat is not tracked in `_chatSessions`; passing its - // chat URI must still resolve via `_sessions` by the owning session - // id. This is the regression that previously hung the agent. + // The default chat is not a peer chat; passing its chat URI must + // still resolve via `_sessions` by the owning session id. This is + // the regression that previously hung the agent. const agent = createTestAgent(disposables); try { const sessionUri = AgentSession.uri('copilotcli', 'session-default'); @@ -2191,7 +2564,7 @@ suite('CopilotAgent', () => { suite('peer chat routing and lifecycle', () => { - /** Installs a stub peer chat into `_chatSessions` keyed by the chat URI. */ + /** Installs a stub peer chat into the owning session's entry, keyed by the chat URI. */ function installStubChat(agent: CopilotAgent, chatUri: URI, options?: { permissionOwner?: string; inputOwner?: string }) { const events: string[] = []; let disposed = false; @@ -2213,7 +2586,7 @@ suite('CopilotAgent', () => { handleClientToolCallComplete() { }, dispose() { disposed = true; }, }; - (agent as unknown as { _chatSessions: Map<string, unknown> })._chatSessions.set(chatUri.toString(), stub); + setPeerChatStub(agent, chatUri, stub); return { events, isDisposed: () => disposed }; } @@ -2264,99 +2637,738 @@ suite('CopilotAgent', () => { await agent.disposeSession(result.session); assert.strictEqual(chat.isDisposed(), true, 'peer chat should be disposed with its parent session'); - const chatSessions = (agent as unknown as { _chatSessions: Map<string, unknown> })._chatSessions; - assert.strictEqual(chatSessions.has(chatUri.toString()), false, 'peer chat entry should be removed'); + assert.strictEqual(hasPeerChatStub(agent, chatUri), false, 'peer chat entry should be removed'); } finally { await disposeAgent(agent); } }); - test('getChats returns the persisted peer chat catalog', async () => { + test('disposeChat deletes the SDK chat (via legacy fallback) and drops the live backing without rewriting copilot.chats', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { - const session = AgentSession.uri('copilotcli', 'session-getchats'); + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); const db = sessionDataService.openDatabase(session); + // A legacy session whose backing still lives in copilot.chats. await db.object.setMetadata('copilot.chats', JSON.stringify({ 'peer-a': { sdkSessionId: 'sdk-a' }, - 'peer-b': { sdkSessionId: 'sdk-b' }, })); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as { _chatBackings: Map<string, unknown> }; + // Materialize the backing first, mirroring the orchestrator's + // restore handing back the persisted providerData. + await agent.materializeChat(chatUri, JSON.stringify({ sdkSessionId: 'sdk-a' })); - const chats = await agent.getChats(session); + await agent.chats.disposeChat(chatUri); - assert.deepStrictEqual( - chats.map(c => c.toString()).sort(), - [buildChatUri(session, 'peer-a'), buildChatUri(session, 'peer-b')].sort(), - ); + const remaining = await db.object.getMetadata('copilot.chats'); + assert.deepStrictEqual({ + backingCleared: internals._chatBackings.has(chatUri.toString()), + deleted: client.deletedSessionIds, + // The agent no longer owns the durable catalog, so it leaves + // the legacy blob untouched (orchestrator drops the entry). + legacyUntouched: remaining ? JSON.parse(remaining) : {}, + }, { + backingCleared: false, + deleted: ['sdk-a'], + legacyUntouched: { 'peer-a': { sdkSessionId: 'sdk-a' } }, + }); } finally { await disposeAgent(agent); } }); + }); + + suite('peer chat create / fork / model+agent / restore round-trip', () => { + + /** Internal surface the multi-chat tests reach into to stub the SDK/agent-session seam. */ + type ChatInternals = { + _chatBackings: Map<string, { sdkSessionId: string; model?: ModelSelection }>; + _sessions: Map<string, CopilotSessionEntry>; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, channelUri?: URI) => CopilotAgentSession; + _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string, targetDbDir: URI) => Promise<string>; + _resolveAgentName: (snapshot: IActiveClientSnapshot, agent: AgentSelection) => string | undefined; + }; + + interface IFakeChatRecorder { + initialized: boolean; + disposed: boolean; + readonly remapCalls: ReadonlyMap<string, string>[]; + readonly sends: { prompt: string; turnId: string | undefined; mode: unknown; senderClientId: string | undefined }[]; + readonly resets: { turnId: string; senderClientId: string | undefined }[]; + readonly modelCalls: { id: string }[]; + readonly agentCalls: (string | undefined)[]; + } + + /** + * Builds a fake {@link CopilotAgentSession} that records the calls + * `createChat`/`sendMessage`/`changeModel`/`changeAgent` route to a peer + * chat, so tests can drive the real agent methods while stubbing only the + * SDK-backed chat. The `_createAgentSession` seam returns this. + */ + function makeFakeChatSession(sessionUri: URI, sdkSessionId: string, getMessages?: () => Promise<readonly Turn[]>, owned?: IDisposable): { rec: IFakeChatRecorder; fake: CopilotAgentSession } { + const rec: IFakeChatRecorder = { + initialized: false, + disposed: false, + remapCalls: [], + sends: [], + resets: [], + modelCalls: [], + agentCalls: [], + }; + const fake = { + sessionUri, + sessionId: sdkSessionId, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async initializeSession(): Promise<void> { rec.initialized = true; }, + async remapTurnIds(mapping: ReadonlyMap<string, string>): Promise<void> { rec.remapCalls.push(mapping); }, + async send(prompt: string, _attachments: unknown, turnId: string | undefined, mode: unknown, senderClientId: string | undefined): Promise<void> { + rec.sends.push({ prompt, turnId, mode, senderClientId }); + }, + resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, + async setModel(id: string): Promise<void> { rec.modelCalls.push({ id }); }, + async setAgent(name: string | undefined): Promise<void> { rec.agentCalls.push(name); }, + handleClientToolCallComplete(): void { }, + async getNextTurnEventId(): Promise<string | undefined> { return undefined; }, + getMessages: getMessages ?? (async () => []), + dispose(): void { rec.disposed = true; owned?.dispose(); }, + } as unknown as CopilotAgentSession; + return { rec, fake }; + } - test('getChats drops corrupted or invalid persisted entries', async () => { + test('createChat materializes a peer chat, records its backing, and returns providerData (no copilot.chats write)', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); try { - const session = AgentSession.uri('copilotcli', 'session-getchats-invalid'); - const db = sessionDataService.openDatabase(session); - await db.object.setMetadata('copilot.chats', JSON.stringify({ - 'peer-ok': { sdkSessionId: 'sdk-ok' }, - 'peer-null': null, - 'peer-missing-id': { model: { id: 'm' } }, - 'peer-nonstring-id': { sdkSessionId: 42 }, - 'peer-empty-id': { sdkSessionId: '' }, - })); + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'create-peer'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); - const chats = await agent.getChats(session); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + let captured: CopilotSessionLaunchPlan | undefined; + let capturedChannel: URI | undefined; + let rec: IFakeChatRecorder | undefined; + internals._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + captured = launchPlan; + capturedChannel = channelUri; + const built = makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager); + rec = built.rec; + return built.fake; + }; - assert.deepStrictEqual( - chats.map(c => c.toString()), - [buildChatUri(session, 'peer-ok')], - ); + const model: ModelSelection = { id: 'gpt-x' }; + const result = await agent.chats.createChat(chatUri, { model }); + + const db = sessionDataService.openDatabase(session); + const raw = await db.object.getMetadata('copilot.chats'); + assert.deepStrictEqual({ + tracked: hasPeerChatStub(agent, chatUri), + initialized: rec?.initialized, + channel: capturedChannel?.toString(), + kind: captured?.kind, + backing: internals._chatBackings.get(chatUri.toString()), + providerData: result ? JSON.parse(result.providerData!) : undefined, + // The orchestrator now owns the durable catalog; the agent no + // longer writes its private `copilot.chats` metadata. + legacyCatalogWritten: raw !== undefined, + }, { + tracked: true, + initialized: true, + channel: chatUri.toString(), + kind: 'create', + backing: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, + providerData: { sdkSessionId: captured!.sessionId, model: { id: 'gpt-x' } }, + legacyCatalogWritten: false, + }); } finally { await disposeAgent(agent); } }); - test('disposeChat removes the persisted entry and deletes its SDK conversation', async () => { + test('createChat is a no-op for the default chat URI', async () => { const sessionDataService = disposables.add(new TestSessionDataService()); - const client = new TestCopilotClient([]); - const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); try { await agent.authenticate('https://api.github.com', 'token'); - const session = AgentSession.uri('copilotcli', 'session-dispose-chat'); - const db = sessionDataService.openDatabase(session); - await db.object.setMetadata('copilot.chats', JSON.stringify({ - 'peer-a': { sdkSessionId: 'sdk-a' }, - })); - const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const session = AgentSession.uri('copilotcli', 'create-default'); + const internals = agent as unknown as ChatInternals; + internals._createAgentSession = () => { throw new Error('_createAgentSession must not be called for the default chat'); }; - await agent.disposeChat(session, chatUri); + await agent.chats.createChat(URI.parse(buildDefaultChatUri(session)), {}); - const remaining = await db.object.getMetadata('copilot.chats'); assert.deepStrictEqual({ - remaining: remaining ? JSON.parse(remaining) : {}, - deleted: client.deletedSessionIds, + tracked: peerChatCount(agent), }, { - remaining: {}, - deleted: ['sdk-a'], + tracked: 0, }); } finally { await disposeAgent(agent); } }); - }); - // Regression for the #319516 incident: a window reload reconnects with a - // NEW clientId but an identical tool list. The cached SDK session's - // staleness check (`ActiveClient.requiresRestart`) must NOT treat a - // clientId-only change as a config change — otherwise either the session - // is needlessly restarted, or (the actual bug) the cached session is - // reused while the live clientId is never updated, so subsequent client - // tool calls are stamped with the dead window's id and hang forever. - suite('client tool refresh on reload (#319516)', () => { - /** Minimal structural view of the agent's private per-session ActiveClient. */ - type TestActiveClient = { + test('createChat forks the source chat into a new peer chat and returns the forked chat providerData', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'fork-peer'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + + const internals = agent as unknown as ChatInternals; + // Install the default chat as the fork source so resolution stays + // in-memory (no SDK resume). + const source = makeFakeChatSession(session, 'source-sdk'); + setDefaultSessionStub(agent, AgentSession.id(session), source.fake); + + // Stub the SDK/fs fork seam: assert the inputs and hand back a + // deterministic forked chat id. + let forkArgs: { sourceEntry: unknown; turnId: string } | undefined; + internals._forkSdkChat = async (_client, sourceEntry, turnId) => { + forkArgs = { sourceEntry, turnId }; + return 'forked-sdk-id'; + }; + let captured: CopilotSessionLaunchPlan | undefined; + internals._createAgentSession = (launchPlan) => { + captured = launchPlan; + return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; + }; + + const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); + const result = await agent.chats.fork(chatUri, { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }); + + const db = sessionDataService.openDatabase(session); + const raw = await db.object.getMetadata('copilot.chats'); + assert.deepStrictEqual({ + sourceIsDefaultSession: forkArgs?.sourceEntry === source.fake, + forkedTurnId: forkArgs?.turnId, + launchKind: captured?.kind, + launchSessionId: captured?.sessionId, + tracked: hasPeerChatStub(agent, chatUri), + backing: internals._chatBackings.get(chatUri.toString()), + providerData: result ? JSON.parse(result.providerData!) : undefined, + legacyCatalogWritten: raw !== undefined, + }, { + sourceIsDefaultSession: true, + forkedTurnId: 't1', + launchKind: 'resume', + launchSessionId: 'forked-sdk-id', + tracked: true, + backing: { sdkSessionId: 'forked-sdk-id' }, + providerData: { sdkSessionId: 'forked-sdk-id' }, + legacyCatalogWritten: false, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a turn to the targeted peer chat only', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'route-msg'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const chatB = URI.parse(buildChatUri(session, 'peer-b')); + const a = makeFakeChatSession(session, 'sdk-a'); + const b = makeFakeChatSession(session, 'sdk-b'); + setPeerChatStub(agent, chatA, a.fake); + setPeerChatStub(agent, chatB, b.fake); + + await agent.chats.sendMessage(chatA, 'hello-a', undefined, 'turn-a', 'client-1'); + + assert.deepStrictEqual({ + aSends: a.rec.sends.map(s => ({ prompt: s.prompt, turnId: s.turnId, senderClientId: s.senderClientId })), + aResets: a.rec.resets, + bSends: b.rec.sends, + bResets: b.rec.resets, + }, { + aSends: [{ prompt: 'hello-a', turnId: 'turn-a', senderClientId: 'client-1' }], + aResets: [{ turnId: 'turn-a', senderClientId: 'client-1' }], + bSends: [], + bResets: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage throws for a peer chat with no backing chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'route-ghost'); + const chatUri = URI.parse(buildChatUri(session, 'ghost')); + await assert.rejects( + () => agent.chats.sendMessage(chatUri, 'hi'), + /unknown chat/, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('changeModel applies to the targeted peer chat only', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'model-route'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const chatB = URI.parse(buildChatUri(session, 'peer-b')); + const a = makeFakeChatSession(session, 'sdk-a'); + const b = makeFakeChatSession(session, 'sdk-b'); + setPeerChatStub(agent, chatA, a.fake); + setPeerChatStub(agent, chatB, b.fake); + + await agent.chats.changeModel(chatA, { id: 'model-x' }); + + assert.deepStrictEqual({ + aModels: a.rec.modelCalls.map(m => m.id), + bModels: b.rec.modelCalls.map(m => m.id), + }, { + aModels: ['model-x'], + bModels: [], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('changeAgent resolves and applies the agent to the targeted peer chat, and clears it with undefined', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'agent-route'); + const chatA = URI.parse(buildChatUri(session, 'peer-a')); + const a = makeFakeChatSession(session, 'sdk-a'); + const internals = agent as unknown as ChatInternals; + setPeerChatStub(agent, chatA, a.fake); + internals._resolveAgentName = (_snapshot, selection) => selection.uri === 'agent://x' ? 'Resolved Agent' : undefined; + + await agent.chats.changeAgent(chatA, { uri: 'agent://x' }); + await agent.chats.changeAgent(chatA, undefined); + + assert.deepStrictEqual(a.rec.agentCalls, ['Resolved Agent', undefined]); + } finally { + await disposeAgent(agent); + } + }); + + test('round-trips peer chats through providerData + materializeChat and resumes per-chat history after a restart', async () => { + // A single session data service is shared across the two agent + // instances to model the on-disk store surviving a process restart. + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 'restore-rt'); + const created: Record<string, string> = {}; + const providerData: Record<string, string> = {}; + + // ---- process #1: create two peer chats, capturing the opaque + // providerData blob the orchestrator would persist for each ---- + const agent1 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent1.authenticate('https://api.github.com', 'token'); + await agent1.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals1 = agent1 as unknown as ChatInternals; + internals1._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + if (channelUri) { + created[channelUri.authority] = launchPlan.sessionId; + } + return makeFakeChatSession(session, launchPlan.sessionId, undefined, launchPlan.shellManager).fake; + }; + const peerAUri = URI.parse(buildChatUri(session, 'peer-a')); + const peerBUri = URI.parse(buildChatUri(session, 'peer-b')); + const resA = await agent1.chats.createChat(peerAUri, {}); + const resB = await agent1.chats.createChat(peerBUri, {}); + providerData['peer-a'] = resA!.providerData!; + providerData['peer-b'] = resB!.providerData!; + } finally { + await disposeAgent(agent1); + } + + // ---- process #2: fresh agent, empty in-memory state ---- + const agent2 = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent2.authenticate('https://api.github.com', 'token'); + // The orchestrator re-creates the (provisional) parent session on + // restore; this seeds the working directory the peer-chat resume + // path needs. + await agent2.createSession({ session, workingDirectory: URI.file('/workspace') }); + + const internals2 = agent2 as unknown as ChatInternals; + const peerA = URI.parse(buildChatUri(session, 'peer-a')); + const peerB = URI.parse(buildChatUri(session, 'peer-b')); + // The orchestrator hands each persisted blob back to the agent. + await agent2.materializeChat(peerA, providerData['peer-a']); + await agent2.materializeChat(peerB, providerData['peer-b']); + + const peerAHistory: readonly Turn[] = [{ id: 'turn-1' } as unknown as Turn]; + let resumed: CopilotSessionLaunchPlan | undefined; + internals2._createAgentSession = (launchPlan) => { + resumed = launchPlan; + return makeFakeChatSession(session, launchPlan.sessionId, async () => peerAHistory, launchPlan.shellManager).fake; + }; + + await agent2.chats.sendMessage(peerA, 'after restart'); + const history = await getPeerChatStub(agent2, peerA)!.getMessages(); + + assert.deepStrictEqual({ + materializedBackings: [internals2._chatBackings.get(peerA.toString()), internals2._chatBackings.get(peerB.toString())], + resumeKind: resumed?.kind, + resumeSessionId: resumed?.sessionId, + expectedSessionId: created['peer-a'], + historyLen: history.length, + tracked: hasPeerChatStub(agent2, peerA), + }, { + materializedBackings: [{ sdkSessionId: created['peer-a'] }, { sdkSessionId: created['peer-b'] }], + resumeKind: 'resume', + resumeSessionId: created['peer-a'], + expectedSessionId: created['peer-a'], + historyLen: 1, + tracked: true, + }); + } finally { + await disposeAgent(agent2); + } + }); + + test('materializeChat falls back to the legacy copilot.chats catalog when providerData is undefined', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'legacy-materialize'); + const db = sessionDataService.openDatabase(session); + await db.object.setMetadata('copilot.chats', JSON.stringify({ + 'peer-a': { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, + })); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + + // undefined blob -> agent recovers the backing from its own catalog. + await agent.materializeChat(chatUri, undefined); + // A corrupt blob is dropped (no backing recorded). + const corruptUri = URI.parse(buildChatUri(session, 'peer-corrupt')); + await agent.materializeChat(corruptUri, 'not json'); + + assert.deepStrictEqual({ + legacy: internals._chatBackings.get(chatUri.toString()), + corrupt: internals._chatBackings.has(corruptUri.toString()), + }, { + legacy: { sdkSessionId: 'legacy-sdk', model: { id: 'gpt-legacy' } }, + corrupt: false, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('changeModel on a peer chat refreshes its backing and fires onDidChangeChatData', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'model-blob'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const internals = agent as unknown as ChatInternals; + setPeerChatStub(agent, chatUri, makeFakeChatSession(session, 'sdk-a').fake); + internals._chatBackings.set(chatUri.toString(), { sdkSessionId: 'sdk-a' }); + + const events: { chat: string; providerData: unknown }[] = []; + disposables.add(agent.onDidChangeChatData(e => events.push({ chat: e.chat.toString(), providerData: JSON.parse(e.providerData) }))); + + await agent.chats.changeModel(chatUri, { id: 'model-x' }); + + assert.deepStrictEqual({ + backing: internals._chatBackings.get(chatUri.toString()), + events, + }, { + backing: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } }, + events: [{ chat: chatUri.toString(), providerData: { sdkSessionId: 'sdk-a', model: { id: 'model-x' } } }], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + + // The chat-addressed surface ({@link IAgent.chats}) is a thin adapter over + // the legacy `(session, chat?)` methods. These tests verify it resolves a + // single chat URI back to the right `(session, chat)` target — a peer + // `ahp-chat` URI keeps its own identity, a session URI maps to the + // session's default chat — and then delegates to the legacy implementation. + suite('chat surface (IAgentChats)', () => { + + type ConvInternals = { + _sessions: Map<string, CopilotSessionEntry>; + _provisionalSessions: Map<string, unknown>; + _createAgentSession: (launchPlan: CopilotSessionLaunchPlan, dir: URI | undefined, activeClient: unknown, channelUri?: URI) => CopilotAgentSession; + }; + + interface IFakeConvRecorder { + readonly sends: { prompt: string; turnId: string | undefined; senderClientId: string | undefined }[]; + readonly resets: { turnId: string; senderClientId: string | undefined }[]; + readonly modelCalls: string[]; + readonly agentCalls: (string | undefined)[]; + aborted: number; + disposed: boolean; + } + + /** + * Installs a recording fake {@link CopilotAgentSession} as a peer chat + * (hosted on the owning session's entry) or as a session's default chat, + * keyed as the real agent would, so the chat adapter can drive + * the real legacy methods. + */ + function installFake(agent: CopilotAgent, key: string, target: 'chat' | 'session', sessionUri: URI): IFakeConvRecorder { + const rec: IFakeConvRecorder = { sends: [], resets: [], modelCalls: [], agentCalls: [], aborted: 0, disposed: false }; + const fake = { + sessionUri, + sessionId: `sdk-${key}`, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async send(prompt: string, _attachments: unknown, turnId: string | undefined, _mode: unknown, senderClientId: string | undefined): Promise<void> { + rec.sends.push({ prompt, turnId, senderClientId }); + }, + resetTurnState(turnId: string, senderClientId: string | undefined): void { rec.resets.push({ turnId, senderClientId }); }, + async setModel(id: string): Promise<void> { rec.modelCalls.push(id); }, + async setAgent(name: string | undefined): Promise<void> { rec.agentCalls.push(name); }, + async abort(): Promise<void> { rec.aborted++; }, + async getMessages(): Promise<readonly Turn[]> { return [{ id: `turn-${key}` } as unknown as Turn]; }, + handleClientToolCallComplete(): void { }, + dispose(): void { rec.disposed = true; }, + } as unknown as CopilotAgentSession; + if (target === 'chat') { + setPeerChatStub(agent, URI.parse(key), fake); + } else { + setDefaultSessionStub(agent, key, fake); + } + return rec; + } + + /** + * Stubs `_createAgentSession` (the SDK-backed launch seam) so peer-chat + * creation/fork stays in-memory: it returns a minimal fake whose + * `sessionId` echoes the launch plan, which is what `createChat` records + * as the chat's backing. + */ + function stubBackingSession(agent: CopilotAgent): void { + (agent as unknown as ConvInternals)._createAgentSession = (launchPlan, _dir, _ac, channelUri) => { + return { + sessionUri: channelUri, + sessionId: launchPlan.sessionId, + appliedSnapshot: { tools: [], plugins: [], mcpServers: {} } satisfies IActiveClientSnapshot, + async initializeSession(): Promise<void> { }, + async remapTurnIds(): Promise<void> { }, + async getMessages(): Promise<readonly Turn[]> { return []; }, + handleClientToolCallComplete(): void { }, + dispose(): void { launchPlan.shellManager?.dispose(); }, + } as unknown as CopilotAgentSession; + }; + } + + test('createSession mints a provisional session', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'scope-create'); + const result = await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals = agent as unknown as ConvInternals; + assert.deepStrictEqual({ + session: result.session.toString(), + provisional: internals._provisionalSessions.has(AgentSession.id(session)), + }, { + session: session.toString(), + provisional: true, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('disposeSession tears down a provisional session', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'scope-dispose'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const internals = agent as unknown as ConvInternals; + assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), true); + + await agent.disposeSession(session); + + assert.strictEqual(internals._provisionalSessions.has(AgentSession.id(session)), false); + } finally { + await disposeAgent(agent); + } + }); + + test('createChat creates a peer chat and returns its providerData', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-create'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + + stubBackingSession(agent); + const result = await agent.chats.createChat(chatUri, { model: { id: 'gpt-x' } }); + + assert.deepStrictEqual({ + tracked: hasPeerChatStub(agent, chatUri), + hasProviderData: !!(result && result.providerData), + model: result ? (JSON.parse(result.providerData!) as { model?: ModelSelection }).model : undefined, + }, { + tracked: true, + hasProviderData: true, + model: { id: 'gpt-x' }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('fork delegates to createChat with the fork source', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: new TestCopilotClient([]) }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-fork'); + await agent.createSession({ session, workingDirectory: URI.file('/workspace') }); + installFake(agent, AgentSession.id(session), 'session', session); + + const forkArgs: { turnId: string }[] = []; + (agent as unknown as { _forkSdkChat: (client: unknown, sourceEntry: unknown, turnId: string) => Promise<string> })._forkSdkChat = async (_c, _s, turnId) => { + forkArgs.push({ turnId }); + return 'forked-sdk-id'; + }; + stubBackingSession(agent); + + const chatUri = URI.parse(buildChatUri(session, 'peer-fork')); + const source: IAgentCreateChatForkSource = { source: URI.parse(buildDefaultChatUri(session)), turnId: 't1' }; + const result = await agent.chats.fork(chatUri, source); + + assert.deepStrictEqual({ + forkArgs, + tracked: hasPeerChatStub(agent, chatUri), + providerData: result ? JSON.parse(result.providerData!) : undefined, + }, { + forkArgs: [{ turnId: 't1' }], + tracked: true, + providerData: { sdkSessionId: 'forked-sdk-id' }, + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a peer chat URI to the peer chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-send-peer'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + + await agent.chats.sendMessage(chatUri, 'hello-peer', undefined, 'turn-1', 'client-1'); + + assert.deepStrictEqual({ + sends: rec.sends, + resets: rec.resets, + }, { + sends: [{ prompt: 'hello-peer', turnId: 'turn-1', senderClientId: 'client-1' }], + resets: [{ turnId: 'turn-1', senderClientId: 'client-1' }], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('sendMessage routes a scope (session) URI to the default chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-send-default'); + const rec = installFake(agent, AgentSession.id(session), 'session', session); + + await agent.chats.sendMessage(defaultChatUri(session), 'hello-default', undefined, 'turn-d', 'client-d'); + + assert.deepStrictEqual(rec.sends, [{ prompt: 'hello-default', turnId: 'turn-d', senderClientId: 'client-d' }]); + } finally { + await disposeAgent(agent); + } + }); + + test('abort, changeModel, and changeAgent route a peer URI to the peer chat', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-ops'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + (agent as unknown as { _resolveAgentName: (snap: IActiveClientSnapshot, a: AgentSelection) => string | undefined })._resolveAgentName = (_snap, sel) => sel.uri === 'agent://x' ? 'Resolved Agent' : undefined; + + await agent.chats.abort(chatUri); + await agent.chats.changeModel(chatUri, { id: 'model-x' }); + await agent.chats.changeAgent(chatUri, { uri: 'agent://x' }); + await agent.chats.changeAgent(chatUri, undefined); + + assert.deepStrictEqual({ + aborted: rec.aborted, + modelCalls: rec.modelCalls, + agentCalls: rec.agentCalls, + }, { + aborted: 1, + modelCalls: ['model-x'], + agentCalls: ['Resolved Agent', undefined], + }); + } finally { + await disposeAgent(agent); + } + }); + + test('getMessages returns the peer chat history', async () => { + const agent = createTestAgent(disposables); + try { + const session = AgentSession.uri('copilotcli', 'conv-history'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + installFake(agent, chatUri.toString(), 'chat', session); + + const turns = await agent.chats.getMessages(chatUri); + + assert.deepStrictEqual(turns.map(t => t.id), [`turn-${chatUri.toString()}`]); + } finally { + await disposeAgent(agent); + } + }); + + test('disposeChat disposes the peer chat', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const client = new TestCopilotClient([]); + const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); + try { + await agent.authenticate('https://api.github.com', 'token'); + const session = AgentSession.uri('copilotcli', 'conv-dispose'); + const chatUri = URI.parse(buildChatUri(session, 'peer-a')); + const rec = installFake(agent, chatUri.toString(), 'chat', session); + + await agent.chats.disposeChat(chatUri); + + assert.deepStrictEqual({ + disposed: rec.disposed, + tracked: hasPeerChatStub(agent, chatUri), + deleted: client.deletedSessionIds, + }, { + disposed: true, + tracked: false, + deleted: ['sdk-' + chatUri.toString()], + }); + } finally { + await disposeAgent(agent); + } + }); + }); + + // Regression for the #319516 incident: a window reload reconnects with a + // NEW clientId but an identical tool list. The cached SDK session's + // staleness check (`ActiveClient.requiresRestart`) must NOT treat a + // clientId-only change as a config change — otherwise either the session + // is needlessly restarted, or (the actual bug) the cached session is + // reused while the live clientId is never updated, so subsequent client + // tool calls are stamped with the dead window's id and hang forever. + suite('client tool refresh on reload (#319516)', () => { + /** Minimal structural view of the agent's private per-session ActiveClient. */ + type TestActiveClient = { readonly toolSet: { ownerOf(toolName: string): string | undefined }; snapshot(): Promise<IActiveClientSnapshot>; requiresRestart(snap: IActiveClientSnapshot): Promise<boolean>; @@ -2645,6 +3657,75 @@ suite('CopilotAgent', () => { await disposeAgent(agent); } }); + + test('does not restore a persisted custom agent that is absent from the current plugin snapshot', async () => { + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 's1'); + const dbRef = sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata('copilot.agent', JSON.stringify({ uri: 'file:///old-client/data.md' })); + } finally { + dbRef.dispose(); + } + + const client = new TestCopilotClient([sdkSession('s1', '/workspace')]); + const resumeAgents: (string | undefined)[] = []; + client.resumeSession = async (_sessionId, options) => { + resumeAgents.push(options?.agent); + return new MockCopilotSession() as unknown as CopilotSession; + }; + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await internals._resumeSession('s1'); + assert.deepStrictEqual(resumeAgents, [undefined]); + } finally { + await disposeAgent(agent); + } + }); + + test('retries resume without a custom agent when the SDK reports the stored agent is missing', async () => { + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const repo = URI.from({ scheme: Schemas.inMemory, path: '/repo' }); + const dataAgent = URI.joinPath(repo, '.github', 'agents', 'data.md'); + await fileService.writeFile(dataAgent, VSBuffer.fromString('---\nname: Data\ndescription: data queries\n---\nbody')); + + const sessionDataService = disposables.add(new TestSessionDataService()); + const session = AgentSession.uri('copilotcli', 's1'); + const dbRef = sessionDataService.openDatabase(session); + try { + await dbRef.object.setMetadata('copilot.workingDirectory', repo.toString()); + await dbRef.object.setMetadata('copilot.agent', JSON.stringify({ uri: dataAgent.toString() })); + } finally { + dbRef.dispose(); + } + + const client = new TestCopilotClient([sdkSession('s1')]); + const resumeAgents: (string | undefined)[] = []; + client.resumeSession = async (_sessionId, options) => { + resumeAgents.push(options?.agent); + if (resumeAgents.length === 1) { + throw new TestSdkError(`Request session.resume failed with message: Custom agent 'Data' not found`, -32603); + } + return new MockCopilotSession() as unknown as CopilotSession; + }; + client.createSession = async () => { + throw new Error('createSession should not be called'); + }; + + const agent = createTestAgent(disposables, { copilotClient: client, useRealResumePath: true, sessionDataService, fileService }); + const internals = agent as unknown as AgentInternals; + try { + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); + await internals._resumeSession('s1'); + assert.deepStrictEqual(resumeAgents, ['Data', undefined]); + } finally { + await disposeAgent(agent); + } + }); }); suite('worktree announcement', () => { @@ -2742,7 +3823,7 @@ suite('CopilotAgent', () => { signals.push(s); })); - await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(session), 'hello'); assert.strictEqual(sendCalls, 1, 'underlying SDK send must still be called'); const markdownSignals = signals.filter((s): s is IAgentActionSignal => @@ -2760,7 +3841,7 @@ suite('CopilotAgent', () => { // 3. Live path is one-shot: a second sendMessage must not re-emit. signals.length = 0; - await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'follow-up'); + await agent.chats.sendMessage(defaultChatUri(session), 'follow-up'); const reemittedMarkdown = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -2818,7 +3899,7 @@ suite('CopilotAgent', () => { disposables.add(agent.onDidSessionProgress(s => { signals.push(s); })); - await agent.sendMessage(session, URI.parse(buildDefaultChatUri(session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(session), 'hello'); const markdownSignals = signals.filter(s => s.kind === 'action' && ( (s.action.type === ActionType.ChatResponsePart && s.action.part.kind === ResponsePartKind.Markdown) || @@ -2835,6 +3916,46 @@ suite('CopilotAgent', () => { } }); + test('prepends the worktreeBranchPrefix ahead of agents/ and strips it from the worktree folder name', async () => { + const sessionId = 'wt-prefix-session'; + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-prefix'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const copilotApiService = new TestCopilotApiService(); + copilotApiService.response = 'add-feature'; + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + copilotApiService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + + const workingDir = await agent.resolveWorktreeForTest({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', branch: 'main', worktreeBranchPrefix: 'users/alice/' }, + }, sessionId, 'Add feature'); + + // The branch keeps the user's prefix ahead of `agents/`, while + // the worktree folder name strips both prefixes. + const expectedWorktree = URI.joinPath(getCopilotWorktreesRoot(repositoryRoot), 'add-feature'); + assert.deepStrictEqual({ + branchName: gitService.addedWorktrees[0]?.branchName, + worktree: workingDir?.toString(), + }, { + branchName: 'users/alice/agents/add-feature', + worktree: expectedWorktree.toString(), + }); + } finally { + await disposeAgent(agent); + } + }); + test('resolveSessionConfig does not offer or default to worktree isolation when the repository has no commits', async () => { const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'empty-repo-config'); await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); @@ -2868,6 +3989,52 @@ suite('CopilotAgent', () => { } }); + test('resolveSessionConfig carries worktreeBranchPrefix across both isolations so it survives toggles', async () => { + const repositoryRoot = URI.joinPath(URI.file(tmpDir), 'repo-branch-prefix'); + await fs.mkdir(repositoryRoot.fsPath, { recursive: true }); + + const gitService = new TestAgentHostGitService(); + gitService.repositoryRoot = repositoryRoot; + + const agent = createTestAgent(disposables, { + sessionDataService: disposables.add(new TestSessionDataService()), + copilotClient: new TestCopilotClient([]), + gitService, + }) as TestableCopilotAgent; + + try { + await agent.authenticate('https://api.github.com', 'token'); + + // The prefix is declared and echoed back for both isolations + // (like `branch`), so a worktree → folder → worktree toggle keeps + // the client-seeded value in the resolved config rather than + // dropping it on the intermediate folder resolve. + const worktree = await agent.resolveSessionConfig({ + workingDirectory: repositoryRoot, + config: { isolation: 'worktree', worktreeBranchPrefix: 'users/alice/' }, + }); + + const folder = await agent.resolveSessionConfig({ + workingDirectory: repositoryRoot, + config: { isolation: 'folder', worktreeBranchPrefix: 'users/alice/' }, + }); + + assert.deepStrictEqual({ + worktreeHasProperty: !!worktree.schema.properties?.[SessionConfigKey.WorktreeBranchPrefix], + worktreeValue: worktree.values[SessionConfigKey.WorktreeBranchPrefix], + folderHasProperty: !!folder.schema.properties?.[SessionConfigKey.WorktreeBranchPrefix], + folderValue: folder.values[SessionConfigKey.WorktreeBranchPrefix], + }, { + worktreeHasProperty: true, + worktreeValue: 'users/alice/', + folderHasProperty: true, + folderValue: 'users/alice/', + }); + } finally { + await disposeAgent(agent); + } + }); + test('onArchivedChanged removes the worktree on archive and recreates it on unarchive', async () => { const sessionId = 'archive-cleanup-session'; const session = AgentSession.uri('copilotcli', sessionId); @@ -3122,7 +4289,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); const result = await agent.createSession({ session: AgentSession.uri('copilotcli', 'anchor-session'), workingDirectory: originalFolder }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); } finally { await disposeAgent(agent); } @@ -3185,7 +4352,7 @@ suite('CopilotAgent', () => { activeClient: { clientId: 'c1', tools: [] }, }); assert.strictEqual(result.provisional, true); - await agent.sendMessage(result.session, URI.parse(buildDefaultChatUri(result.session)), 'hello'); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); } finally { await disposeAgent(agent); } @@ -3207,4 +4374,157 @@ suite('CopilotAgent', () => { }); }); + + suite('custom agent worktree translation', () => { + + // The new methods under test are private; reach in the same way the + // surrounding suites do (e.g. `customization anchoring`). + type AgentInternals = { + _getAlternativeAgentForWorktree(provisional: unknown, workingDirectory: URI | undefined): AgentSelection | undefined; + _resolveAgentWhenMaterializing(provisional: unknown, snapshot: IActiveClientSnapshot, workingDirectory: URI | undefined): Promise<{ agent: AgentSelection; name: string } | undefined>; + _resolveAgentName(snapshot: IActiveClientSnapshot, agent: AgentSelection): string | undefined; + _resolveSessionWorkingDirectory(config: unknown, sessionId: string, prompt?: string): Promise<URI | undefined>; + _createAgentSession(launchPlan: CopilotSessionLaunchPlan, customizationDirectory: URI | undefined, activeClient: unknown, channelUri?: URI): CopilotAgentSession; + _readSessionMetadata(session: URI): Promise<{ agent?: AgentSelection }>; + }; + + const repo = URI.file('/repo'); + const worktree = URI.joinPath(URI.file('/repo.worktrees'), 'agents-x'); + const repoAgentUri = URI.joinPath(repo, '.github', 'agents', 'agent.md').toString(); + const worktreeAgentUri = URI.joinPath(worktree, '.github', 'agents', 'agent.md').toString(); + const emptySnapshot: IActiveClientSnapshot = { tools: [], plugins: [], mcpServers: {} }; + + function provisional(workingDirectory: URI | undefined, agent: AgentSelection | undefined): unknown { + const sessionUri = AgentSession.uri('copilotcli', 'prov-agent'); + return { sessionId: AgentSession.id(sessionUri), sessionUri, workingDirectory, model: undefined, agent, project: undefined }; + } + + test('_getAlternativeAgentForWorktree rewrites a repo agent path onto the worktree', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + assert.deepStrictEqual( + internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), worktree), + { uri: worktreeAgentUri }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_getAlternativeAgentForWorktree returns undefined when there is nothing to translate', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + const outsideRepoAgent: AgentSelection = { uri: URI.file('/home/me/.copilot/agents/agent.md').toString() }; + assert.deepStrictEqual( + { + noAgent: internals._getAlternativeAgentForWorktree(provisional(repo, undefined), worktree), + folderIsolation: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), undefined), + sameWorkingDirectory: internals._getAlternativeAgentForWorktree(provisional(repo, { uri: repoAgentUri }), repo), + agentOutsideRepo: internals._getAlternativeAgentForWorktree(provisional(repo, outsideRepoAgent), worktree), + }, + { + noAgent: undefined, + folderIsolation: undefined, + sameWorkingDirectory: undefined, + agentOutsideRepo: undefined, + }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_resolveAgentWhenMaterializing keeps the original agent for folder isolation (no worktree)', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + internals._resolveAgentName = (_snapshot, selection) => selection.uri === repoAgentUri ? 'Repo Agent' : undefined; + // Folder isolation: the resolved working directory equals the + // user-picked folder, so there is no worktree copy to translate to + // and the originally selected agent is kept as-is. + assert.deepStrictEqual( + await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, repo), + { agent: { uri: repoAgentUri }, name: 'Repo Agent' }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('_resolveAgentWhenMaterializing returns undefined when no agent is selected or neither resolves', async () => { + const agent = createTestAgent(disposables, { copilotClient: new TestCopilotClient([]) }); + try { + const internals = agent as unknown as AgentInternals; + internals._resolveAgentName = () => undefined; + assert.deepStrictEqual( + { + noAgent: await internals._resolveAgentWhenMaterializing(provisional(repo, undefined), emptySnapshot, worktree), + neitherResolves: await internals._resolveAgentWhenMaterializing(provisional(repo, { uri: repoAgentUri }), emptySnapshot, worktree), + }, + { noAgent: undefined, neitherResolves: undefined }, + ); + } finally { + await disposeAgent(agent); + } + }); + + test('materialization rewrites a repo agent to its worktree copy and persists it (no resolution stubbing)', async () => { + // End-to-end through real customization discovery: the same custom + // agent file exists in both the original repo and the worktree. The + // user selects the repo copy, but once the worktree is materialized + // discovery re-anchors there, so the persisted/launched agent must be + // the worktree copy — proving the translation against real resolution + // rather than a stubbed `_resolveAgentName`. + const fileService = disposables.add(new FileService(new NullLogService())); + disposables.add(fileService.registerProvider(Schemas.inMemory, disposables.add(new InMemoryFileSystemProvider()))); + + const repoFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo' }); + const worktreeFolder = URI.from({ scheme: Schemas.inMemory, path: '/repo.worktrees/agents-x' }); + const repoAgentFile = URI.joinPath(repoFolder, '.github', 'agents', 'agent.md'); + const worktreeAgentFile = URI.joinPath(worktreeFolder, '.github', 'agents', 'agent.md'); + const agentContents = VSBuffer.fromString('---\nname: My Agent\ndescription: a custom agent\n---\nbody'); + await fileService.writeFile(repoAgentFile, agentContents); + await fileService.writeFile(worktreeAgentFile, agentContents); + + const client = new TestCopilotClient([]); + client.createSession = async () => new MockCopilotSession() as unknown as CopilotSession; + + const sessionDataService = disposables.add(new TestSessionDataService()); + const { agent } = createTestAgentContext(disposables, { sessionDataService, copilotClient: client, fileService }); + + let launchAgentName: string | undefined; + const internals = agent as unknown as AgentInternals; + internals._resolveSessionWorkingDirectory = async () => worktreeFolder; + const originalCreateAgentSession = internals._createAgentSession; + internals._createAgentSession = (launchPlan, customizationDirectory, activeClient, channelUri) => { + launchAgentName = launchPlan.resolvedAgentName; + return originalCreateAgentSession.call(agent, launchPlan, customizationDirectory, activeClient, channelUri); + }; + + try { + await agent.authenticate('https://api.github.com', 'token'); + const result = await agent.createSession({ + session: AgentSession.uri('copilotcli', 'agent-translate'), + workingDirectory: repoFolder, + agent: { uri: repoAgentFile.toString() }, + }); + assert.strictEqual(result.provisional, true); + await agent.chats.sendMessage(defaultChatUri(result.session), 'hello'); + + // `_readSessionMetadata` reads back the exact agent field the + // resume path consumes, so asserting it stands in for restore. + const stored = await internals._readSessionMetadata(result.session); + assert.deepStrictEqual( + { storedAgent: stored.agent, launchAgentName }, + { storedAgent: { uri: worktreeAgentFile.toString() }, launchAgentName: 'My Agent' }, + 'the repo agent must be rewritten to its worktree copy, both for the SDK launch and the persisted metadata the restore path reads', + ); + } 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 6519390c51559e..30a12ec3cb576f 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -17,16 +17,16 @@ 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 type { ClassifiedEvent, IGDPRProperty, OmitMetadata, StrictPropertyCheck } from '../../../telemetry/common/gdprTypings.js'; -import { ITelemetryService, TelemetryLevel } from '../../../telemetry/common/telemetry.js'; +import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { NullTelemetryServiceShape } from '../../../telemetry/common/telemetryUtils.js'; import { AgentSession, type AgentSignal, type IAgentActionSignal, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { IDiffComputeService } from '../../common/diffComputeService.js'; import { ISessionDataService } from '../../common/sessionDataService.js'; -import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction } from '../../common/state/sessionActions.js'; -import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { ActionType, type ChatDeltaAction, type ChatErrorAction, type ChatInputRequestedAction, type ChatResponsePartAction, type ChatToolCallCompleteAction, type ChatToolCallReadyAction, type ChatToolCallStartAction, type ChatTurnCompleteAction, type ChatUsageAction, type SessionAction } from '../../common/state/sessionActions.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildDefaultChatUri, type ToolDefinition, type ToolResultContent, type ToolResultFileEditContent, type UsageInfoMeta } from '../../common/state/sessionState.js'; +import { McpAuthRequiredReason, McpServerStatus } from '../../common/state/protocol/channels-session/state.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { ActiveClientToolSet } from '../../node/activeClientState.js'; import { type CopilotSessionLaunchPlan, type IActiveClientSnapshot, type ICopilotSessionLauncher, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js'; @@ -35,7 +35,7 @@ import { buildCopilotSystemNotification } from '../../node/copilot/copilotSystem import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentHostAutoReplyEnabledConfigKey, AgentHostGlobalAutoApproveEnabledConfigKey } from '../../common/agentHostSchema.js'; -import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey } from '../../common/copilotCliConfig.js'; import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../sandbox/common/settings.js'; import { createSessionDataService, createZeroDiffComputeService } from '../common/sessionTestHelpers.js'; @@ -193,32 +193,6 @@ class CapturingLogService extends NullLogService { } } -class RecordingTelemetryService implements ITelemetryService { - declare readonly _serviceBrand: undefined; - readonly telemetryLevel = TelemetryLevel.NONE; - readonly sessionId = 'someValue.sessionId'; - readonly machineId = 'someValue.machineId'; - readonly sqmId = 'someValue.sqmId'; - readonly devDeviceId = 'someValue.devDeviceId'; - readonly firstSessionDate = 'someValue.firstSessionDate'; - readonly sendErrorTelemetry = false; - readonly events: Array<{ eventName: string; data: unknown }> = []; - - publicLog(): void { } - - publicLog2<E extends ClassifiedEvent<OmitMetadata<T>> = never, T extends IGDPRProperty = never>(eventName: string, data?: StrictPropertyCheck<T, E>): void { - this.events.push({ eventName, data }); - } - - publicLogError(): void { } - - publicLogError2(): void { } - - setExperimentProperty(): void { } - - setCommonProperty(): void { } -} - // ---- Helpers ---------------------------------------------------------------- /** @@ -684,6 +658,52 @@ suite('CopilotAgentSession', () => { }]); }); + test('forwards an embedded resource with a selection as its already-sliced inline blob', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + // The handler inlines only the selected text into `data`, so the adapter forwards it verbatim (no re-slicing). + await session.send('what is the selected word?', [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'file:test.js', + displayKind: 'selection', + data: encodeBase64(VSBuffer.fromString('world')), + contentType: 'text/plain', + selection: { range: { start: { line: 1, character: 6 }, end: { line: 1, character: 11 } } }, + }]); + + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: 'what is the selected word?', + attachments: [{ + type: 'blob', + data: encodeBase64(VSBuffer.fromString('world')), + mimeType: 'text/plain', + displayName: 'file:test.js', + }], + }]); + }); + + test('sends an embedded resource without a selection as the full blob', async () => { + const { session, mockSession } = await createAgentSession(disposables); + + await session.send('what is in this file?', [{ + type: MessageAttachmentKind.EmbeddedResource, + label: 'file:test.js', + displayKind: 'document', + data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), + contentType: 'text/plain', + }]); + + assert.deepStrictEqual(mockSession.sendRequests, [{ + prompt: 'what is in this file?', + attachments: [{ + type: 'blob', + data: encodeBase64(VSBuffer.fromString('line0\nhello world\nline2')), + mimeType: 'text/plain', + displayName: 'file:test.js', + }], + }]); + }); + test('sends paste simple attachments as text blobs', async () => { const { session, mockSession } = await createAgentSession(disposables); @@ -879,7 +899,7 @@ suite('CopilotAgentSession', () => { .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [{ name: 'env' }], sendRequests: [], responseParts: [{ kind: ResponsePartKind.Markdown, content: '## Environment\n\nLoaded.' }], @@ -921,7 +941,7 @@ suite('CopilotAgentSession', () => { responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [], sendRequests: [{ prompt: '/env', attachments: undefined }], responseParts: [], @@ -958,7 +978,7 @@ suite('CopilotAgentSession', () => { .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [{ name: 'env', input: 'details please' }], sendRequests: [], responseParts: [{ kind: ResponsePartKind.Markdown, content: 'done' }], @@ -997,7 +1017,7 @@ suite('CopilotAgentSession', () => { .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [{ name: 'focus', input: 'src/vs/platform' }], sendRequests: [], responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Focus done' }], @@ -1039,7 +1059,7 @@ suite('CopilotAgentSession', () => { env: true, review: true, skill: true, - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], }); }); @@ -1072,7 +1092,7 @@ suite('CopilotAgentSession', () => { .filter(a => a.type === ActionType.ChatTurnComplete) .map(a => (a as ChatTurnCompleteAction).turnId), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [{ name: 'review', input: 'focus on tests' }], sendRequests: [], responseParts: [{ kind: ResponsePartKind.Markdown, content: 'Review done' }], @@ -1093,7 +1113,7 @@ suite('CopilotAgentSession', () => { responseParts: getActions(signals).filter(a => a.type === ActionType.ChatResponsePart), turnComplete: getActions(signals).filter(a => a.type === ActionType.ChatTurnComplete), }, { - commandListCalls: [{ includeBuiltins: true, includeSkills: false, includeClientCommands: true }], + commandListCalls: [{ includeBuiltins: true, includeSkills: true, includeClientCommands: true }], commandInvokeCalls: [], sendRequests: [{ prompt: '/security-review', attachments: undefined }], responseParts: [], @@ -1772,7 +1792,7 @@ suite('CopilotAgentSession', () => { const { session, mockSession } = await createAgentSession(disposables, { rootValues: { [AgentHostSandboxConfigKey.Sandbox]: { [AgentHostSandboxKey.Enabled]: AgentSandboxEnabledValue.On }, - [AgentHostConfigKey.EnableCustomTerminalTool]: true, + [CopilotCliConfigKey.EnableCustomTerminalTool]: true, }, }); @@ -1984,7 +2004,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, }, }), { - content: 'Shell done', messageText: '`sleep 6` completed', startsTurn: true, }); @@ -1996,7 +2015,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'shell_detached_completed', shellId: 'detached-a' }, }, }), { - content: 'Detached done', messageText: 'Shell `detached-a` completed', startsTurn: true, }); @@ -2008,7 +2026,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_completed', agentId: 'agent-a', agentType: 'task', status: 'completed' }, }, }), { - content: 'Agent done', messageText: 'Background agent agent-a completed', startsTurn: true, }); @@ -2020,7 +2037,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_completed', agentId: 'agent-b', agentType: 'task', status: 'failed' }, }, }), { - content: 'Agent failed', messageText: 'Background agent agent-b failed', startsTurn: true, }); @@ -2032,8 +2048,7 @@ suite('CopilotAgentSession', () => { kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'task' }, }, }), { - content: 'Agent idle', - messageText: 'Background agent agent-a is idle', + messageText: 'Background agent agent-a is complete', startsTurn: true, }); @@ -2044,7 +2059,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'new_inbox_message', entryId: 'entry-a', senderName: 'sidekick', senderType: 'sidekick-agent', summary: 'New message' }, }, }), { - content: 'Inbox message', messageText: 'New inbox message from sidekick', startsTurn: false, }); @@ -2056,7 +2070,6 @@ suite('CopilotAgentSession', () => { kind: { type: 'instruction_discovered', sourcePath: 'packages/billing/AGENTS.md', triggerFile: 'packages/billing/src/index.ts', triggerTool: 'view', description: 'AGENTS.md from packages/billing/' }, }, }), { - content: 'Discovered instruction', messageText: 'Instruction discovered: AGENTS.md from packages/billing/', startsTurn: false, }); @@ -2104,7 +2117,7 @@ suite('CopilotAgentSession', () => { responseTurnId: (getActions(signals).find(a => a.type === ActionType.ChatResponsePart && a.part.kind === ResponsePartKind.Markdown) as ChatResponsePartAction | undefined)?.turnId, completedTurnId: (getActions(signals).find(a => a.type === ActionType.ChatTurnComplete) as ChatTurnCompleteAction | undefined)?.turnId, }, { - message: { text: 'Background agent agent-a is idle', origin: { kind: MessageKind.SystemNotification } }, + message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, responseTurnId: turnStarted.turnId, completedTurnId: turnStarted.turnId, }); @@ -2130,7 +2143,7 @@ suite('CopilotAgentSession', () => { turnId: 'turn-active', part: { kind: ResponsePartKind.SystemNotification, - content: 'Agent "agent-a" has finished processing and is now idle.', + content: 'Background agent agent-a is complete', }, }); }); @@ -2157,8 +2170,8 @@ suite('CopilotAgentSession', () => { assert.deepStrictEqual(getActions(signals) .filter(action => action.type === ActionType.ChatResponsePart) .map(action => action.part), [ - { kind: ResponsePartKind.SystemNotification, content: 'Inbox from sidekick' }, - { kind: ResponsePartKind.SystemNotification, content: 'Discovered instruction' }, + { kind: ResponsePartKind.SystemNotification, content: 'New inbox message from sidekick' }, + { kind: ResponsePartKind.SystemNotification, content: 'Instruction discovered: AGENTS.md from packages/billing/' }, ]); }); @@ -2220,6 +2233,22 @@ suite('CopilotAgentSession', () => { } }); + test('tool_start derives intention from a shell tool description argument', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-intent'); + + // The shell tool's own `description` argument carries the intention. + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-intent', + toolName: 'bash', + arguments: { command: 'ls', description: 'List files in the repo root' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + const toolStart = signals.find(s => isAction(s, ActionType.ChatToolCallStart)); + assert.ok(toolStart && isAction(toolStart, ActionType.ChatToolCallStart)); + assert.strictEqual((toolStart.action as ChatToolCallStartAction).intention, 'List files in the repo root'); + }); + test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { const wd = URI.file('/repo/project'); const { mockSession, signals } = await createAgentSession(disposables, { workingDirectory: wd }); @@ -2276,123 +2305,35 @@ suite('CopilotAgentSession', () => { } }); - test('live tool_complete emits languageModelToolInvoked telemetry', async () => { - const telemetryService = new RecordingTelemetryService(); - const { mockSession } = await createAgentSession(disposables, { telemetryService }); + test('live tool_complete maps SDK shell_exit content to terminal completion', async () => { + const { mockSession, signals } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { - toolCallId: 'tc-bash-telemetry', + toolCallId: 'tc-shell-exit', toolName: 'bash', - arguments: { command: 'npm test' }, + arguments: { command: 'gti status' }, } as SessionEventPayload<'tool.execution_start'>['data']); mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-bash-telemetry', + toolCallId: 'tc-shell-exit', success: true, - result: { content: 'passed' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-mcp-telemetry', - toolName: 'mcp_tool', - arguments: {}, - mcpServerName: 'test-server', - mcpToolName: 'lookup', - } as SessionEventPayload<'tool.execution_start'>['data']); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-mcp-telemetry', - success: false, - error: { code: 'denied', message: 'denied' }, - } as SessionEventPayload<'tool.execution_complete'>['data']); - - const normalizedEvents = telemetryService.events.map(event => { - const data = event.data as { - result: string; - chatSessionId: string | undefined; - toolId: string; - toolExtensionId: string | undefined; - toolSourceKind: string; - invocationTimeMs?: number; - }; - return { - eventName: event.eventName, - data: { - ...data, - invocationTimeMs: typeof data.invocationTimeMs === 'number' && data.invocationTimeMs >= 0, - }, - }; - }); - - assert.deepStrictEqual(normalizedEvents, [ - { - eventName: 'languageModelToolInvoked', - data: { - result: 'success', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'bash', - toolExtensionId: undefined, - toolSourceKind: 'agentHost', - invocationTimeMs: true, - }, - }, - { - eventName: 'languageModelToolInvoked', - data: { - result: 'userCancelled', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'mcp_tool', - toolExtensionId: undefined, - toolSourceKind: 'mcp', - invocationTimeMs: true, - }, - }, - ]); - }); - - test('client tool telemetry does not use clientId as toolExtensionId', async () => { - const telemetryService = new RecordingTelemetryService(); - const tools = [{ name: 'my_tool', description: 'A test tool', inputSchema: { type: 'object', properties: {} } }] as const; - const activeClientToolSet = new ActiveClientToolSet(); - activeClientToolSet.set('test-client', tools); - const { mockSession } = await createAgentSession(disposables, { - telemetryService, - clientSnapshot: { - tools, - plugins: [], - mcpServers: {}, + result: { + content: 'command not found\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 127, cwd: '/repo' }], }, - activeClientToolSet, - }); - - mockSession.fire('tool.execution_start', { - toolCallId: 'tc-client-telemetry', - toolName: 'my_tool', - arguments: {}, - } as SessionEventPayload<'tool.execution_start'>['data']); - mockSession.fire('tool.execution_complete', { - toolCallId: 'tc-client-telemetry', - success: true, - result: { content: 'done' }, } as SessionEventPayload<'tool.execution_complete'>['data']); - const [event] = telemetryService.events; - assert.deepStrictEqual({ - eventName: event.eventName, - data: { - ...(event.data as object), - invocationTimeMs: typeof (event.data as { invocationTimeMs?: number }).invocationTimeMs === 'number', - }, - }, { - eventName: 'languageModelToolInvoked', - data: { - result: 'success', - chatSessionId: AgentSession.uri('copilot', 'test-session-1').toString(), - toolId: 'my_tool', - toolExtensionId: undefined, - toolSourceKind: 'client', - invocationTimeMs: true, - }, - }); - + assert.strictEqual(signals.length, 3); + const completeSignal = signals[2]; + assert.ok(isAction(completeSignal, ActionType.ChatToolCallComplete)); + if (isAction(completeSignal, ActionType.ChatToolCallComplete)) { + const action = completeSignal.action as ChatToolCallCompleteAction; + assert.strictEqual(action.result.success, true); + assert.deepStrictEqual(action.result.content, [ + { type: ToolResultContentType.Text, text: 'command not found\n' }, + { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString() }, + ]); + assert.ok(!action.result.content?.some(content => content.type === ToolResultContentType.Terminal)); + } }); test('live task_complete emits root markdown instead of a tool call', async () => { @@ -3102,6 +3043,67 @@ suite('CopilotAgentSession', () => { ]); }); + test('subagent skill invocation routes to the subagent session scope', async () => { + const { session, mockSession, signals } = await createAgentSession(disposables); + session.resetTurnState('turn-1'); + + mockSession.fire('subagent.started', { + toolCallId: 'tc-subagent', + agentName: 'explore', + agentDisplayName: 'Explore', + agentDescription: 'Explore tests', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-1' }); + + mockSession.fire('skill.invoked', { + name: 'explore', + path: '/skills/explore/SKILL.md', + } as SessionEventPayload<'skill.invoked'>['data'], { id: 'skill-event', agentId: 'agent-1' }); + + const skillActions = signals + .filter((signal): signal is IAgentActionSignal => signal.kind === 'action') + .filter(signal => + signal.action.type === ActionType.ChatToolCallStart + || signal.action.type === ActionType.ChatToolCallReady + || signal.action.type === ActionType.ChatToolCallComplete + ) + .map(signal => ({ parentToolCallId: signal.parentToolCallId, action: signal.action })); + + assert.deepStrictEqual(skillActions, [ + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + toolName: 'skill', + displayName: 'Read Skill', + }, + }, + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + invocationMessage: { markdown: 'Reading skill [explore](file:///skills/explore/SKILL.md)' }, + confirmed: ToolCallConfirmationReason.NotNeeded, + }, + }, + { + parentToolCallId: 'tc-subagent', + action: { + type: ActionType.ChatToolCallComplete, + turnId: 'turn-1', + toolCallId: 'synth-skill-skill-event', + result: { + success: true, + pastTenseMessage: { markdown: 'Read skill [explore](file:///skills/explore/SKILL.md)' }, + }, + }, + }, + ]); + }); + test('history replay seeds turn id from the SDK envelope id, matching `turns.event_id`', async () => { // Regression test: fork / truncate look up the SDK boundary // event id via `getNextTurnEventId(turnId)`, which keys on @@ -3681,6 +3683,31 @@ suite('CopilotAgentSession', () => { assert.strictEqual(result.textResultForLlm, 'result text'); }); + test('agent-coordination client tools auto-ready with a tailored invocation message', async () => { + const agentSnapshot: IActiveClientSnapshot = { + tools: [{ name: 'list_agents', description: 'List agents', inputSchema: { type: 'object', properties: {} } }], + plugins: [], + mcpServers: {}, + }; + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('agent-client', agentSnapshot.tools); + const { mockSession, signals } = await createAgentSession(disposables, { clientSnapshot: agentSnapshot, activeClientToolSet }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-list-agents', + toolName: 'list_agents', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data']); + + // Unlike other client tools (which defer to the permission flow), + // the auto-approved agent-coordination tools auto-ready so their + // invocation renders our tailored message instead of the generic + // "Running {displayName}…" fallback. + const readySignal = signals.find(s => isAction(s, ActionType.ChatToolCallReady)); + assert.ok(readySignal && isAction(readySignal, ActionType.ChatToolCallReady)); + assert.strictEqual((readySignal.action as ChatToolCallReadyAction).invocationMessage, 'Listed agents'); + }); + test('client tool handler does not emit tool_ready (permission flow owns it)', async () => { const activeClientToolSet = new ActiveClientToolSet(); activeClientToolSet.set('client-perm', snapshot.tools); @@ -3733,6 +3760,62 @@ suite('CopilotAgentSession', () => { }); }); + test('permission before tool.execution_start synthesizes the client tool start so the ready is not dropped (SDK >= 1.0.6 ordering)', async () => { + // Regression for the client-tool hang after the Copilot SDK bump + // (1.0.6-preview): the SDK reordered client-tool events so + // `permission.requested` now fires *before* `tool.execution_start`. + // The permission-derived `ChatToolCallReady` must land on an + // existing tool-call part, so the host synthesizes the + // `ChatToolCallStart` at permission time. Without this the ready is + // dropped, the client tool stays streaming, and the turn hangs. + const activeClientToolSet = new ActiveClientToolSet(); + activeClientToolSet.set('client-reorder', snapshot.tools); + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet }); + + // Permission fires FIRST — no tool.execution_start yet. + const resultPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-reorder', + toolName: 'my_tool', + args: { file: 'test.ts' }, + }); + await waitForSignal(s => s.kind === 'pending_confirmation'); + + // A single ChatToolCallStart was synthesized (with the client + // contributor) and it precedes the pending_confirmation, so the + // permission-derived ready has a part to land on. + const startSignals = signals.filter(s => isAction(s, ActionType.ChatToolCallStart)); + assert.strictEqual(startSignals.length, 1); + assert.deepStrictEqual((startSignals[0].action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'client-reorder' }); + const startIndex = signals.indexOf(startSignals[0]); + const pendingIndex = signals.findIndex(s => s.kind === 'pending_confirmation'); + assert.ok(startIndex >= 0 && startIndex < pendingIndex, 'ChatToolCallStart must be emitted before pending_confirmation'); + + // The real tool.execution_start now arrives — it must NOT emit a + // duplicate ChatToolCallStart. + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-reorder', + toolName: 'my_tool', + arguments: { file: 'test.ts' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1, 'tool.execution_start must not emit a duplicate ChatToolCallStart'); + + // The parked SDK handler resolves once the client completes the + // call — i.e. the tool runs instead of hanging. + const tools = runtime.createClientSdkTools(); + const handlerPromise = invokeClientToolHandler(tools[0], 'tc-reorder', { file: 'test.ts' }); + session.respondToPermissionRequest('tc-reorder', true); + assert.strictEqual((await resultPromise).kind, 'approve-once'); + session.handleClientToolCallComplete('tc-reorder', { + success: true, + pastTenseMessage: 'did it', + content: [{ type: ToolResultContentType.Text, text: 'result text' }], + }); + const result = await handlerPromise; + assert.strictEqual(result.resultType, 'success'); + assert.strictEqual(result.textResultForLlm, 'result text'); + }); + test('pending_confirmation forwards parentToolCallId for tools inside subagents', async () => { // Regression: when a client tool runs inside a subagent the // permission-flow `pending_confirmation` must carry the @@ -3771,6 +3854,61 @@ suite('CopilotAgentSession', () => { await resultPromise; }); + test('subagent client tool: permission before tool.execution_start routes the synthesized start and ready to the subagent (SDK >= 1.0.6 ordering)', async () => { + // Regression for the SDK 1.0.6-preview reorder applied to a client + // tool running *inside a subagent*: `permission.requested` fires + // before `tool.execution_start`, so the start is synthesized at + // permission time. The synthesized start (and, via `_activeToolCalls`, + // the permission `pending_confirmation`) must route to the subagent + // session — the raw `permission.requested` event's `agentId` is used + // to recover the parent tool call because the SDK's permission handler + // callback drops it. + const { session, runtime, mockSession, signals, waitForSignal } = await createAgentSession(disposables, { clientSnapshot: snapshot, activeClientToolSet: activeClientToolSetWith('test-client') }); + + mockSession.fire('subagent.started', { + toolCallId: 'tc-parent-subagent', + agentName: 'helper', + agentDisplayName: 'Helper', + agentDescription: 'Helps', + } as SessionEventPayload<'subagent.started'>['data'], { agentId: 'agent-client-tool' }); + + // NEW ordering: the raw permission.requested event (carrying agentId) + // arrives before tool.execution_start. + mockSession.fire('permission.requested', { + requestId: 'req-sub', + permissionRequest: { kind: 'custom-tool', toolCallId: 'tc-sub-client', toolName: 'my_tool', toolDescription: 'A test tool', args: {} }, + } as SessionEventPayload<'permission.requested'>['data'], { agentId: 'agent-client-tool' }); + + const resultPromise = runtime.handlePermissionRequest({ + kind: 'custom-tool', + toolCallId: 'tc-sub-client', + toolName: 'my_tool', + }); + await waitForSignal(s => s.kind === 'pending_confirmation'); + + // The synthesized start routes to the subagent (parentToolCallId set). + const startSignals = signals.filter(s => isAction(s, ActionType.ChatToolCallStart)); + assert.strictEqual(startSignals.length, 1); + assert.strictEqual(startSignals[0].parentToolCallId, 'tc-parent-subagent'); + assert.deepStrictEqual((startSignals[0].action as ChatToolCallStartAction).contributor, { kind: ToolCallContributorKind.Client, clientId: 'test-client' }); + + // The permission pending_confirmation also routes to the subagent. + const permSignals = signals.filter((s): s is IAgentToolPendingConfirmationSignal => s.kind === 'pending_confirmation'); + assert.strictEqual(permSignals.length, 1); + assert.strictEqual(permSignals[0].parentToolCallId, 'tc-parent-subagent'); + + // The real subagent tool.execution_start must not emit a duplicate. + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-sub-client', + toolName: 'my_tool', + arguments: {}, + } as SessionEventPayload<'tool.execution_start'>['data'], { agentId: 'agent-client-tool' }); + assert.strictEqual(signals.filter(s => isAction(s, ActionType.ChatToolCallStart)).length, 1, 'tool.execution_start must not emit a duplicate ChatToolCallStart'); + + session.respondToPermissionRequest('tc-sub-client', false); + await resultPromise; + }); + test('handleClientToolCallComplete pre-completes when no handler is waiting yet', async () => { const { session, runtime } = await createAgentSession(disposables, { clientSnapshot: snapshot }); @@ -4518,6 +4656,66 @@ suite('CopilotAgentSession', () => { suite('MCP server inventory', () => { + test('MCP auth request publishes authRequired state and resolves with authenticate token', async () => { + const { session, runtime, waitForSignal } = await createAgentSession(disposables); + + const authPromise = runtime.handleMcpAuthRequest({ + requestId: 'auth-1', + serverName: 'github', + serverUrl: 'https://mcp.example.com', + reason: 'upscope', + resourceMetadata: JSON.stringify({ + resource: 'https://mcp.example.com', + resource_name: 'Example MCP', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['repo'], + }), + wwwAuthenticateParams: { scope: 'repo issues:write', error: 'insufficient_scope' }, + }, { sessionId: 'test-session-1' }); + + const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; + const action = signal.action as Extract<SessionAction, { type: ActionType.SessionCustomizationUpdated }>; + + assert.deepStrictEqual(action.customization, { + type: 'mcpServer', + id: 'mcp-top-level:copilot:test-session-1:github', + uri: 'mcp-top-level:copilot:test-session-1:github', + name: 'github', + enabled: true, + state: { + kind: McpServerStatus.AuthRequired, + reason: McpAuthRequiredReason.InsufficientScope, + resource: { + resource: 'https://mcp.example.com', + resource_name: 'Example MCP', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['repo'], + }, + requiredScopes: ['repo', 'issues:write'], + description: 'insufficient_scope', + }, + channel: undefined, + mcpApp: { capabilities: { serverTools: { listChanged: true }, serverResources: {}, sampling: {} } }, + }); + + assert.strictEqual(await session.resolveMcpAuthentication({ resource: 'https://mcp.example.com', scopes: ['repo', 'issues:write'], token: 'token-1' }), true); + assert.deepStrictEqual(await authPromise, { kind: 'token', accessToken: 'token-1' }); + }); + + test('needs-auth status remains starting when no auth request details are available', async () => { + const { mockSession, waitForSignal } = await createAgentSession(disposables); + + mockSession.fire('session.mcp_server_status_changed', { + serverName: 'github', + status: 'needs-auth', + } as SessionEventPayload<'session.mcp_server_status_changed'>['data']); + + const signal = await waitForSignal(s => isAction(s, ActionType.SessionCustomizationUpdated)) as IAgentActionSignal; + const action = signal.action as Extract<SessionAction, { type: ActionType.SessionCustomizationUpdated }>; + assert.strictEqual(action.customization.type, 'mcpServer'); + assert.deepStrictEqual(action.customization.state, { kind: McpServerStatus.Starting }); + }); + test('seeds inventory from rpc.mcp.list at subscription time', async () => { const { signals, waitForSignal } = await createAgentSession(disposables, { configureMockSession: m => { diff --git a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts index 37a24eea2ac1af..7e91b7947d0163 100644 --- a/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotGitProject.test.ts @@ -38,6 +38,9 @@ class TestAgentHostGitService implements IAgentHostGitService { async updateRef(): Promise<void> { } async deleteRefs(): Promise<void> { } async revParse(): Promise<undefined> { return undefined; } + async resolveBranchBaselineCommit(): Promise<string | undefined> { return undefined; } + async overlayPathIntoTree(): Promise<string | undefined> { return undefined; } + async diffTreePaths(): Promise<string[] | undefined> { return undefined; } async computeFileDiffsBetweenRefs(): Promise<undefined> { return undefined; } } diff --git a/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts new file mode 100644 index 00000000000000..326f4a41504d83 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotSessionLauncher.test.ts @@ -0,0 +1,244 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { InstantiationService } from '../../../instantiation/common/instantiationService.js'; +import { ServiceCollection } from '../../../instantiation/common/serviceCollection.js'; +import { ILogService, NullLogService } from '../../../log/common/log.js'; +import type { IByokLmChatRequest, IByokLmChatResult, IByokLmModelInfo } from '../../common/agentHostByokLm.js'; +import type { ModelSelection } from '../../common/state/protocol/state.js'; +import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js'; +import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js'; +import { CopilotSessionLauncher, getCopilotReasoningEffort, resolveByokSessionConfig } from '../../node/copilot/copilotSessionLauncher.js'; + +/** + * Covers the BYOK provider/model synthesis the launcher feeds into + * `createSession` / `resumeSession`. The first four tests pin the gating and + * graceful-degradation branches plus the exact SDK config shape using a real + * {@link ByokLmBridgeRegistry} and a counting proxy thunk (no real proxy). The + * last test wires the synthesized config straight into a live + * {@link ByokLmProxyService} and POSTs at it, proving the launcher's output is + * consumable end-to-end: provider `baseUrl` + `Bearer <nonce>.<sessionId>` + + * `model = id` route through the proxy to the renderer bridge. + */ +suite('resolveByokSessionConfig', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + const log = new NullLogService(); + + /** Minimal bridge connection: a scripted `listModels` and an unused `chat`. */ + function connectionOf(listModels: () => Promise<IByokLmModelInfo[]>) { + return { chat: async (): Promise<IByokLmChatResult> => ({ content: '' }), listModels }; + } + + /** A fake proxy handle plus a `startProxy` thunk that records its call count. */ + function countingProxy() { + let starts = 0; + const handle: IByokLmProxyHandle = { + baseUrl: 'http://127.0.0.1:1', + nonce: 'NONCE', + providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, + dispose: () => { }, + }; + return { + get starts() { return starts; }, + startProxy: async () => { starts++; return handle; }, + }; + } + + test('returns empty and never starts the proxy when no bridge is active', async () => { + const registry = new ByokLmBridgeRegistry(); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when the bridge reports no models', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('returns empty and never starts the proxy when enumeration fails', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => { throw new Error('renderer gone'); })); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.deepStrictEqual(config, {}); + assert.strictEqual(proxy.starts, 0); + }); + + test('synthesizes deduped providers and per-model config from the active bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + const registration = registry.register('client-1', connectionOf(async () => [ + { vendor: 'acme', id: 'claude', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { vendor: 'acme', id: 'gpt', name: undefined, maxContextWindowTokens: undefined }, + { vendor: 'globex', id: 'llama', name: 'Globex Llama' }, + ])); + const proxy = countingProxy(); + + const config = await resolveByokSessionConfig(sessionId, registry, proxy.startProxy, log); + registration.dispose(); + + assert.strictEqual(proxy.starts, 1); + assert.deepStrictEqual(config, { + providers: [ + { name: 'acme', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/acme', bearerToken: 'NONCE.sess-1' }, + { name: 'globex', type: 'openai', wireApi: 'completions', baseUrl: 'http://127.0.0.1:1/v/globex', bearerToken: 'NONCE.sess-1' }, + ], + models: [ + { id: 'claude', provider: 'acme', name: 'Acme Claude', maxContextWindowTokens: 200000 }, + { id: 'gpt', provider: 'acme' }, + { id: 'llama', provider: 'globex', name: 'Globex Llama' }, + ], + }); + }); + + test('synthesized provider config routes through a live proxy to the bridge', async () => { + const registry = new ByokLmBridgeRegistry(); + let captured: IByokLmChatRequest | undefined; + const registration = registry.register('client-1', { + chat: async (request) => { captured = request; return { content: 'hello from byok' }; }, + listModels: async () => [{ vendor: 'acme', id: 'claude' }], + }); + const service = new ByokLmProxyService(log, registry); + let handle: IByokLmProxyHandle | undefined; + + const config = await resolveByokSessionConfig(sessionId, registry, async () => (handle = await service.start()), log); + const provider = config.providers![0]; + const model = config.models![0]; + try { + const response = await fetch(`${provider.baseUrl}/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${provider.bearerToken}` }, + body: JSON.stringify({ model: model.id, messages: [{ role: 'user', content: 'hi' }] }), + }); + assert.strictEqual(response.status, 200); + const text = await response.text(); + assert.ok(text.includes('hello from byok'), `expected content in SSE: ${text}`); + } finally { + handle?.dispose(); + registration.dispose(); + service.dispose(); + } + assert.strictEqual(captured?.vendor, 'acme'); + assert.strictEqual(captured?.modelId, 'claude'); + }); +}); + +/** + * Covers the launcher's lazy memoization and disposal of the shared BYOK proxy + * handle: concurrent launches share one bind, and + * {@link CopilotSessionLauncher.disposeByokProxyHandle} (called by the agent + * after the runtime subprocess stops) releases it so the next launch mints a + * fresh nonce. + */ +suite('CopilotSessionLauncher BYOK proxy lifecycle', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionId = 'sess-1'; + + /** Minimal bridge connection: a scripted `listModels` and an unused `chat`. */ + function connectionOf(listModels: () => Promise<IByokLmModelInfo[]>) { + return { chat: async (): Promise<IByokLmChatResult> => ({ content: '' }), listModels }; + } + + /** A fake proxy service whose handles carry a unique nonce per `start()`. */ + function fakeProxyService() { + let starts = 0; + let disposes = 0; + const service: IByokLmProxyService = { + _serviceBrand: undefined, + start: async (): Promise<IByokLmProxyHandle> => { + const nonce = `NONCE-${++starts}`; + return { + baseUrl: 'http://127.0.0.1:1', + nonce, + providerBaseUrl: vendor => `http://127.0.0.1:1/v/${vendor}`, + dispose: () => { disposes++; }, + }; + }, + dispose: () => { }, + }; + return { service, get starts() { return starts; }, get disposes() { return disposes; } }; + } + + function createLauncher(store: DisposableStore, proxy: IByokLmProxyService, registry: IByokLmBridgeRegistry): CopilotSessionLauncher { + const services = new ServiceCollection(); + services.set(ILogService, new NullLogService()); + services.set(IByokLmProxyService, proxy); + services.set(IByokLmBridgeRegistry, registry); + // The launcher's other dependencies are unused by the BYOK path and + // resolve to `undefined` under the non-strict InstantiationService. + const instantiationService = store.add(new InstantiationService(services)); + return instantiationService.createInstance(CopilotSessionLauncher); + } + + test('memoizes the handle, and disposeByokProxyHandle releases it so the next launch mints a fresh nonce', async () => { + const store = new DisposableStore(); + const proxy = fakeProxyService(); + const registry = new ByokLmBridgeRegistry(); + store.add(registry.register('client-1', connectionOf(async () => [{ vendor: 'acme', id: 'claude' }]))); + const launcher = createLauncher(store, proxy.service, registry); + const resolve = () => (launcher as unknown as { _resolveByokSessionConfig(id: string): Promise<{ providers?: { bearerToken: string }[] }> })._resolveByokSessionConfig(sessionId); + + const first = await resolve(); + const second = await resolve(); + assert.strictEqual(proxy.starts, 1, 'subsequent launches share the memoized bind'); + assert.strictEqual(first.providers![0].bearerToken, second.providers![0].bearerToken, 'the shared bind reuses one nonce'); + + await launcher.disposeByokProxyHandle(); + await launcher.disposeByokProxyHandle(); + assert.strictEqual(proxy.disposes, 1, 'the handle is released exactly once and disposal is idempotent'); + + const third = await resolve(); + assert.strictEqual(proxy.starts, 2, 'a fresh bind is minted after disposal'); + assert.notStrictEqual(third.providers![0].bearerToken, first.providers![0].bearerToken, 'the fresh bind carries a new nonce'); + + store.dispose(); + }); +}); + +/** + * Covers the reasoning-effort resolution fed into `createSession` and + * `CopilotAgent._changeModel`: the host-level override (see + * `CopilotCliConfigKey.ReasoningEffortOverride`) wins over the model picker's + * thinking level when valid, and degrades to the picker value otherwise. + */ +suite('getCopilotReasoningEffort', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a valid override wins over the picker value; an invalid or absent override falls back', () => { + const model: ModelSelection = { id: 'gpt-5', config: { thinkingLevel: 'medium' } }; + assert.deepStrictEqual( + [ + getCopilotReasoningEffort(model), + getCopilotReasoningEffort(model, 'xhigh'), + getCopilotReasoningEffort(model, 'turbo'), + getCopilotReasoningEffort(undefined, 'high'), + getCopilotReasoningEffort(undefined), + ], + ['medium', 'xhigh', 'medium', 'high', undefined] + ); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts index 5f6c455d9de0bd..113dd7061d44e2 100644 --- a/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotShellTools.test.ts @@ -9,7 +9,7 @@ import { DeferredPromise } from '../../../../base/common/async.js'; import { URI } from '../../../../base/common/uri.js'; import * as platform from '../../../../base/common/platform.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable, DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, type IDisposable } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { IAgentConfigurationService } from '../../node/agentConfigurationService.js'; import { IEnvironmentService } from '../../../environment/common/environment.js'; @@ -40,11 +40,13 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { commandDetectionSupported = false; readonly commandFinishedListenerRegistered = new DeferredPromise<void>(); private readonly _onCommandFinished = new Emitter<ICommandFinishedEvent>(); + private readonly _onData = new Emitter<string>(); private readonly _onExit = new Emitter<number>(); private readonly _onClaimChanged = new Emitter<TerminalClaim>(); private readonly _onDidSendText = new Emitter<void>(); readonly onDidSendText = this._onDidSendText.event; private readonly _altBufferPromises: DeferredPromise<void>[] = []; + private _content: string | undefined; async createTerminal(params: CreateTerminalParams, options?: { shell?: string; preventShellHistory?: boolean; nonInteractive?: boolean }): Promise<void> { this.created.push({ params, options: { ...options, shell: options?.shell ?? this.defaultShell } }); @@ -57,7 +59,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { this.writeInput(uri, formatTerminalText(data, options)); this._onDidSendText.fire(); } - onData(): IDisposable { return Disposable.None; } + onData(_uri: string, cb: (data: string) => void): IDisposable { return this._onData.event(cb); } onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); } onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); } onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { @@ -77,7 +79,7 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { }); return deferred.p; } - getContent(): string | undefined { return undefined; } + getContent(): string | undefined { return this._content; } getClaim(): TerminalClaim | undefined { return undefined; } hasTerminal(uri: string): boolean { return this.existingTerminalUris.has(uri); } getExitCode(): number | undefined { return undefined; } @@ -87,8 +89,10 @@ class TestAgentHostTerminalManager implements IAgentHostTerminalManager { getTerminalState(): undefined { return undefined; } async getDefaultShell(): Promise<string> { return this.defaultShell; } fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } + fireData(data: string): void { this._onData.fire(data); } fireExit(exitCode: number): void { this._onExit.fire(exitCode); } fireClaimChanged(claim: TerminalClaim): void { this._onClaimChanged.fire(claim); } + setContent(content: string | undefined): void { this._content = content; } fireDidEnterAltBuffer(): void { for (const promise of [...this._altBufferPromises]) { promise.complete(); @@ -386,6 +390,41 @@ suite('CopilotShellTools', () => { assert.match(terminalManager.writes[1].data, /^echo "<<<COPILOT_SENTINEL_[a-f0-9]+_EXIT_\$\?>>>"\r$/); }); + test('primary shell tool ignores echoed sentinel command text', async () => { + const { instantiationService, terminalManager } = createServices(); + + const shellManager = disposables.add(instantiationService.createInstance(ShellManager, URI.parse('copilot:/session-1'), undefined)); + const tools = await createShellTools(shellManager, terminalManager, new NullLogService()); + const bashTool = tools.find(tool => tool.name === 'bash'); + assert.ok(bashTool); + + const invocation: ToolInvocation = { + sessionId: 'session-1', + toolCallId: 'tool-1', + toolName: 'bash', + arguments: { command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, + }; + const resultPromise = bashTool.handler!({ command: 'echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', timeout: 1000 }, invocation) as Promise<ToolResultObject>; + await waitForSentTexts(terminalManager, 2); + + const sentinelMatch = terminalManager.writes[1].data.match(/<<<COPILOT_SENTINEL_([a-f0-9]+)_EXIT_\$\?>>/); + assert.ok(sentinelMatch, 'sentinel marker should be present'); + const sentinelId = sentinelMatch[1]; + const content = [ + ' echo MOCKED_AGENT_HOST_SANDBOX_RESPONSE', + 'MOCKED_AGENT_HOST_SANDBOX_RESPONSE', + `echo "<<<COPILOT_SENTINEL_${sentinelId}_EXIT_$?>>>"`, + `<<<COPILOT_SENTINEL_${sentinelId}_EXIT_0>>>`, + ].join('\r\n'); + terminalManager.setContent(content); + terminalManager.fireData(content); + + const result = await resultPromise; + assert.strictEqual(result.resultType, 'success'); + assert.match(result.textResultForLlm, /Exit code: 0/); + assert.match(result.textResultForLlm, /MOCKED_AGENT_HOST_SANDBOX_RESPONSE/); + }); + test('primary shell tool forces bracketed paste with shell integration', async () => { const { instantiationService, terminalManager } = createServices(); terminalManager.commandDetectionSupported = true; diff --git a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts index 846bcdd8be608e..5f80507c5a3ea7 100644 --- a/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotSlashCommandCompletionProvider.test.ts @@ -6,9 +6,10 @@ import assert from 'assert'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { SYNCED_CUSTOMIZATION_SCHEME } from '../../common/agentHostFileSystemService.js'; import { CompletionItemKind } from '../../common/state/protocol/commands.js'; -import { MessageAttachmentKind } from '../../common/state/protocol/state.js'; -import { CopilotSlashCommandCompletionProvider, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; +import { Customization, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageAttachmentKind, type PluginCustomization, type SkillCustomization } from '../../common/state/protocol/state.js'; +import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandInfo, parseLeadingSlashCommand } from '../../node/copilot/copilotSlashCommandCompletionProvider.js'; suite('CopilotSlashCommandCompletionProvider', () => { @@ -105,6 +106,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const provider = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => [], }); const session = 'copilotcli:/abc'; @@ -124,12 +126,12 @@ suite('CopilotSlashCommandCompletionProvider', () => { test('returns all items for lone "/"', async () => { const items = await run('/'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/plan task ', '/compact ', '/research ', '/research query ', '/rubber-duck ', '/env ', '/review ', '/security-review ', '/rubber-duck review prompt ', '/security-review scope ', '/review scope '].sort()); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact ', '/research ', '/rubber-duck ', '/env ', '/review ', '/security-review '].sort()); }); test('filters to /plan when "/p" typed', async () => { const items = await run('/p'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/plan task ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ']); }); test('filters to /compact when "/c" typed', async () => { @@ -146,18 +148,14 @@ suite('CopilotSlashCommandCompletionProvider', () => { const items = await run('/r'); assert.deepStrictEqual(items.map(i => i.insertText), [ '/research ', - '/research query ', '/review ', - '/review scope ', - '/rubber-duck ', - '/rubber-duck review prompt ' + '/rubber-duck ' ].sort()); }); test('filters to /security-review when "/s" typed', async () => { const items = await run('/s'); - assert.deepStrictEqual(items.map(i => i.insertText), ['/security-review ', - '/security-review scope ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/security-review ']); }); test('returns nothing when /word does not match any command prefix', async () => { @@ -178,96 +176,73 @@ suite('CopilotSlashCommandCompletionProvider', () => { test('range covers only the leading slash word', async () => { const items = await run('/p extra text', 2); - assert.strictEqual(items.length, 2); + assert.strictEqual(items.length, 1); assert.strictEqual(items[0].rangeStart, 0); assert.strictEqual(items[0].rangeEnd, 2); }); test('attachment is Simple with command + description meta', async () => { const items = await run('/'); - assert.deepStrictEqual(items.map(item => ({ type: item.attachment?.type, meta: item.attachment?._meta })), [ + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ { - type: 'simple', + insertText: '/compact ', + type: MessageAttachmentKind.Simple, meta: { command: 'compact', - description: 'Runtime compact' - } - }, - { - type: 'simple', - meta: { - command: 'env', - description: 'Runtime env' - } + description: 'Runtime compact', + }, }, { + insertText: '/env ', type: MessageAttachmentKind.Simple, meta: { - command: 'plan', - description: 'Runtime plan', + command: 'env', + description: 'Runtime env', }, }, { + insertText: '/plan ', type: MessageAttachmentKind.Simple, meta: { command: 'plan', description: 'Runtime plan', + argumentHint: 'task', }, }, { + insertText: '/research ', type: MessageAttachmentKind.Simple, meta: { command: 'research', description: 'Runtime research', + argumentHint: 'query', }, }, { - type: MessageAttachmentKind.Simple, - meta: { - command: 'research', - description: 'Runtime research', - }, - }, - { + insertText: '/review ', type: MessageAttachmentKind.Simple, meta: { command: 'review', description: 'Runtime review', + argumentHint: 'scope', }, }, { + insertText: '/rubber-duck ', type: MessageAttachmentKind.Simple, - meta: { - command: 'review', - description: 'Runtime review', - }, - }, - { - type: 'simple', - meta: { - command: 'rubber-duck', - description: 'Runtime rubber-duck' - } - }, - { - type: 'simple', meta: { command: 'rubber-duck', - description: 'Runtime rubber-duck' - } - }, - { - type: 'simple', - meta: { - command: 'security-review', - description: 'Runtime security review' - } + description: 'Runtime rubber-duck', + argumentHint: 'review prompt', + }, }, { + insertText: '/security-review ', type: MessageAttachmentKind.Simple, meta: { command: 'security-review', description: 'Runtime security review', + argumentHint: 'scope', }, }, ]); @@ -277,6 +252,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => false, getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, @@ -285,13 +261,9 @@ suite('CopilotSlashCommandCompletionProvider', () => { '/compact ', '/env ', '/plan ', - '/plan task ', '/research ', - '/research query ', '/review ', - '/review scope ', - '/security-review ', - '/security-review scope ' + '/security-review ' ].sort()); }); @@ -299,6 +271,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => [], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, @@ -310,6 +283,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => runtimeCommands.filter(command => command.name !== 'env'), + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, @@ -317,15 +291,10 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.deepStrictEqual(items.map(i => i.insertText), [ '/compact ', '/plan ', - '/plan task ', '/research ', - '/research query ', '/review ', - '/review scope ', '/rubber-duck ', - '/rubber-duck review prompt ', '/security-review ', - '/security-review scope ', ].sort()); }); @@ -339,11 +308,12 @@ suite('CopilotSlashCommandCompletionProvider', () => { allowDuringAgentExecution: true, input: { hint: 'scope' }, }], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/f', offset: 2, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/focus ', '/focus scope ']); + assert.deepStrictEqual(items.map(i => i.insertText), ['/focus ']); }); test('keeps runtime commands that also have local send-time handling', async () => { @@ -354,11 +324,12 @@ suite('CopilotSlashCommandCompletionProvider', () => { { name: 'compact', description: 'runtime compact', kind: 'builtin', allowDuringAgentExecution: true }, { name: 'runtime-only', description: 'runtime only', kind: 'client', allowDuringAgentExecution: true }, ], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/', offset: 1, }, CancellationToken.None); - assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact ', '/runtime-only ', '/plan task '].sort()); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/compact ', '/runtime-only '].sort()); }); test('uses runtime input metadata to determine trailing space insertion', async () => { @@ -368,53 +339,62 @@ suite('CopilotSlashCommandCompletionProvider', () => { { name: 'no-input', description: 'No input', kind: 'builtin', allowDuringAgentExecution: true }, { name: 'needs-input', description: 'Needs input', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'value' } }, ], + getSessionCustomizations: async () => [], }); const withInput = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/n', offset: 2, }, CancellationToken.None); - assert.deepStrictEqual(withInput.map(i => i.insertText), ['/no-input ', '/needs-input ', '/needs-input value '].sort()); + assert.deepStrictEqual(withInput.map(i => i.insertText), ['/no-input ', '/needs-input '].sort()); }); - test('expands an enumerated hint into one item per option (with brackets)', async () => { + test('expands input choices into one item per choice', async () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => [ - { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '', choices: [{ name: 'on', description: 'Turn the feature on' }, { name: 'off', description: 'Turn the feature off' }] } }, ], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, }, CancellationToken.None); - // An enumerated hint expands into the bare command plus one item per option. - assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle on ', '/toggle off '].sort()); + // Structured choices expand into one item per choice, each carrying its own description. + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, meta: item.attachment?._meta })), [ + { insertText: '/toggle off ', meta: { command: 'toggle', description: 'Turn the feature off' } }, + { insertText: '/toggle on ', meta: { command: 'toggle', description: 'Turn the feature on' } }, + ]); }); - test('expands an enumerated hint into one item per option (without brackets)', async () => { + test('includes a bare command item when a choice has an empty name', async () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => [ - { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'on|off' } }, + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '', choices: [{ name: '', description: 'Show the current state' }, { name: 'on', description: 'Turn on' }, { name: 'off', description: 'Turn off' }] } }, ], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, }, CancellationToken.None); - // An enumerated hint expands into the bare command plus one item per option. - assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle on ', '/toggle off '].sort()); + // A choice with an empty name produces the bare command alongside the other options. + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle ', '/toggle off ', '/toggle on ']); }); - test('expands an enumerated hint into one item per option (requires input)', async () => { + test('surfaces the free-text hint as an argument hint when there are no choices', async () => { const gated = new CopilotSlashCommandCompletionProvider('copilotcli', { isRubberDuckEnabled: () => true, getRuntimeSlashCommands: async () => [ - { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { required: true, hint: '[on|off]' } }, + { name: 'toggle', description: 'Toggle a feature on or off', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, ], + getSessionCustomizations: async () => [], }); const items = await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text: '/t', offset: 2, }, CancellationToken.None); - // An enumerated hint expands into the bare command plus one item per option. - assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle on ', '/toggle off '].sort()); + // Without structured choices, the free-text hint is not expanded into options; it is surfaced as an argument hint on a single item. + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, meta: item.attachment?._meta })), [ + { insertText: '/toggle ', meta: { command: 'toggle', description: 'Toggle a feature on or off', argumentHint: '[on|off]' } }, + ]); }); test('passes raw session id to runtime command listing', async () => { @@ -425,6 +405,7 @@ suite('CopilotSlashCommandCompletionProvider', () => { seen = id; return [{ name: 'focus', kind: 'builtin', description: 'Focus', allowDuringAgentExecution: true }]; }, + getSessionCustomizations: async () => [], }); await gated.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: 'copilotcli:/abc', text: '/f', offset: 2, @@ -432,4 +413,178 @@ suite('CopilotSlashCommandCompletionProvider', () => { assert.strictEqual(seen, 'abc'); }); }); + + suite('runtime skill completions', () => { + const session = 'copilotcli:/abc'; + + function skill(name: string, description?: string): SkillCustomization { + return { + type: CustomizationType.Skill, + id: `file:///skills/${name}/SKILL.md`, + uri: `file:///skills/${name}/SKILL.md`, + name, + ...(description !== undefined ? { description } : {}), + }; + } + + function plugin(name: string, children?: readonly SkillCustomization[], enabled = true): PluginCustomization { + return { + type: CustomizationType.Plugin, + id: `file:///plugins/${name}`, + uri: `file:///plugins/${name}`, + name, + enabled, + load: { kind: CustomizationLoadStatus.Loaded }, + ...(children ? { children: [...children] } : {}), + }; + } + + function syncedPlugin(name: string, children?: readonly SkillCustomization[]): PluginCustomization { + return { + ...plugin(name, children), + id: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, + uri: `${SYNCED_CUSTOMIZATION_SCHEME}:/plugins/${name}`, + }; + } + + function createProvider(runtimeCommands: readonly ICopilotRuntimeSlashCommandInfo[], customizations: readonly Customization[] = []): CopilotSlashCommandCompletionProvider { + return new CopilotSlashCommandCompletionProvider('copilotcli', { + isRubberDuckEnabled: () => true, + getRuntimeSlashCommands: async () => runtimeCommands, + getSessionCustomizations: async () => customizations, + }); + } + + async function run(provider: CopilotSlashCommandCompletionProvider, text: string, offset = text.length) { + return provider.provideCompletionItems({ kind: CompletionItemKind.UserMessage, channel: session, text, offset }, CancellationToken.None); + } + + test('includes runtime skills that are not known local skills', async () => { + const provider = createProvider([ + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('excludes runtime skills that match a known plugin skill (with plugin prefix)', async () => { + const provider = createProvider( + [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('excludes runtime skills that match a known plugin skill with the same name (no prefix)', async () => { + const provider = createProvider( + [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('monitor-pr', [skill('monitor-pr')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('excludes runtime skills that match a known synced plugin skill (no prefix)', async () => { + const provider = createProvider( + [{ name: 'monitor-pr', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [syncedPlugin('skills-bundle', [skill('monitor-pr')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items, []); + }); + + test('includes runtime skills whose name differs from the prefixed known skill candidate', async () => { + // A non-synced plugin skill is known as `my-plugin:my-skill`, so a bare `my-skill` runtime skill is still surfaced. + const provider = createProvider( + [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('treats skills inside disabled containers as unknown', async () => { + const provider = createProvider( + [{ name: 'my-plugin:my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [plugin('my-plugin', [skill('my-skill')], false)], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-plugin:my-skill ']); + }); + + test('ignores mcp server containers when computing known skills', async () => { + const mcpServer: Customization = { + type: CustomizationType.McpServer, + id: 'file:///mcp/my-skill', + uri: 'file:///mcp/my-skill', + name: 'my-skill', + enabled: true, + state: { kind: McpServerStatus.Ready }, + }; + const provider = createProvider( + [{ name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }], + [mcpServer], + ); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('surfaces the skill prompt hint as an argument hint', async () => { + const provider = createProvider([ + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: 'do stuff' } }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(item => ({ insertText: item.insertText, type: item.attachment?.type, meta: item.attachment?._meta })), [ + { + insertText: '/my-skill ', + type: MessageAttachmentKind.Simple, + meta: { + command: 'my-skill', + description: 'Runtime skill', + argumentHint: 'do stuff', + }, + }, + ]); + }); + + test('does not expand a skill hint into option items', async () => { + const provider = createProvider([ + { name: 'toggle-skill', description: 'Toggle skill', kind: 'skill', allowDuringAgentExecution: true, input: { hint: '[on|off]' } }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/toggle-skill ']); + }); + + test('surfaces runtime skills alongside builtins for a leading slash', async () => { + const provider = createProvider([ + { name: 'plan', description: 'Runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'alpha-skill', description: 'Alpha skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, '/'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/plan ', '/alpha-skill '].sort()); + }); + + test('returns only runtime skills for an in-message slash token', async () => { + const provider = createProvider([ + { name: 'plan', description: 'Runtime plan', kind: 'builtin', allowDuringAgentExecution: true, input: { hint: 'task' } }, + { name: 'runtime-only', description: 'Client command', kind: 'client', allowDuringAgentExecution: true }, + { name: 'my-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ]); + const items = await run(provider, 'use /'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/my-skill ']); + }); + + test('excludes known skills even for an in-message slash token', async () => { + const provider = createProvider( + [ + { name: 'my-plugin:my-skill', description: 'Known skill', kind: 'skill', allowDuringAgentExecution: true }, + { name: 'other-skill', description: 'Runtime skill', kind: 'skill', allowDuringAgentExecution: true }, + ], + [plugin('my-plugin', [skill('my-skill')])], + ); + const items = await run(provider, 'use /'); + assert.deepStrictEqual(items.map(i => i.insertText), ['/other-skill ']); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts index 0adf58339a5930..02935d7eb3a7eb 100644 --- a/src/vs/platform/agentHost/test/node/copilotTestEvents.ts +++ b/src/vs/platform/agentHost/test/node/copilotTestEvents.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionEvent } from '@github/copilot-sdk'; +import type { Attachment, SessionEvent, SessionEventPayload, ToolExecutionCompleteContent } from '@github/copilot-sdk'; // ============================================================================= // Minimal session-event shapes for tests @@ -37,7 +37,11 @@ export interface ISessionEventToolComplete { data: { toolCallId: string; success: boolean; - result?: { content?: string }; + result?: { + /** `content` is result text; `contents` are typed SDK result blocks such as `shell_exit`. */ + content?: string; + contents?: ToolExecutionCompleteContent[]; + }; error?: { message: string; code?: string }; isUserRequested?: boolean; toolTelemetry?: unknown; @@ -102,6 +106,21 @@ export interface ISessionEventAbort { }; } +export interface ISessionEventAssistantTurn { + type: 'assistant.turn_start' | 'assistant.turn_end'; + agentId?: string; + data: { + turnId: string; + interactionId?: string; + }; +} + +export interface ISessionEventSystemNotification { + type: 'system.notification'; + id?: string; + data: SessionEventPayload<'system.notification'>['data']; +} + /** Minimal event shape for session history mapping. */ export type ISessionEvent = | ISessionEventToolStart @@ -110,6 +129,8 @@ export type ISessionEvent = | ISessionEventSubagentStarted | ISessionEventSkillInvoked | ISessionEventAbort + | ISessionEventAssistantTurn + | ISessionEventSystemNotification | { type: string; data?: unknown }; /** diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index db94d9d86825f4..924a0070b030a6 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getEditFilePath, getEditFilePaths, getInvocationMessage, getPastTenseMessage, getPermissionDisplay, getShellIntention, getShellLanguage, getToolDisplayName, getToolInputString, getToolKind, getToolMarkdownContent, isEditTool, isHiddenTool, isMarkdownRenderedTool, synthesizeSkillToolCall, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('copilotToolDisplay — friendly tool names', () => { @@ -299,6 +299,60 @@ suite('view tool — view_range display', () => { }); }); +suite('copilotToolDisplay — built-in tool invocation/past-tense messages', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function invocation(toolName: string, parameters: Record<string, unknown> | undefined): string { + const result = getInvocationMessage(toolName, getToolDisplayName(toolName), parameters); + return typeof result === 'string' ? result : result.markdown; + } + + function pastTense(toolName: string, parameters: Record<string, unknown> | undefined): string { + const result = getPastTenseMessage(toolName, getToolDisplayName(toolName), parameters, true); + return typeof result === 'string' ? result : result.markdown; + } + + test('agent-coordination tools use a single message (past tense) for both invocation and completion', () => { + // read/write agents surface the agent id, and the invocation message + // matches the past-tense message (these tools are fast). + assert.strictEqual(invocation('read_agent', { agent_id: 'math-helper' }), 'Read agent `math-helper`'); + assert.strictEqual(pastTense('read_agent', { agent_id: 'math-helper' }), 'Read agent `math-helper`'); + assert.strictEqual(invocation('write_agent', { agent_id: 'math-helper', message: 'hi' }), 'Wrote to agent `math-helper`'); + assert.strictEqual(pastTense('write_agent', { agent_id: 'math-helper', message: 'hi' }), 'Wrote to agent `math-helper`'); + }); + + test('agent tools fall back to a generic phrase without an agent id', () => { + assert.strictEqual(invocation('read_agent', {}), 'Read agent'); + assert.strictEqual(pastTense('write_agent', undefined), 'Wrote to agent'); + }); + + test('agent tools ignore a malformed (non-string) agent id instead of throwing', () => { + // agent_id comes from untrusted JSON, so a non-string must not reach the + // markdown inline-code formatter (which would throw). + assert.strictEqual(invocation('read_agent', { agent_id: 123 }), 'Read agent'); + assert.strictEqual(pastTense('write_agent', { agent_id: '' }), 'Wrote to agent'); + }); + + test('list_agents shares one message; task keeps distinct present/past phrases', () => { + // list_agents is a fast agent-coordination tool: one message. + assert.strictEqual(invocation('list_agents', {}), 'Listed agents'); + assert.strictEqual(pastTense('list_agents', {}), 'Listed agents'); + // task delegates to a (possibly slow) subagent, so it keeps a present-tense invocation. + assert.strictEqual(invocation('task', {}), 'Delegating task'); + assert.strictEqual(pastTense('task', {}), 'Delegated task'); + }); + + test('unhandled tools fall back to just the display name', () => { + // Known tool with no tailored message: uses its friendly display name. + assert.strictEqual(invocation('store_memory', {}), 'Store Memory'); + assert.strictEqual(pastTense('store_memory', {}), 'Store Memory'); + // Unknown tool: display name is the raw tool name. + assert.strictEqual(invocation('some_new_tool', {}), 'some_new_tool'); + assert.strictEqual(pastTense('some_new_tool', {}), 'some_new_tool'); + }); +}); + // ---- write_/read_ shell tool display --------------------------------------- // // Coverage for the secondary shell helpers (write_bash, read_bash, and their @@ -722,3 +776,29 @@ suite('apply_patch tool display', () => { assert.deepStrictEqual(getEditFilePaths(singleFilePatch), ['/repo/src/foo.ts']); }); }); + +suite('getShellIntention', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('reads the description argument of shell tools, and ignores non-shell tools', () => { + assert.deepStrictEqual({ + bash: getShellIntention('bash', { command: 'ls', description: 'List files' }), + powershell: getShellIntention('powershell', { command: 'Get-ChildItem', description: 'List files' }), + shellNoDescription: getShellIntention('bash', { command: 'ls' }), + shellEmptyDescription: getShellIntention('bash', { command: 'ls', description: '' }), + // The `task` (subagent) tool also has a `description` argument, but it is + // the subagent task description, not a shell intention — must be ignored. + taskTool: getShellIntention('task', { description: 'Explore the codebase' }), + viewTool: getShellIntention('view', { path: '/repo/file.ts', description: 'why' }), + noArgs: getShellIntention('bash', undefined), + }, { + bash: 'List files', + powershell: 'List files', + shellNoDescription: undefined, + shellEmptyDescription: undefined, + taskTool: undefined, + viewTool: undefined, + noArgs: undefined, + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 3ff30e061ec373..3efb5c6d373fd0 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -4,9 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { MessageAttachmentKind, ResponsePartKind, TurnState, type ResponsePart } from '../../common/state/sessionState.js'; +import { MessageAttachmentKind, MessageKind, ResponsePartKind, ToolCallStatus, ToolResultContentType, TurnState, type ResponsePart, type StringOrMarkdown, type ToolCallResponsePart } from '../../common/state/sessionState.js'; import { mapSessionEvents } from '../../node/copilot/mapSessionEvents.js'; import { toSessionEvents, type ISessionEvent } from './copilotTestEvents.js'; @@ -16,8 +17,8 @@ suite('mapSessionEvents — history replay', () => { const session = AgentSession.uri('copilot', 'test-session'); - function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: string }> { - return parts.map(p => p.kind === ResponsePartKind.Markdown ? { kind: p.kind, content: p.content } : { kind: p.kind }); + function partKinds(parts: readonly ResponsePart[]): Array<{ kind: ResponsePartKind; content?: StringOrMarkdown }> { + return parts.map(p => p.kind === ResponsePartKind.Markdown || p.kind === ResponsePartKind.SystemNotification ? { kind: p.kind, content: p.content } : { kind: p.kind }); } test('task_complete with a summary renders as a markdown part, not a tool call', async () => { @@ -90,6 +91,80 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('derives shell tool intention from the description argument on replay', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'ls', description: 'List files in the repo root' } } }, + { type: 'tool.execution_complete', data: { toolCallId: 'tc-1', success: true, result: { content: 'a\nb\n' } } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.intention, 'List files in the repo root'); + }); + + test('maps SDK shell_exit content to terminal completion on replayed tool completion', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'echo hi' } } }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-1', + success: true, + result: { + content: 'hi\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 0, cwd: '/repo', outputPreview: 'hi\n' }], + }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); + if (part.toolCall.status !== ToolCallStatus.Completed) { return; } + assert.deepStrictEqual(part.toolCall.content, [ + { type: ToolResultContentType.Text, text: 'hi\n' }, + { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString(), preview: 'hi\n' }, + ]); + }); + + test('preserves non-zero terminal completion even when SDK tool completion succeeded', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', data: { interactionId: 'm1', content: 'hi' } }, + { type: 'assistant.message', data: { messageId: 'm2', content: '', toolRequests: [{ toolCallId: 'tc-1', name: 'bash' }] } }, + { type: 'tool.execution_start', data: { toolCallId: 'tc-1', toolName: 'bash', arguments: { command: 'gti status' } } }, + { + type: 'tool.execution_complete', + data: { + toolCallId: 'tc-1', + success: true, + result: { + content: 'command not found\n', + contents: [{ type: 'shell_exit', shellId: '0', exitCode: 127, cwd: '/repo' }], + }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + const part = turns[0].responseParts[0] as ToolCallResponsePart; + assert.strictEqual(part.kind, ResponsePartKind.ToolCall); + assert.strictEqual(part.toolCall.status, ToolCallStatus.Completed); + if (part.toolCall.status !== ToolCallStatus.Completed) { return; } + assert.strictEqual(part.toolCall.success, true); + assert.ok(part.toolCall.content?.some(content => content.type === ToolResultContentType.TerminalComplete && content.exitCode === 127)); + assert.ok(!part.toolCall.content?.some(content => content.type === ToolResultContentType.Terminal)); + }); + test('restores best-effort model, fallback agent, and attachments onto user messages', async () => { const events: ISessionEvent[] = [ { type: 'session.model_change', data: { newModel: 'opus-4.7' } }, @@ -176,6 +251,109 @@ suite('mapSessionEvents — history replay', () => { ]); }); + test('restores a system notification inside an assistant turn as a response part', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Wait for the background command' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '<system_notification>\nShell command completed\n</system_notification>', + kind: { type: 'shell_completed', shellId: 'shell-a', exitCode: 0, description: 'sleep 6' }, + }, + }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'Reading the output now.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [{ + id: 'user-event', + message: { text: 'Wait for the background command', origin: { kind: MessageKind.User } }, + state: TurnState.Complete, + parts: [ + { kind: ResponsePartKind.SystemNotification, content: '`sleep 6` completed' }, + { kind: ResponsePartKind.Markdown, content: 'Reading the output now.' }, + ], + }]); + }); + + test('restores an idle system notification as a system-initiated turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Start the background agent' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'The background agent is running.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '<system_notification>\nAgent completed\n</system_notification>', + kind: { type: 'agent_idle', agentId: 'agent-a', agentType: 'general-purpose' }, + }, + }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-2' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-2', content: 'Reading the background agent result.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + message: turn.message, + state: turn.state, + parts: partKinds(turn.responseParts), + })), [ + { + id: 'user-event', + message: { text: 'Start the background agent', origin: { kind: MessageKind.User } }, + state: TurnState.Complete, + parts: [{ kind: ResponsePartKind.Markdown, content: 'The background agent is running.' }], + }, + { + id: 'notification-event', + message: { text: 'Background agent agent-a is complete', origin: { kind: MessageKind.SystemNotification } }, + state: TurnState.Complete, + parts: [{ kind: ResponsePartKind.Markdown, content: 'Reading the background agent result.' }], + }, + ]); + }); + + test('does not restore a passive notification outside an assistant turn', async () => { + const events: ISessionEvent[] = [ + { type: 'user.message', id: 'user-event', data: { interactionId: 'interaction-1', content: 'Check for instructions' } }, + { type: 'assistant.turn_start', data: { turnId: '0', interactionId: 'interaction-1' } }, + { type: 'assistant.message', data: { interactionId: 'interaction-1', content: 'No new instructions.', toolRequests: [] } }, + { type: 'assistant.turn_end', data: { turnId: '0' } }, + { + type: 'system.notification', + id: 'notification-event', + data: { + content: '<system_notification>\nInstruction discovered\n</system_notification>', + kind: { type: 'instruction_discovered', sourcePath: 'AGENTS.md', triggerFile: 'src/index.ts', triggerTool: 'view', description: 'Workspace instructions' }, + }, + }, + ]; + + const { turns } = await mapSessionEvents(session, undefined, toSessionEvents(events)); + + assert.deepStrictEqual(turns.map(turn => ({ + id: turn.id, + parts: partKinds(turn.responseParts), + })), [{ + id: 'user-event', + parts: [{ kind: ResponsePartKind.Markdown, content: 'No new instructions.' }], + }]); + }); + test('synthetic user messages do not start a new turn', async () => { const events: ISessionEvent[] = [ { type: 'user.message', id: 'user-event-1', data: { interactionId: 'interaction-1', content: 'Use the skill' } }, diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index 8165ea534324e3..1b128cd2491611 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -9,12 +9,12 @@ import { observableValue } from '../../../../base/common/observable.js'; import type { IAuthorizationProtectedResourceMetadata } from '../../../../base/common/oauth.js'; import { URI } from '../../../../base/common/uri.js'; import { type ISyncedCustomization } from '../../common/agentPluginManager.js'; -import { AgentSession, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; +import { AgentSession, type AgentProvider, type AgentSignal, type IActiveClient, type IAgent, type IAgentActionSignal, type IAgentChats, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentCreateSessionResult, type IAgentDescriptor, type IAgentModelInfo, type IAgentResolveSessionConfigParams, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type IAgentToolPendingConfirmationSignal } from '../../common/agentService.js'; import { buildSubagentTurnsFromHistory, buildTurnsFromHistory, type IHistoryRecord } from './historyRecordFixtures.js'; import { ProtectedResourceMetadata, ToolCallContributorKind, type AgentSelection, type MessageAttachment, type ModelSelection, type ToolDefinition } from '../../common/state/protocol/state.js'; import type { ResolveSessionConfigResult, SessionConfigCompletionsResult } from '../../common/state/protocol/commands.js'; import { ActionType } from '../../common/state/sessionActions.js'; -import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; +import { ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, CustomizationLoadStatus, buildDefaultChatUri, isAhpChatChannel, parseChatUri, parseSubagentSessionUri, type ClientPluginCustomization, type Customization, type PendingMessage, type StringOrMarkdown, type ToolCallResult, type Turn, type UsageInfo } from '../../common/state/sessionState.js'; import { hasKey } from '../../../../base/common/types.js'; /** Well-known auto-generated title used by the 'with-title' prompt. */ @@ -37,6 +37,7 @@ interface IMockSendMessageCall { readonly prompt: string; readonly attachments?: readonly MessageAttachment[]; readonly chat?: URI; + readonly senderClientId?: string; } /** @@ -58,7 +59,7 @@ export class MockAgent implements IAgent { readonly sendMessageCalls: IMockSendMessageCall[] = []; - readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[] }[] = []; + readonly setPendingMessagesCalls: { session: URI; steeringMessage: PendingMessage | undefined; queuedMessages: readonly PendingMessage[]; chat?: URI }[] = []; readonly disposeSessionCalls: URI[] = []; readonly abortSessionCalls: URI[] = []; readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = []; @@ -69,6 +70,7 @@ export class MockAgent implements IAgent { readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = []; readonly removeActiveClientCalls: { clientId: string }[] = []; readonly clientToolCallCompleteCalls: { session: URI; chat: URI; toolCallId: string; result: ToolCallResult }[] = []; + readonly truncateSessionCalls: { session: URI; turnId: string | undefined; chat: URI | undefined }[] = []; readonly setCustomizationEnabledCalls: { id: string; enabled: boolean }[] = []; /** Configurable return value for getCustomizations. */ customizations: Customization[] = []; @@ -133,8 +135,8 @@ export class MockAgent implements IAgent { return { items: [] }; } - async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string): Promise<void> { - const call = { session, prompt, attachments, chat }; + async sendMessage(session: URI, chat: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise<void> { + const call = { session, prompt, attachments, chat, ...(senderClientId ? { senderClientId } : {}) }; this.sendMessageCalls.push(call); this._onDidSendMessage.fire(call); if (turnId) { @@ -142,8 +144,8 @@ export class MockAgent implements IAgent { } } - setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[]): void { - this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages }); + setPendingMessages(session: URI, steeringMessage: PendingMessage | undefined, queuedMessages: readonly PendingMessage[], chat?: URI): void { + this.setPendingMessagesCalls.push({ session, steeringMessage, queuedMessages, chat }); } async getSessionMessages(session: URI): Promise<readonly Turn[]> { @@ -163,6 +165,10 @@ export class MockAgent implements IAgent { this.abortSessionCalls.push(session); } + async truncateSession(session: URI, turnId?: string, chat?: URI): Promise<void> { + this.truncateSessionCalls.push({ session, turnId, chat }); + } + respondToPermissionRequest(requestId: string, approved: boolean): void { this.respondToPermissionCalls.push({ requestId, approved }); } @@ -179,6 +185,63 @@ export class MockAgent implements IAgent { this.changeAgentCalls.push({ session, agent, chat }); } + /** + * Create an additional (peer) chat. The base mock is single-chat and + * rejects; multi-chat test subclasses override this. + */ + async createChat(_session: URI, _chat: URI, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> { + throw new Error(`Agent ${this.id} does not support multiple chats`); + } + + /** Dispose an additional (peer) chat. Overridden by multi-chat subclasses. */ + async disposeChat(_session: URI, _chat: URI): Promise<void> { } + + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the + * mock records calls against (mirroring the real agents). + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Mock agent chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: URI.parse(chat.toString()) }; + } + + readonly chats: IAgentChats = { + createChat: (chatUri: URI, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.createChat(session, chat, options); + }, + fork: (chatUri: URI, source: IAgentCreateChatForkSource, options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.createChat(session, chat, { ...options, fork: source }); + }, + disposeChat: (chatUri: URI): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.disposeChat(session, chat); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, senderClientId?: string): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.sendMessage(session, chat, prompt, attachments, turnId, senderClientId); + }, + abort: (chat: URI): Promise<void> => { + const { session } = this._resolveChatTarget(chat); + return this.abortSession(session); + }, + changeModel: (chatUri: URI, model: ModelSelection): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.changeModel(session, model, chat); + }, + changeAgent: (chatUri: URI, agent: AgentSelection | undefined): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.changeAgent(session, agent, chat); + }, + getMessages: (chat: URI): Promise<readonly Turn[]> => { + return this.getSessionMessages(chat); + }, + }; + async authenticate(resource: string, token: string): Promise<boolean> { this.authenticateCalls.push({ resource, token }); return true; @@ -797,7 +860,11 @@ export class ScriptedMockAgent implements IAgent { if (subagentInfo) { return buildSubagentTurnsFromHistory(this._preExistingMessages, subagentInfo.toolCallId, session.toString()); } - if (session.toString() === PRE_EXISTING_SESSION_URI.toString()) { + // Restore addresses the default chat by its channel URI; normalize it + // back to the session URI (mirroring the real agents' getSessionMessages). + const parsed = parseChatUri(session); + const normalized = parsed && buildDefaultChatUri(parsed.session) === session.toString() ? URI.parse(parsed.session) : session; + if (normalized.toString() === PRE_EXISTING_SESSION_URI.toString()) { return buildTurnsFromHistory(this._preExistingMessages); } return []; @@ -819,6 +886,49 @@ export class ScriptedMockAgent implements IAgent { // Mock agent doesn't track model state } + /** + * Map an already-resolved chat URI to the `(session, chat)` pair the + * scripted mock's per-chat context is keyed by. + */ + private _resolveChatTarget(chat: URI): { session: URI; chat: URI } { + const parsed = parseChatUri(chat); + if (!parsed) { + throw new Error(`Scripted mock chat operation requires an AHP chat URI: ${chat.toString()}`); + } + return { session: URI.parse(parsed.session), chat: URI.parse(chat.toString()) }; + } + + readonly chats: IAgentChats = { + createChat: (_chat: URI, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + throw new Error('Scripted mock agent does not support multiple chats'); + }, + fork: (_chat: URI, _source: IAgentCreateChatForkSource, _options?: IAgentCreateChatOptions): Promise<IAgentCreateChatResult | void> => { + throw new Error('Scripted mock agent does not support chat forking'); + }, + disposeChat: (_chat: URI): Promise<void> => { + return Promise.resolve(); + }, + sendMessage: (chatUri: URI, prompt: string, attachments?: readonly MessageAttachment[], turnId?: string, _senderClientId?: string): Promise<void> => { + const { session, chat } = this._resolveChatTarget(chatUri); + return this.sendMessage(session, chat, prompt, attachments, turnId); + }, + abort: (chat: URI): Promise<void> => { + const { session } = this._resolveChatTarget(chat); + return this.abortSession(session); + }, + changeModel: (chat: URI, model: ModelSelection): Promise<void> => { + const { session } = this._resolveChatTarget(chat); + return this.changeModel(session, model); + }, + changeAgent: (_chat: URI, _agent: AgentSelection | undefined): Promise<void> => { + // Scripted mock does not track agent selection. + return Promise.resolve(); + }, + getMessages: (chat: URI): Promise<readonly Turn[]> => { + return this.getSessionMessages(chat); + }, + }; + async truncateSession(_session: URI, _turnId?: string): Promise<void> { // Mock agent accepts truncation without side effects } diff --git a/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts index c303e34221a76d..3d41be3d616ac9 100644 --- a/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/codexRealSdk.integrationTest.ts @@ -26,6 +26,7 @@ import { generateUuid } from '../../../../../base/common/uuid.js'; import { MessageKind, PendingMessageKind, ChatInputResponseKind, type ChatInputRequest } from '../../../common/state/sessionState.js'; import { createRealSession, defineSharedRealSdkTests, dispatchTurn, getAcceptedAnswers, type IRealSdkProviderConfig } from './realSdkTestHelpers.js'; import { getActionEnvelope, isActionNotification, startRealServer, TestProtocolClient, type IServerHandle } from './testHelpers.js'; +import { URI } from '../../../../../base/common/uri.js'; const REAL_CODEX_ENABLED = process.env['AGENT_HOST_REAL_CODEX'] === '1'; @@ -91,7 +92,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-steer-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'steer-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'steer-client', createdSessions, URI.file(workingDirectory)); // A long, slow turn gives us a window to steer before it completes. const turnId = generateUuid(); @@ -142,7 +143,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-tool-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'tool-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'tool-client', createdSessions, URI.file(workingDirectory)); // Register a client-provided tool BEFORE the first turn so it lands in // `thread/start.dynamicTools`. @@ -218,7 +219,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-tool2-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'tool-client-2', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'tool-client-2', createdSessions, URI.file(workingDirectory)); // Let the background prewarm materialize a thread BEFORE any tools are // registered, so the tools must be applied via a thread restart. @@ -290,7 +291,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-servertool-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'servertool-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'servertool-client', createdSessions, URI.file(workingDirectory)); // No client tools are registered. The agent host's server tools // (feedback "comments") are wired automatically by the server and must @@ -334,7 +335,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-fileapprove-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'fileapprove-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'fileapprove-client', createdSessions, URI.file(workingDirectory)); // Read-only sandbox + on-request approval forces codex to ask before // applying any file edit (an `item/fileChange/requestApproval`). @@ -382,7 +383,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-trunc-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'trunc-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'trunc-client', createdSessions, URI.file(workingDirectory)); // Drive two quick turns to completion so the session has history. for (const [turnId, text] of [['trunc-1', 'Reply with exactly OK1.'], ['trunc-2', 'Reply with exactly OK2.']] as const) { @@ -414,7 +415,7 @@ defineSharedRealSdkTests(CODEX_CONFIG); this.timeout(180_000); const workingDirectory = mkdtempSync(join(tmpdir(), 'codex-planmode-')); tempDirs.push(workingDirectory); - const session = await createRealSession(client, CODEX_CONFIG, 'planmode-client', createdSessions, workingDirectory); + const session = await createRealSession(client, CODEX_CONFIG, 'planmode-client', createdSessions, URI.file(workingDirectory)); // Switch the session to Plan mode via the platform-generic Agent Mode // control — codex only exposes `request_user_input` in plan collaboration diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts new file mode 100644 index 00000000000000..48fd4c1e304c41 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/copilotCustomizations.integrationTest.ts @@ -0,0 +1,687 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Copilot SDK customization integration tests running on a mocked LLM. + * + * agent host log file: ~/.vscode-insiders/tmp/tmp_vscode_1/ahp-customizations-home-mock-ZBucPX/Library/Application Support/Code - OSS Dev/logs/20260701T192836/agenthost-server.log + */ + +import assert from 'assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from '../../../../../base/common/path.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ActionType, SessionCustomizationsChangedAction } from '../../../common/state/sessionActions.js'; +import { CustomizationType, type DirectoryCustomization } from '../../../common/state/sessionState.js'; +import { type AhpNotification } from '../../../common/state/sessionProtocol.js'; +import { createRealSession, dispatchTurn, IRealSdkProviderConfig } from './realSdkTestHelpers.js'; +import { getActionEnvelope, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from './testHelpers.js'; + +/** + * Whether `notification` is a *settled* `session/customizationsChanged` for + * `sessionUri`. + * + * Filesystem customization discovery is asynchronous: a client + * `SessionActiveClientSet`/`sync` can publish a snapshot before the initial + * disk scan has settled, producing a transient `customizations: []` + * notification (see `SessionPluginController.getCustomizationsSettled`). + * Because `clearReceived()` clears the local buffer but cannot retract an + * already-sent socket message, such a pre-discovery snapshot may even be + * delivered *after* a `clearReceived()`. These empty snapshots are not + * meaningful state changes, so the tests match and count only non-empty + * (settled) notifications — every session discovers at least the standard + * customization directories, so a settled snapshot always has a non-empty list. + */ +function isSettledCustomizationsNotification(notification: AhpNotification, sessionUri: string): boolean { + if (!isActionNotification(notification, ActionType.SessionCustomizationsChanged) || getActionEnvelope(notification).channel !== sessionUri) { + return false; + } + return (getActionEnvelope(notification).action as SessionCustomizationsChangedAction).customizations.length > 0; +} + +const COPILOT_CONFIG: IRealSdkProviderConfig = { + suiteTitle: 'Protocol WebSocket — Real Copilot SDK Mocked LLM', + provider: 'copilotcli', + scheme: 'copilotcli', + shellToolName: 'bash', + subagentToolNames: ['task'], + exitPlanModeToolName: 'exit_plan_mode', + enabled: true, + supportsWorktreeIsolation: true, + supportsSubagents: true, + supportsPlanMode: true, + githubToken: 'not-a-real-token', // The tests will use a mocked LLM, so the token doesn't need to be valid. +}; + +const SETUP_TIMEOUT_MS = 45_000; +const TEST_TIMEOUT_MS = 90_000; +const NOTIFICATION_TIMEOUT_MS = 10_000; + +suite('Protocol WebSocket — Real Copilot SDK, Mocked LLM (Copilot customizations)', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + const createdSessions: string[] = []; + const tempDirs: string[] = []; + let userHomeDir: string; + + suiteSetup(async function () { + this.timeout(SETUP_TIMEOUT_MS); + userHomeDir = await mkdtemp(`${tmpdir()}/ahp-customizations-home-mock-`); + server = await startRealServer({ mockLlm: true, homeDir: userHomeDir }); + tempDirs.push(userHomeDir); + }); + + suiteTeardown(async function () { + server?.process.kill(); + + for (const dir of tempDirs) { + try { + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { /* best-effort */ } + } + tempDirs.length = 0; + }); + + setup(async function () { + this.timeout(SETUP_TIMEOUT_MS); + client = new TestProtocolClient(server.port); + await client.connect(); + await cleanHomeFolder(); + }); + + teardown(async function () { + for (const session of createdSessions) { + try { + await client.call('disposeSession', { session }, 5000); + } catch { /* best-effort */ } + } + createdSessions.length = 0; + client.close(); + }); + + async function cleanHomeFolder() { + const foldersToClean = ['.copilot/agents', '.copilot/instructions', '.copilot/skills', '.copilot/hooks', '.agents', '.claude']; + await Promise.all([ + ...foldersToClean.map(folder => rm(join(userHomeDir, folder), { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })), + ]); + } + + test('detects only directory customizations on an empty workspace via session/customizationsChanged after hello (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-empty-mock-`); + tempDirs.push(workspaceDir); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-empty-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-empty-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-empty-mock', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isSettledCustomizationsNotification(n, sessionUri), NOTIFICATION_TIMEOUT_MS), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const mappedCustomizations = customizationsAction.customizations.map(customization => ({ + type: customization.type, + uri: customization.uri, + children: customization.type === CustomizationType.Directory ? (customization.children ?? []).map(child => child.uri) : undefined, + })); + const expectedCustomizations = [ + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'skills')).toString(), children: [] }, + ]; + const actualByUri = new Map(mappedCustomizations.map(customization => [customization.uri, customization])); + assert.strictEqual(actualByUri.size, expectedCustomizations.length, `expected ${expectedCustomizations.length} unique customizations, got: ${JSON.stringify(mappedCustomizations)}`); + const actualCustomizations = expectedCustomizations.map(expected => actualByUri.get(expected.uri)); + assert.deepStrictEqual(actualCustomizations, expectedCustomizations); + }); + + test('detects workspace agents, instructions, skills, and hooks via session/customizationsChanged after hello (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-test-mock-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const instructionsDir = join(githubDir, 'instructions'); + const skillsDir = join(githubDir, 'skills', 'hello-skill'); + const hooksDir = join(githubDir, 'hooks'); + const userAgentsDir = join(userHomeDir, '.copilot', 'agents'); + const userInstructionsDir = join(userHomeDir, '.copilot', 'instructions'); + const userCopilotSkillsDir = join(userHomeDir, '.copilot', 'skills', 'copilot-hello-skill'); + const userSkillsDir = join(userHomeDir, '.agents', 'skills', 'user-hello-skill'); + const userHooksDir = join(userHomeDir, '.copilot', 'hooks'); + const userAgentFile = join(userAgentsDir, 'user-hello.agent.md'); + const userInstructionFile = join(userInstructionsDir, 'user-policy.instructions.md'); + const userCopilotSkillFile = join(userCopilotSkillsDir, 'SKILL.md'); + const userSkillFile = join(userSkillsDir, 'SKILL.md'); + const userHookFile = join(userHooksDir, 'user-pre-tool.json'); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(skillsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + mkdir(userAgentsDir, { recursive: true }), + mkdir(userInstructionsDir, { recursive: true }), + mkdir(userCopilotSkillsDir, { recursive: true }), + mkdir(userSkillsDir, { recursive: true }), + mkdir(userHooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(agentsDir, 'hello.agent.md'), [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(join(instructionsDir, 'policy.instructions.md'), [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')), + writeFile(join(skillsDir, 'SKILL.md'), [ + '---', + 'name: hello-skill', + 'description: Says hello', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(join(hooksDir, 'pre-tool.json'), JSON.stringify({ PreToolUse: [] }, undefined, 2)), + writeFile(userAgentFile, [ + '---', + 'name: User Hello Agent', + 'description: Handles user hello requests', + '---', + 'You are a user-scope test agent.', + ].join('\n')), + writeFile(userInstructionFile, [ + '---', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer concise language.', + ].join('\n')), + writeFile(userCopilotSkillFile, [ + '---', + 'name: user-copilot-skill', + 'description: Says hello from Copilot home', + '---', + 'Return a Copilot home greeting.', + ].join('\n')), + writeFile(userSkillFile, [ + '---', + 'name: user-hello-skill', + 'description: Says hello from user home', + '---', + 'Return a user-level greeting.', + ].join('\n')), + writeFile(userHookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-mock', 'hello', 2); + + const [customizationsNotif] = await Promise.all([ + client.waitForNotification(n => isSettledCustomizationsNotification(n, sessionUri), NOTIFICATION_TIMEOUT_MS), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + const customizationsAction = getActionEnvelope(customizationsNotif).action as SessionCustomizationsChangedAction; + const mappedCustomizations = customizationsAction.customizations.map(customization => ({ + type: customization.type, + uri: customization.uri, + children: customization.type === CustomizationType.Directory ? (customization.children ?? []).map(child => child.uri) : undefined, + })); + const expectedCustomizations = [ + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.agents', 'skills')).toString(), children: [URI.file(userSkillFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'agents')).toString(), children: [URI.file(userAgentFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'hooks')).toString(), children: [URI.file(userHookFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'instructions')).toString(), children: [URI.file(userInstructionFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(userHomeDir, '.copilot', 'skills')).toString(), children: [URI.file(userCopilotSkillFile).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.agents', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'agents')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.claude', 'skills')).toString(), children: [] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'agents')).toString(), children: [URI.file(join(agentsDir, 'hello.agent.md')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'hooks')).toString(), children: [URI.file(join(hooksDir, 'pre-tool.json')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'instructions')).toString(), children: [URI.file(join(instructionsDir, 'policy.instructions.md')).toString()] }, + { type: CustomizationType.Directory, uri: URI.file(join(workspaceDir, '.github', 'skills')).toString(), children: [URI.file(join(skillsDir, 'SKILL.md')).toString()] }, + ]; + const actualByUri = new Map(mappedCustomizations.map(customization => [customization.uri, customization])); + assert.strictEqual(actualByUri.size, expectedCustomizations.length, `expected ${expectedCustomizations.length} unique customizations, got: ${JSON.stringify(mappedCustomizations)}`); + const actualCustomizations = expectedCustomizations.map(expected => actualByUri.get(expected.uri)); + assert.deepStrictEqual(actualCustomizations, expectedCustomizations); + }); + + test('emits session/customizationsChanged when customization files are edited, added, and removed (mock LLM)', async function () { + this.timeout(TEST_TIMEOUT_MS); + + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-customizations-watch-mock-`); + tempDirs.push(workspaceDir); + const githubDir = join(workspaceDir, '.github'); + const agentsDir = join(githubDir, 'agents'); + const skillsDir = join(githubDir, 'skills'); + const instructionsDir = join(githubDir, 'instructions'); + const hooksDir = join(githubDir, 'hooks'); + const homeAgentsDir = join(userHomeDir, '.copilot', 'agents'); + const homeCopilotSkillsDir = join(userHomeDir, '.copilot', 'skills'); + const homeSkillsDir = join(userHomeDir, '.agents', 'skills'); + const homeInstructionsDir = join(userHomeDir, '.copilot', 'instructions'); + const homeHooksDir = join(userHomeDir, '.copilot', 'hooks'); + const agentFile = join(agentsDir, 'hello.agent.md'); + const addedAgentFile = join(agentsDir, 'added.agent.md'); + const skillFile = join(skillsDir, 'watch-skill', 'SKILL.md'); + const addedSkillFile = join(skillsDir, 'added-skill', 'SKILL.md'); + const instructionFile = join(instructionsDir, 'watch.instructions.md'); + const addedInstructionFile = join(instructionsDir, 'added.instructions.md'); + const hookFile = join(hooksDir, 'pre-tool.json'); + const addedHookFile = join(hooksDir, 'post-tool.json'); + const homeAgentFile = join(homeAgentsDir, 'home.agent.md'); + const homeCopilotSkillFile = join(homeCopilotSkillsDir, 'nls', 'SKILL.md'); + const addedHomeAgentFile = join(homeAgentsDir, 'added-home.agent.md'); + const homeSkillFile = join(homeSkillsDir, 'home-skill', 'SKILL.md'); + const addedHomeSkillFile = join(homeSkillsDir, 'added-home-skill', 'SKILL.md'); + const homeInstructionFile = join(homeInstructionsDir, 'home.instructions.md'); + const addedHomeInstructionFile = join(homeInstructionsDir, 'added-home.instructions.md'); + const homeHookFile = join(homeHooksDir, 'home-pre-tool.json'); + const addedHomeHookFile = join(homeHooksDir, 'home-post-tool.json'); + const agentsInstructionsFile = join(workspaceDir, 'AGENTS.md'); + const workspaceAgentsDir = join(workspaceDir, '.agents', 'agents'); + const workspaceAgentsFile = join(workspaceAgentsDir, 'workspace-folder.agent.md'); + const workspaceRootUri = URI.file(workspaceDir).toString(); + + await Promise.all([ + mkdir(agentsDir, { recursive: true }), + mkdir(join(skillsDir, 'watch-skill'), { recursive: true }), + mkdir(instructionsDir, { recursive: true }), + mkdir(hooksDir, { recursive: true }), + mkdir(homeAgentsDir, { recursive: true }), + mkdir(join(homeSkillsDir, 'home-skill'), { recursive: true }), + mkdir(homeInstructionsDir, { recursive: true }), + mkdir(homeHooksDir, { recursive: true }), + ]); + await Promise.all([ + writeFile(agentFile, [ + '---', + 'name: Hello Agent', + 'description: Handles hello requests', + '---', + 'You are a test agent.', + ].join('\n')), + writeFile(skillFile, [ + '---', + 'name: watch-skill', + 'description: Watches skill changes', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(instructionFile, [ + '---', + 'name: Watch Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Be concise.', + ].join('\n')), + writeFile(hookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + writeFile(homeAgentFile, [ + '---', + 'name: Home Agent', + 'description: Home scoped agent', + '---', + 'You are a home test agent.', + ].join('\n')), + writeFile(homeSkillFile, [ + '---', + 'name: home-skill', + 'description: Home scoped skill', + '---', + 'Return a greeting.', + ].join('\n')), + writeFile(homeInstructionFile, [ + '---', + 'name: Home Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer home defaults.', + ].join('\n')), + writeFile(homeHookFile, JSON.stringify({ PreToolUse: [] }, undefined, 2)), + ]); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-customizations-watch-mock', createdSessions, URI.file(workspaceDir)); + client.dispatch({ + channel: sessionUri, + clientSeq: 1, + action: { + type: ActionType.SessionActiveClientSet, + activeClient: { + clientId: 'real-sdk-customizations-watch-client-mock', + tools: [], + }, + }, + }); + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-customizations-watch-mock', 'hello', 2); + + const getChildNamesAtDirectory = (action: SessionCustomizationsChangedAction, directoryUri: string, type: CustomizationType): string[] => { + const directories = action.customizations.filter((customization): customization is DirectoryCustomization => customization.type === CustomizationType.Directory); + const directory = directories.find(customization => customization.uri === directoryUri); + return (directory?.children ?? []) + .filter(child => child.type === type) + .map(child => child.name) + .sort((a, b) => a.localeCompare(b)); + }; + + const waitForDirectoryChildNames = async (directoryUri: string, type: CustomizationType, expectedNames: readonly string[], timeoutMs = NOTIFICATION_TIMEOUT_MS): Promise<void> => { + const expectedSorted = [...expectedNames].sort((a, b) => a.localeCompare(b)); + const assertSingleCustomizationChangeNotification = (): void => { + const matchingNotifications = client.receivedNotifications().filter(notification => + isActionNotification(notification, ActionType.SessionCustomizationsChanged) && + getActionEnvelope(notification).channel === sessionUri + ); + // Only settled (non-empty) notifications represent a real state + // change; transient pre-discovery `customizations: []` snapshots + // may straddle a `clearReceived()` (see + // `isSettledCustomizationsNotification`) and must not be counted. + const settledNotifications = matchingNotifications.filter(notification => isSettledCustomizationsNotification(notification, sessionUri)); + assert.strictEqual( + settledNotifications.length, + 1, + `expected exactly one settled ${ActionType.SessionCustomizationsChanged} notification for ${directoryUri}; got ${settledNotifications.length} settled of ${matchingNotifications.length} total: ${JSON.stringify(matchingNotifications)}`, + ); + }; + + const getMatchingActionFromNotifications = (notifications: ReturnType<TestProtocolClient['receivedNotifications']>): SessionCustomizationsChangedAction | undefined => { + for (const notification of notifications) { + if (!isSettledCustomizationsNotification(notification, sessionUri)) { + continue; + } + const action = getActionEnvelope(notification).action as SessionCustomizationsChangedAction; + const names = getChildNamesAtDirectory(action, directoryUri, type); + if (JSON.stringify(names) === JSON.stringify(expectedSorted)) { + return action; + } + } + return undefined; + }; + + const existingAction = getMatchingActionFromNotifications(client.receivedNotifications()); + if (existingAction) { + assert.deepStrictEqual(getChildNamesAtDirectory(existingAction, directoryUri, type), expectedSorted); + assertSingleCustomizationChangeNotification(); + return; + } + + let notif; + try { + notif = await client.waitForNotification(n => { + if (!isSettledCustomizationsNotification(n, sessionUri)) { + return false; + } + const action = getActionEnvelope(n).action as SessionCustomizationsChangedAction; + const names = getChildNamesAtDirectory(action, directoryUri, type); + return JSON.stringify(names) === JSON.stringify(expectedSorted); + }, timeoutMs); + } catch (error) { + throw new Error(`Timeout waiting for customizations update. directory=${directoryUri}, type=${type}, expected=${JSON.stringify(expectedSorted)}, received=${JSON.stringify(client.receivedNotifications())}, error=${error}`); + } + const action = getActionEnvelope(notif).action as SessionCustomizationsChangedAction; + assert.deepStrictEqual(getChildNamesAtDirectory(action, directoryUri, type), expectedSorted); + assertSingleCustomizationChangeNotification(); + }; + + await Promise.all([ + waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent']), + waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent']), + client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), NOTIFICATION_TIMEOUT_MS), + ]); + + client.clearReceived(); + await writeFile(agentFile, [ + '---', + 'name: Hello Agent Renamed', + 'description: Handles hello requests', + '---', + 'You are a renamed test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent Renamed']); + + client.clearReceived(); + await writeFile(addedAgentFile, [ + '---', + 'name: Added Agent', + 'description: Added after startup', + '---', + 'You are a newly added test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Added Agent', 'Hello Agent Renamed']); + + client.clearReceived(); + await rm(addedAgentFile, { force: true }); + await waitForDirectoryChildNames(URI.file(agentsDir).toString(), CustomizationType.Agent, ['Hello Agent Renamed']); + + client.clearReceived(); + await writeFile(agentsInstructionsFile, 'Be concise in all responses.'); + await waitForDirectoryChildNames(workspaceRootUri, CustomizationType.Rule, ['AGENTS.md']); + + client.clearReceived(); + await rm(agentsInstructionsFile, { force: true }); + await waitForDirectoryChildNames(workspaceRootUri, CustomizationType.Rule, []); + + client.clearReceived(); + await mkdir(workspaceAgentsDir, { recursive: true }); + await writeFile(workspaceAgentsFile, [ + '---', + 'name: Workspace Folder Agent', + 'description: Found in .agents/agents', + '---', + 'You are a workspace-folder test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(workspaceAgentsDir).toString(), CustomizationType.Agent, ['Workspace Folder Agent']); + + client.clearReceived(); + await writeFile(skillFile, [ + '---', + 'name: watch-skill-renamed', + 'description: Watches skill changes', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['watch-skill-renamed']); + + client.clearReceived(); + await mkdir(join(skillsDir, 'added-skill'), { recursive: true }); + await writeFile(addedSkillFile, [ + '---', + 'name: added-skill', + 'description: Added after startup', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['added-skill', 'watch-skill-renamed']); + + client.clearReceived(); + await rm(addedSkillFile, { force: true }); + await waitForDirectoryChildNames(URI.file(skillsDir).toString(), CustomizationType.Skill, ['watch-skill-renamed']); + + client.clearReceived(); + await writeFile(instructionFile, [ + '---', + 'name: Watch Policy Renamed', + 'applyTo:', + ' - "**/*"', + '---', + 'Be concise.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Watch Policy Renamed']); + + client.clearReceived(); + await writeFile(addedInstructionFile, [ + '---', + 'name: Added Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Added Policy', 'Watch Policy Renamed']); + + client.clearReceived(); + await rm(addedInstructionFile, { force: true }); + await waitForDirectoryChildNames(URI.file(instructionsDir).toString(), CustomizationType.Rule, ['Watch Policy Renamed']); + + client.clearReceived(); + await writeFile(hookFile, JSON.stringify({ PreToolUse: [{ command: 'echo changed' }] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['pre-tool.json']); + + client.clearReceived(); + await writeFile(addedHookFile, JSON.stringify({ PostToolUse: [] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['post-tool.json', 'pre-tool.json']); + + client.clearReceived(); + await rm(addedHookFile, { force: true }); + await waitForDirectoryChildNames(URI.file(hooksDir).toString(), CustomizationType.Hook, ['pre-tool.json']); + + client.clearReceived(); + await writeFile(homeAgentFile, [ + '---', + 'name: Home Agent Renamed', + 'description: Home scoped agent', + '---', + 'You are a renamed home test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent Renamed']); + + client.clearReceived(); + await writeFile(addedHomeAgentFile, [ + '---', + 'name: Added Home Agent', + 'description: Added after startup in home', + '---', + 'You are a newly added home test agent.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Added Home Agent', 'Home Agent Renamed']); + + client.clearReceived(); + await rm(addedHomeAgentFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeAgentsDir).toString(), CustomizationType.Agent, ['Home Agent Renamed']); + + client.clearReceived(); + await writeFile(homeSkillFile, [ + '---', + 'name: home-skill-renamed', + 'description: Home scoped skill', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['home-skill-renamed']); + + client.clearReceived(); + await mkdir(join(homeCopilotSkillsDir, 'nls'), { recursive: true }); + await writeFile(homeCopilotSkillFile, [ + '---', + 'name: nls-copilot-home-skill', + 'description: Added under ~/.copilot/skills', + '---', + 'Return localized strings.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeCopilotSkillsDir).toString(), CustomizationType.Skill, ['nls-copilot-home-skill']); + + client.clearReceived(); + await mkdir(join(homeSkillsDir, 'added-home-skill'), { recursive: true }); + await writeFile(addedHomeSkillFile, [ + '---', + 'name: added-home-skill', + 'description: Added after startup in home', + '---', + 'Return a greeting.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['added-home-skill', 'home-skill-renamed']); + + client.clearReceived(); + await rm(addedHomeSkillFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeSkillsDir).toString(), CustomizationType.Skill, ['home-skill-renamed']); + + client.clearReceived(); + await writeFile(homeInstructionFile, [ + '---', + 'name: Home Policy Renamed', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer home defaults.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Home Policy Renamed']); + + client.clearReceived(); + await writeFile(addedHomeInstructionFile, [ + '---', + 'name: Added Home Policy', + 'applyTo:', + ' - "**/*"', + '---', + 'Prefer short answers.', + ].join('\n')); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Added Home Policy', 'Home Policy Renamed']); + + client.clearReceived(); + await rm(addedHomeInstructionFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeInstructionsDir).toString(), CustomizationType.Rule, ['Home Policy Renamed']); + + client.clearReceived(); + await writeFile(homeHookFile, JSON.stringify({ PreToolUse: [{ command: 'echo home-changed' }] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-pre-tool.json']); + + client.clearReceived(); + await writeFile(addedHomeHookFile, JSON.stringify({ PostToolUse: [] }, undefined, 2)); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-post-tool.json', 'home-pre-tool.json']); + + client.clearReceived(); + await rm(addedHomeHookFile, { force: true }); + await waitForDirectoryChildNames(URI.file(homeHooksDir).toString(), CustomizationType.Hook, ['home-pre-tool.json']); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotImportSession.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotImportSession.integrationTest.ts new file mode 100644 index 00000000000000..8cfbb25bb48229 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/copilotImportSession.integrationTest.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Copilot SDK integration test for IMPORTING a translated conversation. + * + * Validates the core premise of local→Copilot-CLI migration: a synthesized + * `events.jsonl` (built by {@link buildSessionEventLogFromTurns}) seeded through + * a {@link DiskSessionFsProvider}, then `resumeSession`d, reconstitutes as real + * SDK turns — and is therefore editable (proven by `history.truncate`). + * + * Disabled by default. To run it, set `AGENT_HOST_REAL_SDK=1` (a Copilot CLI + * package must be installed under `node_modules/@github/copilot*`): + * + * AGENT_HOST_REAL_SDK=1 ./scripts/test-integration.sh --run src/vs/platform/agentHost/test/node/protocol/copilotImportSession.integrationTest.ts + * + * Authentication: token from `gh auth token`, overridable via `GITHUB_TOKEN`. + */ + +import assert from 'assert'; +import { promises as fs } from 'fs'; +import { tmpdir } from 'os'; +import { CopilotClient, RuntimeConnection, approveAll, type CopilotSession, type SessionEvent, type SessionFsFileInfo, type SessionFsProvider } from '@github/copilot-sdk'; +import { FileAccess } from '../../../../../base/common/network.js'; +import { delimiter, dirname, join } from '../../../../../base/common/path.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../base/common/uuid.js'; +import { rgDiskPath } from '../../../../../base/node/ripgrep.js'; +import { MessageKind, ResponsePartKind, TurnState, type ResponsePart, type Turn } from '../../../common/state/sessionState.js'; +import { buildSessionEventLogFromTurns } from '../../../node/copilot/buildSessionEvents.js'; +import { DiskSessionFsProvider } from '../../../node/copilot/diskSessionFsProvider.js'; +import { resolveGitHubToken } from './realSdkTestHelpers.js'; + +/** + * Directory entry shape returned by {@link SessionFsProvider.readdirWithTypes}. + * Mirrors the SDK's `SessionFsReaddirWithTypesEntry`, which is not re-exported + * from the package root. + */ +type SessionFsReaddirWithTypesEntry = { name: string; type: 'file' | 'directory' }; + +const REAL_SDK_ENABLED = process.env['AGENT_HOST_REAL_SDK'] === '1'; + +/** Session-state directory the client advertises to the runtime for session-scoped files. */ +const SESSION_STATE_PATH = 'session-state'; + +/** `node_modules` directory that ships alongside the compiled `out/`. */ +function nodeModulesUri(): URI { + return URI.joinPath(FileAccess.asFileUri(''), '..', 'node_modules'); +} + +/** Resolve a Copilot CLI entry point from `node_modules/@github/copilot*`. */ +async function resolveCopilotCliPath(): Promise<string> { + const nodeModules = URI.joinPath(nodeModulesUri(), '@github').fsPath; + const entries = await fs.readdir(nodeModules).catch(() => [] as string[]); + const candidates = entries + .filter(name => name === 'copilot' || name.startsWith('copilot-')) + .filter(name => name !== 'copilot-sdk') + .map(name => join(nodeModules, name, 'index.js')); + for (const candidate of candidates) { + if (await fs.stat(candidate).then(() => true, () => false)) { + return candidate; + } + } + throw new Error(`No Copilot CLI found under ${nodeModules} (looked for copilot*/index.js). Install a @github/copilot-<platform> package.`); +} + +/** + * Build the subprocess environment the Copilot CLI needs to start in + * stdio-server mode. Mirrors the production wiring in `copilotAgent.ts`: + * without `COPILOT_CLI_RUN_AS_NODE=1` the CLI entry point runs interactively + * and exits ("CLI server exited unexpectedly with code 0"), and without + * `MXC_BIN_DIR` the sandbox auto-detection cannot locate its binaries. + */ +async function buildCliEnv(): Promise<Record<string, string | undefined>> { + const env: Record<string, string | undefined> = Object.assign({}, process.env, { ELECTRON_RUN_AS_NODE: '1' }); + delete env['NODE_OPTIONS']; + delete env['VSCODE_INSPECTOR_OPTIONS']; + delete env['VSCODE_ESM_ENTRYPOINT']; + delete env['VSCODE_HANDLES_UNCAUGHT_ERRORS']; + for (const key of Object.keys(env)) { + if (key === 'ELECTRON_RUN_AS_NODE') { + continue; + } + if (key.startsWith('VSCODE_') || key.startsWith('ELECTRON_')) { + delete env[key]; + } + } + env['COPILOT_CLI_RUN_AS_NODE'] = '1'; + env['USE_BUILTIN_RIPGREP'] = 'false'; + env['COPILOT_MCP_APPS'] = 'true'; + + // Point the MXC sandbox auto-detection at VS Code's bundled binaries. + env['MXC_BIN_DIR'] = URI.joinPath(nodeModulesUri(), '@microsoft', 'mxc-sdk', 'bin').fsPath; + + // Make VS Code's built-in ripgrep discoverable to the CLI subprocess. + const rgDir = dirname(await rgDiskPath()); + const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') ?? 'PATH'; + const currentPath = env[pathKey]; + env[pathKey] = currentPath ? `${currentPath}${delimiter}${rgDir}` : rgDir; + + return env; +} + +/** Recursively list every file under `root`, returned as paths relative to `root`. */ +async function walkFiles(root: string): Promise<string[]> { + const out: string[] = []; + const rec = async (dir: string, rel: string): Promise<void> => { + let names: string[]; + try { + names = await fs.readdir(dir); + } catch { + return; + } + for (const name of names) { + const childRel = rel ? join(rel, name) : name; + const stat = await fs.stat(join(dir, name)).catch(() => undefined); + if (stat?.isDirectory()) { + await rec(join(dir, name), childRel); + } else if (stat) { + out.push(childRel); + } + } + }; + await rec(root, ''); + return out; +} + +/** Wraps a provider to record the SessionFs paths the runtime touches (diagnostics on failure). */ +class RecordingSessionFsProvider implements SessionFsProvider { + readonly reads: string[] = []; + constructor(private readonly _inner: SessionFsProvider) { } + readFile(path: string): Promise<string> { this.reads.push(`readFile ${path}`); return this._inner.readFile(path); } + writeFile(path: string, content: string, mode?: number): Promise<void> { return this._inner.writeFile(path, content, mode); } + appendFile(path: string, content: string, mode?: number): Promise<void> { return this._inner.appendFile(path, content, mode); } + exists(path: string): Promise<boolean> { this.reads.push(`exists ${path}`); return this._inner.exists(path); } + stat(path: string): Promise<SessionFsFileInfo> { this.reads.push(`stat ${path}`); return this._inner.stat(path); } + mkdir(path: string, recursive: boolean, mode?: number): Promise<void> { return this._inner.mkdir(path, recursive, mode); } + readdir(path: string): Promise<string[]> { this.reads.push(`readdir ${path}`); return this._inner.readdir(path); } + readdirWithTypes(path: string): Promise<SessionFsReaddirWithTypesEntry[]> { this.reads.push(`readdirWithTypes ${path}`); return this._inner.readdirWithTypes(path); } + rm(path: string, recursive: boolean, force: boolean): Promise<void> { return this._inner.rm(path, recursive, force); } + rename(src: string, dest: string): Promise<void> { return this._inner.rename(src, dest); } +} + +function markdown(content: string): ResponsePart { + return { kind: ResponsePartKind.Markdown, id: generateUuid(), content }; +} + +function userTurn(id: string, text: string, response: string): Turn { + return { + id, + message: { text, origin: { kind: MessageKind.User } }, + responseParts: response ? [markdown(response)] : [], + usage: undefined, + state: TurnState.Complete, + }; +} + +(REAL_SDK_ENABLED ? suite : suite.skip)('Real Copilot SDK — import via seeded events.jsonl', function () { + + this.timeout(120_000); + + let client: CopilotClient; + let baseDir: string; + + suiteSetup(async function () { + baseDir = await fs.mkdtemp(join(tmpdir(), 'ahp-import-')); + const cliPath = await resolveCopilotCliPath(); + client = new CopilotClient({ + useLoggedInUser: false, + gitHubToken: resolveGitHubToken(), + connection: RuntimeConnection.forStdio({ path: cliPath }), + env: await buildCliEnv(), + logLevel: 'error', + sessionFs: { + initialCwd: baseDir, + sessionStatePath: SESSION_STATE_PATH, + conventions: process.platform === 'win32' ? 'windows' : 'posix', + }, + }); + await client.start(); + }); + + suiteTeardown(async function () { + await client?.stop().catch(() => { }); + if (baseDir) { + await fs.rm(baseDir, { recursive: true, force: true }).catch(() => { }); + } + }); + + test('seeded conversation resumes as real, editable turns', async function () { + const sessionId = generateUuid(); + const turns: Turn[] = [ + userTurn('turn-a', 'What is 2+2? Reply with just the number.', 'It is 4.'), + userTurn('turn-b', 'And 3+3? Reply with just the number.', 'It is 6.'), + ]; + + // Seed the synthesized event log at the runtime's session-state path. + const sessionDir = join(baseDir, SESSION_STATE_PATH); + await fs.mkdir(sessionDir, { recursive: true }); + const jsonl = buildSessionEventLogFromTurns(turns, { sessionId, workingDirectory: baseDir }); + await fs.writeFile(join(sessionDir, 'events.jsonl'), jsonl, 'utf8'); + + const provider = new RecordingSessionFsProvider(new DiskSessionFsProvider(baseDir)); + + let session: CopilotSession; + try { + session = await client.resumeSession(sessionId, { + onPermissionRequest: approveAll, + createSessionFsProvider: () => provider, + workingDirectory: baseDir, + }); + } catch (err) { + assert.fail(`resumeSession failed. SessionFs accesses:\n ${provider.reads.join('\n ')}\nError: ${err instanceof Error ? err.message : String(err)}`); + } + + try { + const events: SessionEvent[] = await session.getEvents(); + const userMessages = events.filter(e => e.type === 'user.message').map(e => e.data.content); + assert.ok( + userMessages.some(c => c.includes('What is 2+2?')) && userMessages.some(c => c.includes('And 3+3?')), + `expected both imported prompts in reconstructed history, got: ${JSON.stringify(userMessages)}\nSessionFs accesses:\n ${provider.reads.join('\n ')}`, + ); + + // Editability: truncating at the first imported user turn removes it + // and everything after — only possible because these are real events. + const firstUser = events.find(e => e.type === 'user.message'); + assert.ok(firstUser, 'expected a reconstructed user.message event'); + const truncate = await session.rpc.history.truncate({ eventId: firstUser.id }); + assert.ok(truncate.eventsRemoved >= 1, `expected truncate to remove events, removed ${truncate.eventsRemoved}`); + } finally { + await session.disconnect().catch(() => { }); + } + }); +}); + +/** + * Validates the PRODUCTION import seam: `SessionConfigBase.configDirectory`. + * + * Unlike the `SessionFsProvider` route above (which requires flipping the + * client-level `sessionFs` master switch — routing *all* sessions through a + * provider and dropping native SQLite/todo support), `configDirectory` is a + * per-session override. We seed a synthesized `events.jsonl` at the CLI's + * native on-disk layout under a per-session `configDirectory`, then resume + * with an ordinary client (no `sessionFs`), leaving every other session's + * storage untouched. The test first creates a throwaway session to *discover* + * the exact native layout, then seeds a fresh session at that layout. + */ +(REAL_SDK_ENABLED ? suite : suite.skip)('Real Copilot SDK — import via configDirectory (native storage)', function () { + + this.timeout(120_000); + + let client: CopilotClient; + let root: string; + let configDir: string; + let workDir: string; + + suiteSetup(async function () { + root = await fs.mkdtemp(join(tmpdir(), 'ahp-import-cfg-')); + configDir = join(root, 'config'); + workDir = join(root, 'work'); + await fs.mkdir(configDir, { recursive: true }); + await fs.mkdir(workDir, { recursive: true }); + const cliPath = await resolveCopilotCliPath(); + client = new CopilotClient({ + // Deliberately NO `sessionFs`: native on-disk storage, redirected + // per session via `configDirectory` — the low-risk production seam. + useLoggedInUser: false, + gitHubToken: resolveGitHubToken(), + connection: RuntimeConnection.forStdio({ path: cliPath }), + env: await buildCliEnv(), + logLevel: 'error', + }); + await client.start(); + }); + + suiteTeardown(async function () { + await client?.stop().catch(() => { }); + if (root) { + await fs.rm(root, { recursive: true, force: true }).catch(() => { }); + } + }); + + test('seeding events.jsonl under configDirectory resumes as real, editable turns', async function () { + // Phase 1 — discover the native events.jsonl layout by creating a + // throwaway session and inspecting what the CLI writes on disk. + const discoverId = generateUuid(); + try { + const throwaway = await client.createSession({ + sessionId: discoverId, + configDirectory: configDir, + workingDirectory: workDir, + onPermissionRequest: approveAll, + }); + await throwaway.disconnect().catch(() => { }); + } catch { + // Best-effort discovery; fall back to the assumed layout below. + } + const discoveredRel = (await walkFiles(configDir)).find(f => f.endsWith('events.jsonl') && f.includes(discoverId)); + + // Phase 2 — seed a fresh session's events.jsonl at the discovered + // layout (or the assumed one) and resume it with the normal client. + const importId = generateUuid(); + const turns: Turn[] = [ + userTurn('turn-a', 'What is 2+2? Reply with just the number.', 'It is 4.'), + userTurn('turn-b', 'And 3+3? Reply with just the number.', 'It is 6.'), + ]; + const jsonl = buildSessionEventLogFromTurns(turns, { sessionId: importId, workingDirectory: workDir }); + const seedRel = discoveredRel + ? discoveredRel.replace(discoverId, importId) + : join('session-state', importId, 'events.jsonl'); + const seedPath = join(configDir, seedRel); + await fs.mkdir(dirname(seedPath), { recursive: true }); + await fs.writeFile(seedPath, jsonl, 'utf8'); + + let session: CopilotSession; + try { + session = await client.resumeSession(importId, { + configDirectory: configDir, + workingDirectory: workDir, + onPermissionRequest: approveAll, + }); + } catch (err) { + const tree = (await walkFiles(configDir)).join('\n '); + assert.fail(`resumeSession(configDirectory) failed.\nDiscovered layout: ${discoveredRel ?? '(none — used assumed layout)'}\nSeeded at: ${seedRel}\nconfigDir tree:\n ${tree}\nError: ${err instanceof Error ? err.message : String(err)}`); + } + + try { + const events: SessionEvent[] = await session.getEvents(); + const userMessages = events.filter(e => e.type === 'user.message').map(e => e.data.content); + assert.ok( + userMessages.some(c => c.includes('What is 2+2?')) && userMessages.some(c => c.includes('And 3+3?')), + `expected both imported prompts in reconstructed history, got: ${JSON.stringify(userMessages)}\nSeeded at: ${seedRel}`, + ); + + const firstUser = events.find(e => e.type === 'user.message'); + assert.ok(firstUser, 'expected a reconstructed user.message event'); + const truncate = await session.rpc.history.truncate({ eventId: firstUser.id }); + assert.ok(truncate.eventsRemoved >= 1, `expected truncate to remove events, removed ${truncate.eventsRemoved}`); + } finally { + await session.disconnect().catch(() => { }); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts index f0925c97e051f3..6c09d20777e1e8 100644 --- a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdk.integrationTest.ts @@ -58,10 +58,12 @@ defineSharedRealSdkTests(COPILOT_CONFIG); let client: TestProtocolClient; const createdSessions: string[] = []; const tempDirs: string[] = []; + let userHomeDir: string; suiteSetup(async function () { this.timeout(60_000); - server = await startRealServer(); + userHomeDir = await mkdtemp(`${tmpdir()}/ahp-customizations-home-`); + server = await startRealServer({ homeDir: userHomeDir }); }); suiteTeardown(function () { @@ -70,6 +72,9 @@ defineSharedRealSdkTests(COPILOT_CONFIG); setup(async function () { this.timeout(30_000); + if (!tempDirs.includes(userHomeDir)) { + tempDirs.push(userHomeDir); + } client = new TestProtocolClient(server.port); await client.connect(); }); @@ -93,8 +98,10 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('usage reports include Copilot cost metadata', async function () { this.timeout(120_000); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-cost-report-')); + tempDirs.push(workingDirectory); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-usage', createdSessions, URI.file(tmpdir()).toString()); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-usage', createdSessions, URI.file(workingDirectory)); dispatchTurn(client, sessionUri, 'turn-usage', 'Reply with exactly "usage-ok" and do not use tools.', 1); const usageNotif = await client.waitForNotification(n => isActionNotification(n, 'chat/usage'), 90_000); @@ -122,15 +129,15 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('attaches a Python file and reads its function names', async function () { this.timeout(120_000); - const tempDir = await mkdtemp(`${tmpdir()}/ahp-attachment-test-`); - tempDirs.push(tempDir); - const filePath = join(tempDir, 'calculator.py'); + const workingDirectory = await mkdtemp(`${tmpdir()}/ahp-attachment-test-`); + tempDirs.push(workingDirectory); + const filePath = join(workingDirectory, 'calculator.py'); await writeFile(filePath, [ 'def add(a, b):', '\treturn a + b', ].join('\n')); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-attachment', createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-attachment', createdSessions, URI.file(workingDirectory)); const prompt = 'Read the attached Python file. What function names are defined in it? Reply with only the function names.'; const attachments: MessageAttachment[] = [{ type: MessageAttachmentKind.Resource, @@ -144,10 +151,13 @@ defineSharedRealSdkTests(COPILOT_CONFIG); assert.match(result.responseText, /\badd\b/i, `expected the model to identify the attached file function; got: ${JSON.stringify(result.responseText)}`); }); - test.skip('attaches a text blob and reads its function names', async function () { + test('attaches a text blob and reads its function names', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-blob-attachment', createdSessions, URI.file(tmpdir()).toString()); + const workingDirectory = await mkdtemp(join(tmpdir(), 'copilot-text-blob-')); + tempDirs.push(workingDirectory); + + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-blob-attachment', createdSessions, URI.file(workingDirectory)); const prompt = 'Read the attached Python text blob. What function names are defined in it? Reply with only the function names.'; const attachments: MessageAttachment[] = [{ type: MessageAttachmentKind.Simple, @@ -167,10 +177,10 @@ defineSharedRealSdkTests(COPILOT_CONFIG); test('strips redundant `cd <workingDirectory> &&` prefix from shell tool calls', async function () { this.timeout(180_000); - const tempDir = await mkdtemp(`${tmpdir()}/ahp-cd-strip-test-`); - tempDirs.push(tempDir); - const expectedWorkingDirPath = tempDir; - const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-cd-strip', createdSessions, URI.file(tempDir).toString()); + const workspaceDir = await mkdtemp(`${tmpdir()}/ahp-cd-strip-test-`); + tempDirs.push(workspaceDir); + const expectedWorkingDirPath = workspaceDir; + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-cd-strip', createdSessions, URI.file(workspaceDir)); client.clearReceived(); dispatchTurn(client, sessionUri, 'turn-cd-strip', diff --git a/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts new file mode 100644 index 00000000000000..1f7d56aeca84e1 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/protocol/copilotRealSdkMocked.integrationTest.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Real Copilot SDK integration tests running on a mocked LLM. + */ + +import assert from 'assert'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'os'; +import { URI } from '../../../../../base/common/uri.js'; +import { ResponsePartKind } from '../../../common/state/sessionState.js'; +import { createRealSession, dispatchTurn, IRealSdkProviderConfig } from './realSdkTestHelpers.js'; +import { fetchSessionWithChat, isActionNotification, IServerHandle, startRealServer, TestProtocolClient } from './testHelpers.js'; + +export const COPILOT_CONFIG: IRealSdkProviderConfig = { + suiteTitle: 'Protocol WebSocket — Real Copilot SDK Mocked LLM', + provider: 'copilotcli', + scheme: 'copilotcli', + shellToolName: 'bash', + subagentToolNames: ['task'], + exitPlanModeToolName: 'exit_plan_mode', + enabled: true, + supportsWorktreeIsolation: true, + supportsSubagents: true, + supportsPlanMode: true, + githubToken: 'not-a-real-token', // The tests will use a mocked LLM, so the token doesn't need to be valid. +}; + +suite('Protocol WebSocket — Real Copilot SDK, Mocked LLM (Copilot-specific)', function () { + + let server: IServerHandle; + let client: TestProtocolClient; + const createdSessions: string[] = []; + const tempDirs: string[] = []; + + suiteSetup(async function () { + this.timeout(120_000); + server = await startRealServer({ mockLlm: true }); + }); + + suiteTeardown(function () { + server?.process.kill(); + }); + + setup(async function () { + this.timeout(120_000); + client = new TestProtocolClient(server.port); + await client.connect(); + }); + + teardown(async function () { + for (const session of createdSessions) { + try { + await client.call('disposeSession', { session }, 5000); + } catch { /* best-effort */ } + } + createdSessions.length = 0; + client.close(); + + for (const dir of tempDirs) { + try { + await rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); + } catch { /* best-effort */ } + } + tempDirs.length = 0; + }); + + test('returns a hello response via mock LLM', async function () { + this.timeout(180_000); + + const probeToken = 'MOCK_REQUEST_PROBE_12345'; + const workspaceDir = await mkdtemp(`${tmpdir()}/test-mock-hello`); + tempDirs.push(workspaceDir); + const sessionUri = await createRealSession(client, COPILOT_CONFIG, 'real-sdk-mock-hello', createdSessions, URI.file(workspaceDir)); + dispatchTurn(client, sessionUri, 'turn-mock-hello', `Reply with exactly: ${probeToken}`, 1); + try { + await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); + } catch (err) { + console.error(`Failed to receive chat/turnComplete notification within timeout: ${err}, receivedNotifications: ${JSON.stringify(client.receivedNotifications())}, logMessages: ${server.mockLlm?.logMessages.join('\n') ?? 'no mockllm server'}`); + throw new Error(`Failed to receive chat/turnComplete notification within timeout: ${err}, receivedNotifications: ${JSON.stringify(client.receivedNotifications())}, logMessages: ${server.mockLlm?.logMessages.join('\n') ?? 'no mockllm server'}`); + } + + assert.ok((server.mockLlm?.requestCount() ?? 0) >= 1, 'expected at least one request to the mock LLM'); + + const state = await fetchSessionWithChat(client, sessionUri); + + const turn = state.turns.find(t => t.id === 'turn-mock-hello'); + const markdownText = turn?.responseParts.map(p => p.kind === ResponsePartKind.Markdown ? p.content : '').join('\n') ?? ``; + assert.ok(markdownText.trim().length > 0, `expected non-empty assistant markdown; got: ${JSON.stringify(markdownText)}`); + assert.match(markdownText, new RegExp(`\\b${probeToken}\\b`, 'i'), `expected probe token in assistant markdown; got: ${JSON.stringify(markdownText)}`); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts index a8c6e738edf6ce..5b46f517ab53c2 100644 --- a/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/realSdkTestHelpers.ts @@ -39,7 +39,7 @@ import { type ChatToolCallStartAction, } from '../../../common/state/sessionActions.js'; import type { SessionAddedParams } from '../../../common/state/protocol/notifications.js'; -import { AgentHostConfigKey } from '../../../common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey } from '../../../common/copilotCliConfig.js'; import { getActionEnvelope, isActionNotification, fetchSessionWithChat, IServerHandle, startRealServer, TestProtocolClient, } from './testHelpers.js'; @@ -127,6 +127,11 @@ export interface IRealSdkProviderConfig { * shared test prompt doesn't reliably drive it to `ExitPlanMode`. */ readonly supportsPlanMode: boolean; + + /** + * The github token to use. If not provided, the test will attempt to resolve it from the environment or `gh auth token`. + */ + readonly githubToken?: string; } // #endregion @@ -139,10 +144,10 @@ export async function createRealSession( config: IRealSdkProviderConfig, clientId: string, trackingList: string[], - workingDirectory?: string, + workingDirectory: URI, ): Promise<string> { await c.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }, 30_000); - await c.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: resolveGitHubToken() }, 30_000); + await c.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: config.githubToken ?? resolveGitHubToken() }, 30_000); const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); // Default to `folder` isolation so the agent runs in the directory the @@ -152,7 +157,7 @@ export async function createRealSession( await c.call('createSession', { channel: sessionUri, provider: config.provider, - workingDirectory, + workingDirectory: workingDirectory.toString(), config: workingDirectory ? { isolation: 'folder' } : undefined, }, 30_000); @@ -552,7 +557,10 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { test('sends a simple message and receives a response', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, config, `real-sdk-simple-${config.provider}`, createdSessions, URI.file(tmpdir()).toString()); + const workspaceDir = mkdtempSync(`${tmpdir()}/read-sdk-simple`); + tempDirs.push(workspaceDir); + + const sessionUri = await createRealSession(client, config, `real-sdk-simple-${config.provider}`, createdSessions, URI.file(workspaceDir)); dispatchTurn(client, sessionUri, 'turn-1', 'Say exactly "hello" and nothing else', 1); const complete = await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete'), 90_000); @@ -626,7 +634,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const tempDir = mkdtempSync(`${tmpdir()}/ahp-perm-test-`); tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, config, `real-sdk-permission-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-permission-${config.provider}`, createdSessions, URI.file(tempDir)); dispatchTurn(client, sessionUri, 'turn-perm', 'Run the shell command: echo "hello from test"', 1); // Validate the permission flow by driving toward the first signal @@ -678,7 +686,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { const tempDir = mkdtempSync(`${tmpdir()}/ahp-plan-test-`); tempDirs.push(tempDir); - const sessionUri = await createRealSession(client, config, `real-sdk-plan-mode-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-plan-mode-${config.provider}`, createdSessions, URI.file(tempDir)); client.dispatch({ channel: sessionUri, @@ -723,7 +731,10 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { test('can abort a running turn', async function () { this.timeout(120_000); - const sessionUri = await createRealSession(client, config, `real-sdk-abort-${config.provider}`, createdSessions, URI.file(tmpdir()).toString()); + const tempDir = mkdtempSync(`${tmpdir()}/ahp-abort-`); + tempDirs.push(tempDir); + + const sessionUri = await createRealSession(client, config, `real-sdk-abort-${config.provider}`, createdSessions, URI.file(tempDir)); dispatchTurn(client, sessionUri, 'turn-abort', 'Write a very long essay about the history of computing', 1); await client.waitForNotification( @@ -784,7 +795,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { client.dispatch({ channel: ROOT_STATE_URI, clientSeq: 0, - action: { type: ActionType.RootConfigChanged, config: { [AgentHostConfigKey.EnableCustomTerminalTool]: true } }, + action: { type: ActionType.RootConfigChanged, config: { [CopilotCliConfigKey.EnableCustomTerminalTool]: true } }, }); const sessionUri = URI.from({ scheme: config.scheme, path: `/${generateUuid()}` }).toString(); @@ -897,7 +908,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); writeFileSync(`${tempDir}/file-b.txt`, 'beta'); - const sessionUri = await createRealSession(client, config, `real-sdk-subagent-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-subagent-${config.provider}`, createdSessions, URI.file(tempDir)); const sessionChatUri = buildDefaultChatUri(sessionUri); let approvalsActive = true; @@ -997,7 +1008,7 @@ export function defineSharedRealSdkTests(config: IRealSdkProviderConfig): void { writeFileSync(`${tempDir}/file-a.txt`, 'alpha'); writeFileSync(`${tempDir}/file-b.txt`, 'beta'); - const sessionUri = await createRealSession(client, config, `real-sdk-subagent-replay-${config.provider}`, createdSessions, URI.file(tempDir).toString()); + const sessionUri = await createRealSession(client, config, `real-sdk-subagent-replay-${config.provider}`, createdSessions, URI.file(tempDir)); const sessionChatUri = buildDefaultChatUri(sessionUri); // A unique phrase that only the subagent is asked to emit in an diff --git a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts index 863ed192efa7a6..50e14a47640fdc 100644 --- a/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts +++ b/src/vs/platform/agentHost/test/node/protocol/testHelpers.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { ChildProcess, fork } from 'child_process'; +import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { WebSocket } from 'ws'; import { URI } from '../../../../../base/common/uri.js'; @@ -207,6 +208,51 @@ export class TestProtocolClient { export interface IServerHandle { process: ChildProcess; port: number; + /** Present when the server was started with a mock LLM; exposes request count for assertions. */ + mockLlm?: IMockLlmServerHandleWithLog; +} + +interface IMockLlmServerHandle { + readonly url: string; + requestCount(): number; + getRequests?(): readonly unknown[]; + close(): Promise<void>; +} + +interface IMockLlmServerHandleWithLog extends IMockLlmServerHandle { + logMessages: string[]; +} + +interface IMockLlmServerModule { + startServer(port: number, options?: { logger?: (msg: string) => void; verbose?: boolean; captureRequests?: boolean }): Promise<IMockLlmServerHandle>; + registerScenario(id: string, definition: unknown): void; +} + +function buildCopilotChatToken(mockUrl: string, copilotPlan: 'free' | 'pro' = 'free'): string { + return Buffer.from(JSON.stringify({ + token: 'smoketest-fake-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + refresh_in: 1800, + sku: copilotPlan === 'pro' ? 'individual_subscription_copilot' : 'free_limited_copilot', + individual: true, + isNoAuthUser: true, + copilot_plan: copilotPlan, + organization_login_list: [], + endpoints: { api: mockUrl, proxy: mockUrl }, + })).toString('base64'); +} + +async function startMockLlmServer(): Promise<IMockLlmServerHandleWithLog> { + const mockServerPath = fileURLToPath(new URL('../../../../../../../scripts/chat-simulation/common/mock-llm-server.ts', import.meta.url)); + const nodeRequire = createRequire(import.meta.url); + const mockModule = nodeRequire(mockServerPath) as IMockLlmServerModule; + mockModule.registerScenario('text-only', { + type: 'multi-turn', + turns: [{ kind: 'echo-last-message' }], + }); + const messages: string[] = []; + const serverHandle = await mockModule.startServer(0, { logger: msg => messages.push(msg), verbose: true, captureRequests: true }); + return { ...serverHandle, logMessages: messages }; } export async function startServer(options?: { readonly quiet?: boolean; readonly userDataDir?: string; readonly env?: NodeJS.ProcessEnv }): Promise<IServerHandle> { @@ -255,10 +301,11 @@ export async function startServer(options?: { readonly quiet?: boolean; readonly } /** - * Start the agent host server with the real Copilot SDK agent (no mock agent). + * Start the agent host server with the Copilot SDK agent with either a real or mocked LLM. * The server is started with logging enabled so the CopilotAgent is registered. */ -export async function startRealServer(options?: { readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string }): Promise<IServerHandle> { +export async function startRealServer(options?: { readonly claudeSdkRoot?: string; readonly codexSdkRoot?: string; readonly mockLlm?: boolean; readonly homeDir?: string; readonly env?: NodeJS.ProcessEnv }): Promise<IServerHandle> { + const mockLlmServer = options?.mockLlm ? await startMockLlmServer() : undefined; return new Promise((resolve, reject) => { const serverPath = fileURLToPath(new URL('../../../node/agentHostServerMain.js', import.meta.url)); const args = ['--port', '0', '--without-connection-token']; @@ -268,17 +315,64 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin if (options?.codexSdkRoot) { args.push('--codex-sdk-root', options.codexSdkRoot); } - const child = fork(serverPath, args, { - stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + const childEnv = { + ...process.env, + ...(options?.env ?? {}), + ...(options?.homeDir ? { + HOME: options.homeDir, + USERPROFILE: options.homeDir, + } : {}), // Codex defaults to disabled; opt it in for the real-SDK suite when a // codex SDK root is supplied so the provider actually registers. - env: options?.codexSdkRoot - ? { ...process.env, [AgentHostCodexAgentEnabledEnvVar]: 'true' } - : process.env, + ...(options?.codexSdkRoot ? { [AgentHostCodexAgentEnabledEnvVar]: 'true' } : {}), + ...(mockLlmServer ? { + GITHUB_PAT: 'smoketest-fake-pat', + IS_SCENARIO_AUTOMATION: '1', + // Real-SDK Copilot tests run against responses-capable models + // (e.g. gpt-5.3-codex) that are "pro"-gated in the mock /models + // fixture, so mint a pro-plan token for this harness. + VSCODE_COPILOT_CHAT_TOKEN: buildCopilotChatToken(mockLlmServer.url, 'pro'), + // Route the Copilot SDK's GitHub API calls (token refresh, model + // discovery, etc.) at the mock instead of api.github.com, which + // would 401 with the fake token. + COPILOT_DEBUG_GITHUB_API_URL: mockLlmServer.url, + COPILOT_API_URL: mockLlmServer.url, + GITHUB_COPILOT_API_TOKEN: 'smoketest-fake-agent-host-token', + // Route the agent host's shared CAPI client (used by the Codex / + // agent-host harnesses for model discovery + requests) at the mock + // instead of api.github.com, which would 401 with the fake token. + VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE: mockLlmServer.url, + } : {}), + }; + let child: ChildProcess; + try { + child = fork(serverPath, args, { + stdio: ['pipe', 'pipe', 'pipe', 'ipc'], + env: childEnv, + }); + } catch (err) { + void mockLlmServer?.close(); + throw err; + } + let mockClosed = false; + const closeMockServer = async (): Promise<void> => { + if (mockClosed || !mockLlmServer) { + return; + } + mockClosed = true; + try { + await mockLlmServer.close(); + } catch { + // best effort + } + }; + child.on('exit', () => { + void closeMockServer(); }); const timer = setTimeout(() => { child.kill(); + void closeMockServer(); reject(new Error('Real server startup timed out')); }, 30_000); @@ -287,7 +381,7 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin const match = text.match(/READY:(\d+)/); if (match) { clearTimeout(timer); - resolve({ process: child, port: parseInt(match[1], 10) }); + resolve({ process: child, port: parseInt(match[1], 10), mockLlm: mockLlmServer }); } }); @@ -300,11 +394,13 @@ export async function startRealServer(options?: { readonly claudeSdkRoot?: strin child.on('error', err => { clearTimeout(timer); + void closeMockServer(); reject(err); }); child.on('exit', code => { clearTimeout(timer); + void closeMockServer(); reject(new Error(`Real server exited prematurely with code ${code}`)); }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts index 71a9280401b70e..a1d23e61a999ff 100644 --- a/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/turnExecution.integrationTest.ts @@ -125,7 +125,7 @@ suite('Protocol WebSocket — Turn Execution', function () { assert.strictEqual(state.turns[1].id, 'turn-m2'); }); - test('fetchTurns returns completed turn history', async function () { + test('fetchTurns acknowledges completed turn history loading', async function () { this.timeout(15_000); const sessionUri = await createAndSubscribeSession(client, 'test-fetchTurns'); @@ -137,9 +137,13 @@ suite('Protocol WebSocket — Turn Execution', function () { await new Promise(resolve => setTimeout(resolve, 200)); await client.waitForNotification(n => isActionNotification(n, 'chat/turnComplete')); - const result = await client.call<FetchTurnsResult>('fetchTurns', { channel: sessionUri, limit: 10 }); - assert.ok(result.turns.length >= 2); - assert.strictEqual(typeof result.hasMore, 'boolean'); + const loadedPromise = client.waitForNotification(n => isActionNotification(n, 'chat/turnsLoaded')); + const result = await client.call<FetchTurnsResult>('fetchTurns', { channel: defaultChatChannel(sessionUri) }); + assert.deepStrictEqual(result, {}); + const loaded = await loadedPromise; + const action = getActionEnvelope(loaded).action as { type: string; turns: unknown[]; turnsNextCursor?: string }; + assert.deepStrictEqual(action.turns, []); + assert.strictEqual(action.turnsNextCursor, undefined); }); test('usage info is captured on completed turn', async function () { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 7687a04c0c62b8..896750c35fd0c0 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../log/common/log.js'; import { FileType } from '../../../files/common/files.js'; import { type IAgentCreateSessionConfig, type IAgentResolveSessionConfigParams, type IAgentService, type IAgentSessionConfigCompletionsParams, type IAgentSessionMetadata, type AuthenticateParams, type AuthenticateResult } from '../../common/agentService.js'; import { CompletionsParams, CompletionsResult, ContentEncoding, ListSessionsResult, ResourceReadResult, ResolveSessionConfigResult, SessionConfigCompletionsResult, ResourceMkdirParams, ResourceMkdirResult, ResourceResolveParams, ResourceResolveResult, ResourceCopyParams, ResourceCopyResult } from '../../common/state/protocol/commands.js'; -import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction } from '../../common/state/sessionActions.js'; +import { ActionType, type IRootConfigChangedAction, type SessionAction, type TerminalAction, type ClientAnnotationsAction, type ProgressParams } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, type SessionSummary } from '../../common/state/sessionState.js'; @@ -1990,6 +1990,70 @@ suite('ProtocolServerHandler', () => { }); }); + suite('download progress channel', () => { + // Progress is emitted on the state manager (so it reaches both local + // IPC and remote WebSocket renderers through the same path as session + // notifications). This suite verifies the handler forwards each frame to + // connected clients as a `progress` notification on the root channel. + // Spun up per-test with a private state manager so the outer suite is + // unaffected. + let dlStateManager: AgentHostStateManager; + let dlServer: MockProtocolServer; + let dlAgentService: MockAgentService; + let localDisposables: DisposableStore; + + setup(() => { + localDisposables = new DisposableStore(); + dlStateManager = localDisposables.add(new AgentHostStateManager(new NullLogService())); + dlServer = localDisposables.add(new MockProtocolServer()); + dlAgentService = new MockAgentService(); + dlAgentService.setStateManager(dlStateManager); + localDisposables.add(dlAgentService); + localDisposables.add(new ProtocolServerHandler( + dlAgentService, + dlStateManager, + dlServer, + { defaultDirectory: URI.file('/home/testuser').toString() }, + localDisposables.add(new AgentHostFileSystemProvider()), + new NullLogService(), + )); + }); + + teardown(() => { + localDisposables.dispose(); + }); + + function connectDownloadClient(clientId: string): MockProtocolTransport { + const transport = new MockProtocolTransport(); + dlServer.simulateConnection(transport); + transport.simulateMessage(request(1, 'initialize', { + protocolVersions: [PROTOCOL_VERSION], + clientId, + })); + return transport; + } + + function findProgress(sent: ProtocolMessage[]): ProgressParams[] { + return sent + .filter(isJsonRpcNotification) + .filter((m): m is AhpNotification & { method: 'root/progress'; params: ProgressParams } => m.method === 'root/progress') + .map(m => m.params); + } + + test('forwards each progress frame to connected clients on the root channel', () => { + const transport = connectDownloadClient('client-dl-1'); + + dlStateManager.emitProgress({ progressToken: 't1', progress: 0, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 500, total: 1000, message: 'Claude' }); + dlStateManager.emitProgress({ progressToken: 't1', progress: 1000, total: 1000, message: 'Claude' }); + + const frames = findProgress(transport.sent); + assert.deepStrictEqual(frames.map(f => f.progress), [0, 500, 1000]); + assert.ok(frames.every(f => f.progressToken === 't1' && f.message === 'Claude' && f.total === 1000)); + assert.ok(frames.every(f => (f as ProgressParams & { channel: string }).channel === 'ahp-root://'), 'frames are broadcast on the root channel'); + }); + }); + suite('resource watches', () => { test('subscribe to a resource-watch channel returns the descriptor + bumps refcount; envelopes are routed', async () => { diff --git a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts index aa4821096879c1..5a59b1ef94897b 100644 --- a/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionCustomizationDiscovery.test.ts @@ -120,6 +120,7 @@ suite('SessionCustomizationDiscovery', () => { await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); await seed('/home/.copilot/agents/abc.agent.md', 'user agent abc'); await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); + await seed('/home/.copilot/skills/alpha/SKILL.md', 'user copilot skill'); await seed('/home/.agents/skills/aaa/SKILL.md', 'user skill aaa'); await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); @@ -162,6 +163,7 @@ suite('SessionCustomizationDiscovery', () => { await seed('/workspace/.github/copilot-instructions.md', 'workspace copilot instructions'); await seed('/workspace/.claude/CLAUDE.md', 'workspace claude instruction'); await seed('/home/.copilot/agents/user.agent.md', 'user agent'); + await seed('/home/.copilot/skills/copilot-user-skill/SKILL.md', 'user copilot skill'); await seed('/home/.agents/skills/user-skill/SKILL.md', 'user skill'); await seed('/home/.copilot/instructions/user.instructions.md', 'user instruction'); await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); @@ -192,6 +194,7 @@ suite('SessionCustomizationDiscovery', () => { assert.strictEqual(watched.get(URI.joinPath(workspace, '.github', 'hooks').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot').toString()), false); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'agents').toString()), false); + assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.agents', 'skills').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'instructions').toString()), true); assert.strictEqual(watched.get(URI.joinPath(userHome, '.copilot', 'hooks').toString()), true); @@ -364,6 +367,7 @@ suite('SessionCustomizationDiscovery', () => { const wsInstr = await seed('/workspace/.github/instructions/baz.instructions.md', 'instr body'); const wsHook = await seed('/workspace/.github/hooks/pre-tool.json', '{"PreToolUse": []}'); const userAgent = await seed('/home/.copilot/agents/qux.agent.md', 'user agent'); + const userCopilotSkill = await seed('/home/.copilot/skills/copilot-zap/SKILL.md', 'user copilot skill'); const userSkill = await seed('/home/.agents/skills/zap/SKILL.md', 'user skill'); const userHook = await seed('/home/.copilot/hooks/post-tool.json', '{"PostToolUse": []}'); // Noise that should not be picked up @@ -376,6 +380,7 @@ suite('SessionCustomizationDiscovery', () => { assert.deepStrictEqual([...files].sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())), [ { uri: userAgent, type: DiscoveredType.Agent }, + { uri: userCopilotSkill, type: DiscoveredType.Skill }, { uri: userHook, type: DiscoveredType.Hook }, { uri: userSkill, type: DiscoveredType.Skill }, { uri: wsAgent, type: DiscoveredType.Agent }, diff --git a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts index f57a37d664f33a..cf7a546a2eb36e 100644 --- a/src/vs/platform/agentHost/test/node/sessionDataService.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDataService.test.ts @@ -13,6 +13,7 @@ import { FileService } from '../../../files/common/fileService.js'; import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesystemProvider.js'; import { NullLogService } from '../../../log/common/log.js'; import { AgentSession } from '../../common/agentService.js'; +import { buildChatUri } from '../../common/state/sessionState.js'; import { SessionDataService } from '../../node/sessionDataService.js'; suite('SessionDataService', () => { @@ -43,6 +44,17 @@ suite('SessionDataService', () => { assert.strictEqual(dir.toString(), URI.joinPath(basePath, 'agentSessionData', 'foo-bar-baz-qux').toString()); }); + test('getSessionDataDir gives each peer chat of a session its own directory', () => { + const session = AgentSession.uri('copilotcli', 'session-1'); + const chatA = URI.parse(buildChatUri(session, 'chat-a')); + const chatB = URI.parse(buildChatUri(session, 'chat-b')); + const dirA = service.getSessionDataDir(chatA); + const dirB = service.getSessionDataDir(chatB); + assert.notStrictEqual(dirA.toString(), dirB.toString()); + // The plain session URI keeps its authority-free directory. + assert.strictEqual(service.getSessionDataDir(session).toString(), URI.joinPath(basePath, 'agentSessionData', 'session-1').toString()); + }); + test('deleteSessionData removes directory', async () => { const session = AgentSession.uri('copilot', 'session-1'); const dir = service.getSessionDataDir(session); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index af48bc6bd11970..db19777b3335ac 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -10,6 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; import { FileEditKind, MessageKind } from '../../common/state/sessionState.js'; +import type { IReviewedFileRecord } from '../../common/sessionDataService.js'; import type { Database } from '@vscode/sqlite3'; import { generateUuid } from '../../../../base/common/uuid.js'; import { join } from '../../../../base/common/path.js'; @@ -618,6 +619,85 @@ suite('SessionDatabase', () => { }); }); + // ---- reviewed files ------------------------------------------------- + + suite('reviewed files', () => { + const uriA = URI.parse('file:///workspace/a.ts'); + const uriB = URI.parse('file:///workspace/b.ts'); + + const normalize = (records: readonly IReviewedFileRecord[]) => records.map(r => ({ uri: r.uri.toString(), nonce: r.nonce })); + + test('markFileReviewed and isFileReviewed discriminate by uri and nonce', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual( + await Promise.all([ + db.isFileReviewed(uriA, 'n1'), + db.isFileReviewed(uriA, 'n2'), + db.isFileReviewed(uriB, 'n1'), + ]), + [true, false, false], + ); + }); + + test('getReviewedFiles returns all entries in insertion order', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriB.toString(), nonce: 'n2' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('getReviewedFilesForUri returns only the given uri', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriB, 'n2'); + await db.markFileReviewed(uriA, 'n3'); + + assert.deepStrictEqual(normalize(await db.getReviewedFilesForUri(uriA)), [ + { uri: uriA.toString(), nonce: 'n1' }, + { uri: uriA.toString(), nonce: 'n3' }, + ]); + }); + + test('unmarkFileReviewed removes an entry and is a no-op when absent', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); + await db.unmarkFileReviewed(uriA, 'n1'); // no-op, must not throw + + assert.deepStrictEqual( + await Promise.all([db.isFileReviewed(uriA, 'n1'), db.getReviewedFiles()]), + [false, []], + ); + }); + + test('marking the same (uri, nonce) twice keeps a single entry', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + + await db.markFileReviewed(uriA, 'n1'); + await db.markFileReviewed(uriA, 'n1'); + + assert.deepStrictEqual(normalize(await db.getReviewedFiles()), [{ uri: uriA.toString(), nonce: 'n1' }]); + }); + + test('migration v7 creates the reviewed_files table', async () => { + db = disposables.add(await SessionDatabase.open(':memory:')); + const tables = await db.getAllTables(); + assert.ok(tables.includes('reviewed_files')); + }); + }); + // ---- vacuumInto ----------------------------------------------------- suite('vacuumInto', () => { diff --git a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts index da81a8a17654a1..849d3e2f045fe3 100644 --- a/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/agentHostOctoKitService.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../log/common/log.js'; import { AgentHostOctoKitService, type FetchFunction } from '../../../node/shared/agentHostOctoKitService.js'; +import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; type Captured = { url: string; init: RequestInit | undefined }; @@ -17,8 +18,8 @@ function getUrl(input: string | URL | Request): string { return input instanceof URL ? input.href : input.url; } -function makeService(fetchImpl: FetchFunction): AgentHostOctoKitService { - return new AgentHostOctoKitService(fetchImpl, new NullLogService()); +function makeService(fetchImpl: FetchFunction, enterpriseUri?: string): AgentHostOctoKitService { + return new AgentHostOctoKitService(fetchImpl, new NullLogService(), createTestGitHubEndpointService(enterpriseUri)); } function signal(): AbortSignal { @@ -162,4 +163,22 @@ suite('AgentHostOctoKitService', () => { /GitHub GraphQL request failed: Pull request is in clean status/, ); }); + + test('routes REST calls to the GitHub Enterprise Server API base', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ html_url: 'https://ghe.acme.com/o/r/pull/7', number: 7, node_id: 'n' })); + const service = makeService(fetch, 'https://ghe.acme.com'); + + await service.createPullRequest('o', 'r', 'T', 'B', 'feature', 'main', false, 'tok', signal()); + + assert.strictEqual(captured().url, 'https://ghe.acme.com/api/v3/repos/o/r/pulls'); + }); + + test('routes GraphQL calls to the GitHub Enterprise Server GraphQL endpoint', async () => { + const { fetch, captured } = capturingFetch(jsonResponse({ data: { enablePullRequestAutoMerge: { pullRequest: { id: 'PR_1' } } } })); + const service = makeService(fetch, 'https://ghe.acme.com'); + + await service.enablePullRequestAutoMerge('PR_1', 'MERGE', 'tok', signal()); + + assert.strictEqual(captured().url, 'https://ghe.acme.com/api/graphql'); + }); }); diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts index 0bf26ea1be5646..33e7dfae9bd790 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.integrationTest.ts @@ -20,6 +20,7 @@ import { NullLogService } from '../../../../log/common/log.js'; import product from '../../../../product/common/product.js'; import { IProductService } from '../../../../product/common/productService.js'; import { CopilotApiService } from '../../../node/shared/copilotApiService.js'; +import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -34,7 +35,7 @@ suite('CopilotApiService.utilityChatCompletion (real CAPI)', () => { // `globalThis.fetch` through `this._fetch(...)` throws // "Illegal invocation" in the Electron renderer. const boundFetch: typeof globalThis.fetch = (...args) => globalThis.fetch(...args); - return new CopilotApiService(boundFetch, new NullLogService(), productService); + return new CopilotApiService(boundFetch, new NullLogService(), productService, createTestGitHubEndpointService()); } (hasToken ? test : test.skip)('answers a trivial arithmetic prompt', async function () { diff --git a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts index 4558221cf7ba30..a2706ac7d35567 100644 --- a/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/copilotApiService.test.ts @@ -8,6 +8,7 @@ import type Anthropic from '@anthropic-ai/sdk'; import { Iterable } from '../../../../../base/common/iterator.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { COPILOT_API_ERROR_STATUS_STREAMING, CopilotApiError, CopilotApiService, type FetchFunction } from '../../../node/shared/copilotApiService.js'; +import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; import { NullLogService } from '../../../../log/common/log.js'; import { IProductService } from '../../../../product/common/productService.js'; import product from '../../../../product/common/product.js'; @@ -84,8 +85,8 @@ function modelsResponse(models: object[]): Response { }); } -function createService(fetchImpl: FetchFunction): CopilotApiService { - return new CopilotApiService(fetchImpl, new NullLogService(), testProductService); +function createService(fetchImpl: FetchFunction, enterpriseUri?: string): CopilotApiService { + return new CopilotApiService(fetchImpl, new NullLogService(), testProductService, createTestGitHubEndpointService(enterpriseUri)); } type CapturedRequest = { url: string; init: RequestInit | undefined }; @@ -272,6 +273,21 @@ suite('CopilotApiService', () => { assert.strictEqual(capturedAuthHeader, 'Bearer my-secret-gh-token'); }); + test('routes endpoint discovery to the GitHub Enterprise host when configured', async () => { + let discoveryUrl: string | undefined; + const service = createService(async (input) => { + const url = getUrl(input); + if (url.includes('/copilot_internal')) { + discoveryUrl = url; + return tokenResponse(); + } + return anthropicResponse([{ type: 'text', text: 'ok' }]); + }, 'https://acme.ghe.com'); + + await service.messages('gh-tok', baseRequest); + assert.strictEqual(discoveryUrl, 'https://api.acme.ghe.com/copilot_internal/user'); + }); + test('throws on 403 from endpoint discovery', async () => { const service = createService(async () => new Response('{"message":"Not authorized"}', { status: 403, statusText: 'Forbidden' })); await assert.rejects( diff --git a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts index de5543056c9726..68802f922f3066 100644 --- a/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/editSurvivalReporter.test.ts @@ -15,6 +15,7 @@ import { InMemoryFileSystemProvider } from '../../../../files/common/inMemoryFil import { NullLogService } from '../../../../log/common/log.js'; import { NullTelemetryServiceShape } from '../../../../telemetry/common/telemetryUtils.js'; import { EditSurvivalReporterFactory } from '../../../node/shared/editSurvivalReporter.js'; +import { buildDefaultChatUri } from '../../../common/state/sessionState.js'; class RecordingTelemetryService extends NullTelemetryServiceShape { readonly events: Array<{ name: string; data: unknown }> = []; @@ -83,6 +84,28 @@ suite('agentHost editSurvivalReporter', () => { assert.strictEqual(data.aiCharCount, 'after-text'.length); }); + test('resolves ahp-chat sub-channel URIs back to the parent harness', async () => { + await fileService.writeFile(URI.file('/workspace/b.ts'), VSBuffer.fromString('after-text')); + + const reporter = factory.launch({ + sessionUri: buildDefaultChatUri('claude:/session-9'), + turnId: 'turn-1', + toolCallId: 'tc-9', + filePath: '/workspace/b.ts', + beforeText: 'before-text', + afterText: 'after-text', + isCreate: false, + aiChunks: ['after-text'], + }); + disposables.add(reporter); + + await timeout(50); + + const data = telemetry.events[0].data as Record<string, unknown>; + assert.strictEqual(data.provider, 'claude'); + assert.strictEqual(data.agentSessionId, 'session-9'); + }); + test('emits a delete event when the file is missing', async () => { const reporter = factory.launch({ sessionUri: 'codex:/session-2', diff --git a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts index e5f87262f7ef6b..0285f128e8dc62 100644 --- a/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts +++ b/src/vs/platform/agentHost/test/node/shared/mcpCustomizationController.test.ts @@ -6,9 +6,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { ActionType } from '../../../common/state/protocol/common/actions.js'; -import { CustomizationType, McpServerStatus, type Customization, type McpServerState } from '../../../common/state/protocol/channels-session/state.js'; +import { CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerState } from '../../../common/state/protocol/channels-session/state.js'; import type { SessionAction } from '../../../common/state/sessionActions.js'; -import { McpCustomizationController, findMcpChildId, parseMcpChannelUri, type ISdkMcpServer } from '../../../node/shared/mcpCustomizationController.js'; +import { McpCustomizationController, findMcpChildId, findMcpServerName, parseMcpChannelUri, type ISdkMcpServer } from '../../../node/shared/mcpCustomizationController.js'; function harness(opts: { customizations?: readonly Customization[] } = {}) { const actions: SessionAction[] = []; @@ -28,6 +28,17 @@ function server(name: string, state: McpServerState): ISdkMcpServer { function ready(): McpServerState { return { kind: McpServerStatus.Ready }; } function starting(): McpServerState { return { kind: McpServerStatus.Starting }; } function stopped(): McpServerState { return { kind: McpServerStatus.Stopped }; } +function authRequired(): McpServerState { + return { + kind: McpServerStatus.AuthRequired, + reason: McpAuthRequiredReason.Required, + resource: { + resource: 'https://mcp.example.com', + authorization_servers: ['https://auth.example.com'], + }, + requiredScopes: ['repo'], + }; +} function errored(message: string): McpServerState { return { kind: McpServerStatus.Error, error: { errorType: 'test-error', message } }; } @@ -197,6 +208,24 @@ suite('McpCustomizationController', () => { ]); }); + test('runtimeStates snapshots child and top-level servers by customization id', () => { + const { controller } = harness({ customizations: PLUGIN_CUSTOMIZATIONS }); + store.add(controller); + + controller.applyOne(server('fs', ready())); + controller.applyOne(server('search', starting())); + + assert.deepStrictEqual(controller.runtimeStates.get(), new Map([ + ['mcp-child:demo:fs', { state: { kind: McpServerStatus.Ready }, channel: 'mcp://copilot/session-1/fs' }], + ['mcp-top-level:copilot:session-1:search', { state: { kind: McpServerStatus.Starting }, channel: undefined }], + ])); + assert.strictEqual(controller.serverNameForCustomizationId('mcp-child:demo:fs'), 'fs'); + assert.strictEqual(controller.serverNameForCustomizationId('mcp-top-level:copilot:session-1:search'), 'search'); + + controller.remove('fs'); + assert.deepStrictEqual([...controller.runtimeStates.get().keys()], ['mcp-top-level:copilot:session-1:search']); + }); + test('top-level entry stays top-level across updates (id stable)', () => { const { controller, actions } = harness(); store.add(controller); @@ -212,6 +241,37 @@ suite('McpCustomizationController', () => { assert.deepStrictEqual(ids, [expectedId, expectedId, expectedId]); }); + test('authRequired state is preserved across coarse starting updates', () => { + const { controller, actions } = harness({ customizations: PLUGIN_CUSTOMIZATIONS }); + store.add(controller); + + const authState = authRequired(); + controller.applyOne(server('fs', authState)); + controller.applyOne(server('fs', starting())); + controller.applyOne(server('fs', ready())); + + assert.deepStrictEqual(actions, [ + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: authState, + channel: undefined, + }, + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: authState, + channel: undefined, + }, + { + type: ActionType.SessionMcpServerStateChanged, + id: 'mcp-child:demo:fs', + state: { kind: McpServerStatus.Ready }, + channel: 'mcp://copilot/session-1/fs', + }, + ]); + }); + test('parseMcpChannelUri round-trips the controller-minted channel URI', () => { const channel = 'mcp://copilot/session-1/fs'; assert.deepStrictEqual(parseMcpChannelUri(channel), { @@ -259,4 +319,22 @@ suite('McpCustomizationController', () => { assert.strictEqual(findMcpChildId(customizations, 'search'), 'mcp-top-level:test:search'); assert.strictEqual(findMcpChildId(customizations, 'missing'), undefined); }); + + test('findMcpServerName finds bare top-level entries and plugin children', () => { + const customizations: readonly Customization[] = [ + ...PLUGIN_CUSTOMIZATIONS, + { + type: CustomizationType.McpServer, + id: 'mcp-top-level:test:search', + uri: 'mcp-top-level:test:search', + name: 'search', + enabled: true, + state: { kind: McpServerStatus.Ready }, + }, + ]; + + assert.strictEqual(findMcpServerName(customizations, 'mcp-child:demo:fs'), 'fs'); + assert.strictEqual(findMcpServerName(customizations, 'mcp-top-level:test:search'), 'search'); + assert.strictEqual(findMcpServerName(customizations, 'missing'), undefined); + }); }); diff --git a/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts new file mode 100644 index 00000000000000..a6b42f7459b816 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/testAgentHostTerminalManager.ts @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DeferredPromise } from '../../../../base/common/async.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import type { CreateTerminalParams } from '../../common/state/protocol/commands.js'; +import type { TerminalClaim, TerminalInfo, TerminalState } from '../../common/state/protocol/state.js'; +import type { IAgentHostTerminalManager, ICommandFinishedEvent } from '../../node/agentHostTerminalManager.js'; + +/** + * Controllable fake {@link IAgentHostTerminalManager} for tests. `createTerminal` + * records the request and announces the terminal URI via + * {@link onDidCreateTerminal}; tests drive command completion with + * {@link fireCommandFinished}. When no terminal interaction is needed it also + * serves as a benign no-op stand-in. + */ +export class TestAgentHostTerminalManager extends Disposable implements IAgentHostTerminalManager { + declare readonly _serviceBrand: undefined; + + defaultShell = '/bin/bash'; + commandDetectionSupported = true; + readonly created: CreateTerminalParams[] = []; + readonly sentTexts: { uri: string; data: string }[] = []; + readonly disposedTerminals: string[] = []; + + /** Resolves once a command-finished listener is registered (i.e. a command is running). */ + readonly commandFinishedListenerRegistered = new DeferredPromise<void>(); + + private readonly _onCommandFinished = this._register(new Emitter<ICommandFinishedEvent>()); + private readonly _onData = this._register(new Emitter<string>()); + private readonly _onExit = this._register(new Emitter<number>()); + private readonly _onClaimChanged = this._register(new Emitter<TerminalClaim>()); + private readonly _onDidCreateTerminal = this._register(new Emitter<string>()); + readonly onDidCreateTerminal = this._onDidCreateTerminal.event; + + async createTerminal(params: CreateTerminalParams): Promise<void> { + this.created.push(params); + this._onDidCreateTerminal.fire(params.channel); + } + writeInput(): void { } + async sendText(uri: string, data: string): Promise<void> { this.sentTexts.push({ uri, data }); } + onData(_uri: string, cb: (data: string) => void): IDisposable { return this._onData.event(cb); } + onExit(_uri: string, cb: (exitCode: number) => void): IDisposable { return this._onExit.event(cb); } + onClaimChanged(_uri: string, cb: (claim: TerminalClaim) => void): IDisposable { return this._onClaimChanged.event(cb); } + onCommandFinished(_uri: string, cb: (event: ICommandFinishedEvent) => void): IDisposable { + this.commandFinishedListenerRegistered.complete(); + return this._onCommandFinished.event(cb); + } + createAltBufferPromise(): Promise<void> { return new Promise<void>(() => { }); } + getContent(): string | undefined { return undefined; } + getClaim(): TerminalClaim | undefined { return undefined; } + hasTerminal(): boolean { return false; } + getExitCode(): number | undefined { return undefined; } + supportsCommandDetection(): boolean { return this.commandDetectionSupported; } + disposeTerminal(uri: string): void { this.disposedTerminals.push(uri); } + getTerminalInfos(): TerminalInfo[] { return []; } + getTerminalState(): TerminalState | undefined { return undefined; } + async getDefaultShell(): Promise<string> { return this.defaultShell; } + fireCommandFinished(event: ICommandFinishedEvent): void { this._onCommandFinished.fire(event); } +} diff --git a/src/vs/platform/agentHost/test/node/testGitHubEndpointService.ts b/src/vs/platform/agentHost/test/node/testGitHubEndpointService.ts new file mode 100644 index 00000000000000..945959c95c1554 --- /dev/null +++ b/src/vs/platform/agentHost/test/node/testGitHubEndpointService.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { deriveGitHubEndpoints, gitHubCopilotResource, gitHubRepoResource } from '../../common/githubEndpoints.js'; +import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; + +/** + * A static {@link IAgentHostGitHubEndpointService} for tests. Resolves the same + * endpoints as production for a given (optional) enterprise URI; `onDidChange` + * never fires. + */ +export function createTestGitHubEndpointService(enterpriseUri?: string): IAgentHostGitHubEndpointService { + const endpoints = deriveGitHubEndpoints(enterpriseUri); + return { + _serviceBrand: undefined, + onDidChange: Event.None, + getApiBaseUri: () => endpoints.apiBaseUri, + getGraphQlUri: () => endpoints.graphQlUri, + getEnterpriseHost: () => endpoints.enterpriseHost, + getEnterpriseUri: () => enterpriseUri || undefined, + getCopilotResource: () => gitHubCopilotResource(endpoints), + getRepoResource: () => gitHubRepoResource(endpoints), + }; +} diff --git a/src/vs/platform/agentPlugins/common/pluginParsers.ts b/src/vs/platform/agentPlugins/common/pluginParsers.ts index fc6d318e6c551d..29281cf211c0b1 100644 --- a/src/vs/platform/agentPlugins/common/pluginParsers.ts +++ b/src/vs/platform/agentPlugins/common/pluginParsers.ts @@ -135,8 +135,8 @@ export interface IPluginFormatConfig { readonly format: PluginFormat; readonly manifestPath: string; readonly hookConfigPath: string; - readonly pluginRootToken: string | undefined; - readonly pluginRootEnvVar: string | undefined; + readonly pluginRootTokens: readonly string[]; + readonly pluginRootEnvVars: readonly string[]; /** Parses hooks from a JSON object using the format's conventions. */ parseHooks(hookUri: URI, json: unknown, pluginUri: URI, workspaceRoot: URI | undefined, userHome: URI): IParsedHookGroup[]; } @@ -145,8 +145,8 @@ const COPILOT_FORMAT: IPluginFormatConfig = { format: PluginFormat.Copilot, manifestPath: 'plugin.json', hookConfigPath: 'hooks.json', - pluginRootToken: undefined, - pluginRootEnvVar: undefined, + pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'], + pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'], parseHooks(hookUri, json, _pluginUri, workspaceRoot, userHome) { return parseHooksJson(hookUri, json, workspaceRoot, userHome); }, @@ -156,8 +156,8 @@ const CLAUDE_FORMAT: IPluginFormatConfig = { format: PluginFormat.Claude, manifestPath: '.claude-plugin/plugin.json', hookConfigPath: 'hooks/hooks.json', - pluginRootToken: '${CLAUDE_PLUGIN_ROOT}', - pluginRootEnvVar: 'CLAUDE_PLUGIN_ROOT', + pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'], + pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'], parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) { return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${CLAUDE_PLUGIN_ROOT}', 'CLAUDE_PLUGIN_ROOT'); }, @@ -167,8 +167,8 @@ const OPEN_PLUGIN_FORMAT: IPluginFormatConfig = { format: PluginFormat.OpenPlugin, manifestPath: '.plugin/plugin.json', hookConfigPath: 'hooks/hooks.json', - pluginRootToken: '${PLUGIN_ROOT}', - pluginRootEnvVar: 'PLUGIN_ROOT', + pluginRootTokens: ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'], + pluginRootEnvVars: ['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT'], parseHooks(hookUri, json, pluginUri, workspaceRoot, userHome) { return interpolateHookPluginRoot(hookUri, json, pluginUri, workspaceRoot, userHome, '${PLUGIN_ROOT}', 'PLUGIN_ROOT'); }, @@ -448,10 +448,10 @@ export function shellQuotePluginRootInCommand(command: string, fsPath: string, t export function interpolateMcpPluginRoot( def: IMcpServerDefinition, fsPath: string, - token: string, - envVar: string, + tokens: readonly string[], + envVars: readonly string[], ): IMcpServerDefinition { - const replace = (s: string) => s.replaceAll(token, fsPath); + const replace = (s: string) => tokens.reduce((result, token) => result.replaceAll(token, fsPath), s); const config = def.configuration; let interpolated: IMcpServerConfiguration; @@ -471,7 +471,9 @@ export function interpolateMcpPluginRoot( local.env[k] = replace(v); } } - local.env[envVar] = fsPath; + for (const envVar of envVars) { + local.env[envVar] = fsPath; + } if (local.envFile) { local.envFile = replace(local.envFile); } @@ -1094,8 +1096,9 @@ export function parseMcpServerDefinitionMap( uri: definitionURI, customization: makeMcpServerCustomization(definitionURI, name), }; - if (formatConfig.pluginRootToken && formatConfig.pluginRootEnvVar) { - def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootToken, formatConfig.pluginRootEnvVar); + def = interpolateMcpPluginRoot(def, pluginFsPath, formatConfig.pluginRootTokens, formatConfig.pluginRootEnvVars); + if (def.configuration.type === McpServerType.LOCAL && def.configuration.cwd === undefined) { + def = { ...def, configuration: { ...def.configuration, cwd: pluginFsPath } }; } def = convertBareEnvVarsToVsCodeSyntax(def); definitions.push(def); diff --git a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts index 968d76d579070e..f6b8734266050f 100644 --- a/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts +++ b/src/vs/platform/agentPlugins/test/common/pluginParsers.test.ts @@ -22,6 +22,7 @@ import { resolveComponentDirs, normalizeMcpServerConfiguration, shellQuotePluginRootInCommand, + interpolateMcpPluginRoot, convertBareEnvVarsToVsCodeSyntax, toParsedAgent, toParsedSkill, @@ -241,6 +242,29 @@ suite('pluginParsers', () => { }); }); + suite('interpolateMcpPluginRoot', () => { + + test('replaces tokens and sets env vars without pairing array entries', () => { + const result = interpolateMcpPluginRoot({ + name: 'test', + uri: URI.file('/plugin/.mcp.json'), + configuration: { + type: McpServerType.LOCAL, + command: '${PLUGIN_ROOT}/bin/server', + args: ['--data', '${CLAUDE_PLUGIN_ROOT}/data'], + }, + customization: stubMcpCustomization(), + }, '/plugin', ['${PLUGIN_ROOT}', '${CLAUDE_PLUGIN_ROOT}'], ['PLUGIN_ROOT']); + + assert.deepStrictEqual(result.configuration, { + type: McpServerType.LOCAL, + command: '/plugin/bin/server', + args: ['--data', '/plugin/data'], + env: { PLUGIN_ROOT: '/plugin' }, + }); + }); + }); + // ---- convertBareEnvVarsToVsCodeSyntax ------------------------------- suite('convertBareEnvVarsToVsCodeSyntax', () => { diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 79f076df6bc685..92db79754a400f 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -123,6 +123,14 @@ export interface IBrowserViewWindowConfiguration { * workspace folder paths. */ readonly trustedFileRoots: readonly string[]; + /** + * Whether Workspace Trust is disabled entirely + * (`security.workspace.trust.enabled: false`) for this window. When + * `true`, every `file://` request is allowed regardless of + * {@link trustedFileRoots} since there is no meaningful notion of an + * untrusted folder to enforce. + */ + readonly trustAllFiles: boolean; } export interface IBrowserViewBounds { diff --git a/src/vs/platform/browserView/electron-main/browserSession.ts b/src/vs/platform/browserView/electron-main/browserSession.ts index ace51fa2384a66..7748280b41d32d 100644 --- a/src/vs/platform/browserView/electron-main/browserSession.ts +++ b/src/vs/platform/browserView/electron-main/browserSession.ts @@ -184,10 +184,13 @@ export class BrowserSession { } private static readonly _trustedFileRoots = TernarySearchTree.forPaths<true>(!isLinux); + private static _trustAllFiles = false; + /** * Set trusted file roots for all browser sessions. */ - static setTrustedFileRoots(roots: readonly string[]): void { + static setTrustedFileRoots(roots: readonly string[], trustAllFiles: boolean): void { + BrowserSession._trustAllFiles = trustAllFiles; BrowserSession._trustedFileRoots.clear(); for (const root of roots) { if (root) { @@ -274,7 +277,7 @@ export class BrowserSession { }); this.electronSession.protocol.handle(Schemas.file, request => { const filePath = normalize(URI.parse(request.url).fsPath); - if (!BrowserSession._trustedFileRoots.findSubstr(filePath)) { + if (!BrowserSession._trustAllFiles && !BrowserSession._trustedFileRoots.findSubstr(filePath)) { return new Response(localize('browserSession.untrustedFile', 'Forbidden. File does not reside within a trusted folder.'), { status: 403 }); } return this.electronSession.fetch(request, { bypassCustomProtocolHandlers: true }); diff --git a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts index c7494084591f5a..a30b3b3d5edd1d 100644 --- a/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts +++ b/src/vs/platform/browserView/electron-main/browserSessionPermissions.ts @@ -296,6 +296,13 @@ export class BrowserSessionPermissions extends Disposable implements IBrowserSes } set(origin: string, grants: readonly IPermissionCategoryState[]): void { + const key = toOriginKey(origin); + for (const grant of grants) { + if (grant.state === null) { + this._resolvePendingForCategory(key, grant.category); + } + } + // Coalesce the per-category onDidChange flushes into a single write for // the whole batch so persisting from the management UI isn't N writes. this._batching = true; @@ -310,6 +317,17 @@ export class BrowserSessionPermissions extends Disposable implements IBrowserSes } } + private _resolvePendingForCategory(origin: string, category: PermissionCategory): void { + if (!origin || this._pending.size === 0) { + return; + } + for (const pending of [...this._pending]) { + if (pending.origin === origin && pending.category === category) { + pending.deferred.complete(); + } + } + } + clear(): void { this._permissionStore.clear(); } diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index da4c20e34d0a91..b7f963076bb3d4 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -59,6 +59,9 @@ export class BrowserView extends Disposable { private _currentWindow: ICodeWindow | IAuxiliaryWindow | undefined; private _isDisposed = false; + private _wantsVisibility = false; + private _hasBeenLaidOut = false; + private static readonly MAX_CONSOLE_LOG_ENTRIES = 1000; private readonly _consoleLogs: string[] = []; @@ -147,7 +150,9 @@ export class BrowserView extends Disposable { }); // Use a default size of 1024x768. - this._view.setBounds({ x: -10000, y: -10000, width: 1024, height: 768 }); + // Important: The bounds here must be on-screen, otherwise some OSes (like macOS) may not actually start rendering. + // We just have to be careful to not show the view until a layout has happened in the correct location. + this._view.setBounds({ x: 0, y: 0, width: 1024, height: 768 }); this._view.setBackgroundColor('#FFFFFF'); this._ownerWindow = this.windowsMainService.getWindowById(owner.mainWindowId)!; @@ -637,6 +642,11 @@ export class BrowserView extends Disposable { width: Math.round(bounds.width * bounds.zoomFactor), height: Math.round(bounds.height * bounds.zoomFactor) }); + + this._hasBeenLaidOut = true; + if (this._wantsVisibility && !this._view.getVisible()) { + this._view.setVisible(true); + } } setBrowserZoomIndex(zoomIndex: number): void { @@ -649,7 +659,7 @@ export class BrowserView extends Disposable { * Set the visibility of this view */ setVisible(visible: boolean): void { - if (this._view.getVisible() === visible) { + if (this._wantsVisibility === visible) { return; } @@ -658,7 +668,11 @@ export class BrowserView extends Disposable { this._currentWindow?.win?.webContents.focus(); } - this._view.setVisible(visible); + if (this._hasBeenLaidOut || !visible) { + this._view.setVisible(visible); + } + + this._wantsVisibility = visible; this._onDidChangeVisibility.fire({ visible }); } @@ -762,9 +776,29 @@ export class BrowserView extends Disposable { if (options?.awaitNextPaint) { await this._waitForNextPaint(); } - const image = await this._view.webContents.capturePage(options?.screenRect, { - stayHidden: true - }); + const image = await (async () => { + const maxAttempts = 5; + let lastError: Error | undefined; + for (let i = 0; i < maxAttempts; i++) { + try { + return await this._view.webContents.capturePage(options?.screenRect, { + stayHidden: true + }); + } catch (error) { + // `UnknownVizError` is a transient Electron error when no frame is available yet + // (e.g. offscreen scenarios where rendering has just been kicked off by `setVisible(true)`), + // so retry a few times. + if (error instanceof Error && error.message === 'UnknownVizError') { + lastError = error; + await new Promise(resolve => setTimeout(resolve, 16)); + continue; + } else { + throw error; + } + } + } + throw new Error(`Failed to capture screenshot after ${maxAttempts} attempts`, { cause: lastError }); + })(); const buffer = format === 'png' ? image.toPNG() : image.toJPEG(quality); const screenshot = VSBuffer.wrap(buffer); // Only update _lastScreenshot if capturing the full view diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index 8a513c1d051681..3934db7b5b858f 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -390,12 +390,14 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa private _recomputeTrustedFileRoots(): void { const roots = new Set<string>(); + let trustAllFiles = false; for (const configuration of this._windowConfigurations.values()) { for (const root of configuration.trustedFileRoots) { roots.add(root); } + trustAllFiles ||= configuration.trustAllFiles; } - BrowserSession.setTrustedFileRoots([...roots]); + BrowserSession.setTrustedFileRoots([...roots], trustAllFiles); } /** diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 3ccaabe3279be8..3efaac5ae76706 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -28,6 +28,7 @@ export interface IPlaywrightActionScope { const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes const SESSION_INACTIVITY_MS = 30 * 60_000; // 30 minutes +const OPEN_PAGE_NAVIGATION_TIMEOUT_MS = 30_000; /** * Narrow a raw Playwright transport payload to a {@link CDPRequest}. @@ -177,6 +178,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService this.logService, this.agentNetworkFilterService, this.telemetryService, + viewId => this.startTrackingPage(viewId), ); // Keep the global tracked set in sync with group events. When a @@ -265,12 +267,7 @@ export class PlaywrightService extends Disposable implements IPlaywrightService async openPage(sessionId: string, url: string): Promise<{ pageId: string; summary: string }> { const session = await this._getOrCreateSession(sessionId); - const result = await session.openPage(url); - // The creating session's group already has the view. Use - // startTrackingPage to add it to the canonical set and - // replicate into other sessions. - await this.startTrackingPage(result.pageId); - return result; + return session.openPage(url); } async getSummary(sessionId: string, pageId: string): Promise<string> { @@ -380,6 +377,7 @@ class PlaywrightSession extends Disposable { private readonly logService: ILogService, private readonly agentNetworkFilterService: IAgentNetworkFilterService, private readonly telemetryService: ITelemetryService, + private readonly onDidCreatePage: (viewId: string) => Promise<void>, ) { super(); @@ -405,9 +403,18 @@ class PlaywrightSession extends Disposable { const page = await this._openContext.newPage(); const viewId = await this._onPageAdded(page); + await this.onDidCreatePage(viewId); if (url && url !== 'about:blank' && page.url() !== url) { - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); + try { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: OPEN_PAGE_NAVIGATION_TIMEOUT_MS }); + } catch (error) { + if (!isNavigationTimeoutError(error)) { + throw error; + } + + throw new Error(`Navigation to ${url} timed out after ${OPEN_PAGE_NAVIGATION_TIMEOUT_MS} ms. The page (ID: ${viewId}) is open and can be reused.`); + } } const summary = await this._getSummary(viewId); @@ -760,6 +767,16 @@ class PlaywrightSession extends Disposable { } } +function isNavigationTimeoutError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + + return error.name === 'TimeoutError' + || /Timeout \d+ms exceeded/.test(error.message) + || /navigation timeout/i.test(error.message); +} + /** * Per-invocation state threaded through {@link PlaywrightSession.invokeFunction} * and its deferral machinery so completion telemetry can be emitted exactly once diff --git a/src/vs/platform/extensions/common/extensions.ts b/src/vs/platform/extensions/common/extensions.ts index e26d74e2e63c1b..6a8cc98d148b24 100644 --- a/src/vs/platform/extensions/common/extensions.ts +++ b/src/vs/platform/extensions/common/extensions.ts @@ -506,9 +506,16 @@ export class ExtensionError extends Error { readonly extension: ExtensionIdentifier; constructor(extensionIdentifier: ExtensionIdentifier, cause: Error, message?: string) { - super(`Error in extension ${ExtensionIdentifier.toKey(extensionIdentifier)}: ${message ?? cause.message}`, { cause }); + const detail = message && cause?.message ? `${message}: ${cause.message}` : (message ?? cause?.message); + super(`Error in extension ${ExtensionIdentifier.toKey(extensionIdentifier)}: ${detail}`, { cause }); this.name = 'ExtensionError'; this.extension = extensionIdentifier; + // Adopt the underlying cause's stack so error telemetry buckets by the real + // failure site inside the extension instead of the generic event-dispatch + // frames. Extension attribution relies on `this.extension`, not this stack. + if (cause?.stack) { + this.stack = cause.stack; + } } } diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index ffee8e7b664fd3..d5630b57899d39 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -9,6 +9,9 @@ const _allApiProposals = { activeComment: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.activeComment.d.ts', }, + agentEditorComments: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts', + }, agentSessionsWorkspace: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.agentSessionsWorkspace.d.ts', }, diff --git a/src/vs/platform/files/common/remoteFileSystemProxy.ts b/src/vs/platform/files/common/remoteFileSystemProxy.ts deleted file mode 100644 index 6c6e9a475b6fc4..00000000000000 --- a/src/vs/platform/files/common/remoteFileSystemProxy.ts +++ /dev/null @@ -1,18 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Channel name used by each renderer process to register a server channel - * that exposes its file system operations. The main process routes calls - * from other renderers to the right window. - */ -export const REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME = 'remoteFileSystemProxy'; - -/** - * Channel name registered by the main process handler that receives requests - * from renderers and forwards them to the window that owns the remote - * connection matching the URI's authority. - */ -export const REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME = 'remoteFileSystemProxyHandler'; diff --git a/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts b/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts deleted file mode 100644 index 07c2236833040d..00000000000000 --- a/src/vs/platform/files/electron-browser/remoteFileSystemProxyClient.ts +++ /dev/null @@ -1,134 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { VSBuffer } from '../../../base/common/buffer.js'; -import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; -import { Schemas } from '../../../base/common/network.js'; -import { URI } from '../../../base/common/uri.js'; -import { IChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { - FileSystemProviderCapabilities, - FileType, - IFileChange, - IFileDeleteOptions, - IFileOverwriteOptions, - IFileService, - IFileSystemProviderWithFileReadWriteCapability, - IFileWriteOptions, - IStat, - IWatchOptions, -} from '../../files/common/files.js'; -import { ILogService } from '../../log/common/log.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -/** - * A read-only file system provider registered in windows that do not have a - * direct remote connection. It proxies file system operations through the main - * process to a window that owns the matching remote file system provider. - * - * This enables copy/paste and drag-and-drop of files between remote and local - * workspaces. - */ -export class RemoteFileSystemProxyClient extends Disposable implements IFileSystemProviderWithFileReadWriteCapability { - - static register( - fileService: IFileService, - mainProcessService: IMainProcessService, - logService: ILogService, - remoteAuthority: string | undefined, - ): IDisposable { - // If this window has its own remote connection, it uses the direct - // RemoteFileSystemProviderClient. Registering the proxy here would - // create a loop (main process routes back to this same window). - if (remoteAuthority) { - return Disposable.None; - } - - const disposables = new DisposableStore(); - - // Listen for activation of the vscode-remote scheme. Since this - // window has no direct remote connection, register the proxy provider - // that routes through the main process to a window that does. - disposables.add(fileService.onWillActivateFileSystemProvider(e => { - if (e.scheme === Schemas.vscodeRemote) { - e.join((async () => { - try { - const provider = new RemoteFileSystemProxyClient(mainProcessService, logService); - disposables.add(provider); - disposables.add(fileService.registerProvider(Schemas.vscodeRemote, provider)); - logService.info('RemoteFileSystemProxyClient: Registered proxy provider for vscode-remote scheme'); - } catch (error) { - logService.error('RemoteFileSystemProxyClient: Failed to register proxy provider', error); - } - })()); - } - })); - - return disposables; - } - - private readonly channel: IChannel; - - readonly onDidChangeCapabilities: Event<void> = Event.None; - - private readonly _onDidChangeFile = this._register(new Emitter<readonly IFileChange[]>()); - readonly onDidChangeFile: Event<readonly IFileChange[]> = this._onDidChangeFile.event; - - get capabilities(): FileSystemProviderCapabilities { - return FileSystemProviderCapabilities.FileReadWrite | - FileSystemProviderCapabilities.Readonly | - FileSystemProviderCapabilities.PathCaseSensitive; - } - - private constructor( - mainProcessService: IMainProcessService, - private readonly logService: ILogService, - ) { - super(); - - // Get the channel from the main process — the main process handler will - // route calls to the appropriate renderer window based on the URI's - // remote authority - this.channel = mainProcessService.getChannel(REMOTE_FILE_SYSTEM_PROXY_HANDLER_CHANNEL_NAME); - } - - async stat(resource: URI): Promise<IStat> { - this.logService.trace('RemoteFileSystemProxyClient#stat', resource.toString()); - return this.channel.call('stat', [resource]); - } - - async readdir(resource: URI): Promise<[string, FileType][]> { - this.logService.trace('RemoteFileSystemProxyClient#readdir', resource.toString()); - return this.channel.call('readdir', [resource]); - } - - async readFile(resource: URI): Promise<Uint8Array> { - this.logService.trace('RemoteFileSystemProxyClient#readFile', resource.toString()); - const buffer: VSBuffer = await this.channel.call('readFile', [resource]); - return buffer.buffer; - } - - async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise<void> { - throw new Error('Remote file system proxy provider is read-only'); - } - - watch(_resource: URI, _opts: IWatchOptions): IDisposable { - return Disposable.None; - } - - async mkdir(_resource: URI): Promise<void> { - throw new Error('Remote file system proxy provider is read-only'); - } - - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise<void> { - throw new Error('Remote file system proxy provider is read-only'); - } - - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise<void> { - throw new Error('Remote file system proxy provider is read-only'); - } -} diff --git a/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts b/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts deleted file mode 100644 index e7e37d6ea4c00c..00000000000000 --- a/src/vs/platform/files/electron-browser/remoteFileSystemProxyServer.ts +++ /dev/null @@ -1,81 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; -import { VSBuffer } from '../../../base/common/buffer.js'; -import { IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { IFileService, IFileStatWithMetadata, FileType } from '../../files/common/files.js'; -import { IMainProcessService } from '../../ipc/common/mainProcessService.js'; -import { REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -/** - * Registered in every renderer process. Serves file system operations to other - * windows that cannot directly access this window's remote file system provider. - * The main process routes incoming requests via {@link RemoteFileSystemProxyMainHandler}. - * - * This follows the same pattern as {@link ElectronRemoteResourceLoader}. - */ -export class RemoteFileSystemProxyServer extends Disposable { - - constructor( - @IMainProcessService mainProcessService: IMainProcessService, - @IFileService private readonly fileService: IFileService, - ) { - super(); - - const channel: IServerChannel = { - listen<T>(_: unknown, event: string): Event<T> { - throw new Error(`Event not found: ${event}`); - }, - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - call: (_: unknown, command: string, arg?: any): Promise<any> => { - const args = arg as unknown[]; - switch (command) { - case 'stat': return this.stat(URI.revive(args[0] as UriComponents)); - case 'readdir': return this.readdir(URI.revive(args[0] as UriComponents)); - case 'readFile': return this.readFile(URI.revive(args[0] as UriComponents)); - case 'exists': return this.exists(URI.revive(args[0] as UriComponents)); - case 'resolve': return this.resolve(URI.revive(args[0] as UriComponents), args[1] as boolean); - } - - throw new Error(`Call not found: ${command}`); - } - }; - - mainProcessService.registerChannel(REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME, channel); - } - - private async stat(uri: URI): Promise<{ type: FileType; size: number; mtime: number; ctime: number }> { - const provider = this.fileService.getProvider(uri.scheme); - if (!provider) { - throw new Error(`No provider for scheme: ${uri.scheme}`); - } - return provider.stat(uri); - } - - private async readdir(uri: URI): Promise<[string, FileType][]> { - const provider = this.fileService.getProvider(uri.scheme); - if (!provider) { - throw new Error(`No provider for scheme: ${uri.scheme}`); - } - return provider.readdir(uri); - } - - private async readFile(uri: URI): Promise<VSBuffer> { - const content = await this.fileService.readFile(uri); - return content.value; - } - - private async exists(uri: URI): Promise<boolean> { - return this.fileService.exists(uri); - } - - private async resolve(uri: URI, resolveMetadata: boolean): Promise<IFileStatWithMetadata> { - return this.fileService.resolve(uri, { resolveMetadata }) as Promise<IFileStatWithMetadata>; - } -} diff --git a/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts b/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts deleted file mode 100644 index 0f5918a53ddaf5..00000000000000 --- a/src/vs/platform/files/electron-main/remoteFileSystemProxyMainHandler.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Event } from '../../../base/common/event.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { Schemas } from '../../../base/common/network.js'; -import { URI, UriComponents } from '../../../base/common/uri.js'; -import { IChannel, IServerChannel } from '../../../base/parts/ipc/common/ipc.js'; -import { REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME } from '../common/remoteFileSystemProxy.js'; - -export interface IRemoteFileSystemProxyWindowsService { - getWindows(): readonly { readonly id: number; readonly remoteAuthority?: string }[]; -} - -export interface IRemoteFileSystemProxyIPCServer { - getChannel(channelName: string, clientFilter: (client: { ctx: string }) => boolean): IChannel; -} - -/** - * Main process channel that routes remote file system operations from one - * renderer to another. When a local window needs to read a `vscode-remote://` - * file, this handler finds the renderer window connected to the matching remote - * authority and forwards the request through its - * {@link REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME} server channel. - */ -export class RemoteFileSystemProxyMainHandler extends Disposable implements IServerChannel { - - constructor( - private readonly windowsMainService: IRemoteFileSystemProxyWindowsService, - private readonly electronIpcServer: IRemoteFileSystemProxyIPCServer, - ) { - super(); - } - - listen<T>(_: unknown, event: string): Event<T> { - throw new Error(`Event not found: ${event}`); - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async call(_: unknown, command: string, arg?: any): Promise<any> { - const args = arg as unknown[]; - const uri = URI.revive(args[0] as UriComponents); - - // Only handle vscode-remote:// URIs to avoid routing unrelated URIs - // that happen to have an authority (e.g. UNC paths on Windows). - if (uri.scheme !== Schemas.vscodeRemote) { - throw new Error(`Unsupported scheme: ${uri.scheme}. Only ${Schemas.vscodeRemote} URIs are supported.`); - } - - // Find a window that has a remote connection matching this URI's authority - const targetWindow = this.findWindowForAuthority(uri.authority); - if (!targetWindow) { - throw new Error(`No window found with remote authority: ${uri.authority}`); - } - - // Get the remote file system proxy channel registered by the target renderer - const targetChannel = this.getRendererChannel(targetWindow.id); - - // Forward the call to the target renderer - return targetChannel.call(command, arg); - } - - private findWindowForAuthority(authority: string): { id: number } | undefined { - const windows = this.windowsMainService.getWindows(); - for (const window of windows) { - if (window.remoteAuthority === authority) { - return { id: window.id }; - } - } - return undefined; - } - - private getRendererChannel(windowId: number): IChannel { - // Get the channel registered by the target renderer window. - // The connection context format is `window:{id}`. - return this.electronIpcServer.getChannel( - REMOTE_FILE_SYSTEM_PROXY_CHANNEL_NAME, - (client) => client.ctx === `window:${windowId}`, - ); - } -} diff --git a/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts b/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts deleted file mode 100644 index e1ccfff90cb0c0..00000000000000 --- a/src/vs/platform/files/test/electron-main/remoteFileSystemProxy.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { Event } from '../../../../base/common/event.js'; -import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; -import { IChannel } from '../../../../base/parts/ipc/common/ipc.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { IRemoteFileSystemProxyIPCServer, IRemoteFileSystemProxyWindowsService, RemoteFileSystemProxyMainHandler } from '../../electron-main/remoteFileSystemProxyMainHandler.js'; - -function createMockChannel(callImpl: (command: string, arg?: unknown) => Promise<unknown> = async () => 'ok'): IChannel { - return { - call: callImpl as IChannel['call'], - listen: (() => Event.None) as IChannel['listen'], - }; -} - -suite('RemoteFileSystemProxyMainHandler', () => { - - const disposables = new DisposableStore(); - - teardown(() => { - disposables.clear(); - }); - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('throws when no window matches the URI authority', async () => { - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1 }, - { id: 2, remoteAuthority: 'ssh-remote+myhost' }, - { id: 3, remoteAuthority: 'wsl+ubuntu' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: () => createMockChannel(), - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await assert.rejects( - () => handler.call(undefined, 'stat', [URI.parse('vscode-remote://unknown-host/test')]), - /No window found with remote authority/ - ); - }); - - test('throws for non-vscode-remote URIs', async () => { - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1, remoteAuthority: 'ssh-remote+myhost' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: () => createMockChannel(), - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await assert.rejects( - () => handler.call(undefined, 'stat', [URI.parse('file:///local/path')]), - /Unsupported scheme/ - ); - }); - - test('routes call to correct window based on URI authority', async () => { - let calledWindowCtx: string | undefined; - - const windowsService: IRemoteFileSystemProxyWindowsService = { - getWindows: () => [ - { id: 1 }, - { id: 2, remoteAuthority: 'ssh-remote+myhost' }, - ], - }; - - const mockServer: IRemoteFileSystemProxyIPCServer = { - getChannel: (_name: string, filter: (client: { ctx: string }) => boolean) => { - const connections = [ - { ctx: 'window:1' }, - { ctx: 'window:2' }, - ]; - calledWindowCtx = connections.find(filter)?.ctx; - return createMockChannel(); - }, - }; - - const handler = disposables.add(new RemoteFileSystemProxyMainHandler(windowsService, mockServer)); - - await handler.call(undefined, 'stat', [URI.parse('vscode-remote://ssh-remote+myhost/some/path')]); - - assert.strictEqual(calledWindowCtx, 'window:2'); - }); -}); diff --git a/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts new file mode 100644 index 00000000000000..076932c8fce922 --- /dev/null +++ b/src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts @@ -0,0 +1,217 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { ILifecycleMainService } from '../../lifecycle/electron-main/lifecycleMainService.js'; +import { ILogService } from '../../log/common/log.js'; +import { INativeSystemWideKeybinding, INativeSystemWideKeybindingResult } from '../../native/common/native.js'; +import { INativeRunActionInWindowRequest } from '../../window/common/window.js'; +import { ICodeWindow } from '../../window/electron-main/window.js'; +import { IWindowsMainService } from '../../windows/electron-main/windows.js'; + +/** + * The subset of Electron's `globalShortcut` module that this service relies on. Injecting it as a + * constructor dependency (rather than importing the global directly) lets tests provide a fake. + */ +export interface IGlobalShortcutRegistry { + register(accelerator: string, callback: () => void): boolean; + unregister(accelerator: string): void; + isRegistered(accelerator: string): boolean; +} + +export const IGlobalKeybindingsMainService = createDecorator<IGlobalKeybindingsMainService>('globalKeybindingsMainService'); + +export interface IGlobalKeybindingsMainService { + + readonly _serviceBrand: undefined; + + /** + * Replaces the set of system-wide (OS global) keybindings owned by the given window with the + * provided set, reconciling the actual OS registrations. The result reports the user settings + * labels (or accelerators) that could not be registered (e.g. because the accelerator is already + * taken by the OS or another application). + */ + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult; +} + +export class GlobalKeybindingsMainService extends Disposable implements IGlobalKeybindingsMainService { + + declare readonly _serviceBrand: undefined; + + /** Per-window desired bindings, keyed by window id then by accelerator. */ + private readonly registry = new Map<number, Map<string, INativeSystemWideKeybinding>>(); + + /** Accelerators this service currently owns an OS registration for. */ + private readonly registeredAccelerators = new Set<string>(); + + /** Accelerators that were desired but failed to register (e.g. already taken). */ + private readonly failedAccelerators = new Set<string>(); + + constructor( + private readonly globalShortcut: IGlobalShortcutRegistry, + @IWindowsMainService private readonly windowsMainService: IWindowsMainService, + @ILifecycleMainService lifecycleMainService: ILifecycleMainService, + @ILogService private readonly logService: ILogService + ) { + super(); + + this._register(this.windowsMainService.onDidDestroyWindow(window => this.onDidDestroyWindow(window))); + this._register(lifecycleMainService.onWillShutdown(() => this.unregisterAll())); + this._register(toDisposable(() => this.unregisterAll())); + } + + updateKeybindings(windowId: number, keybindings: readonly INativeSystemWideKeybinding[]): INativeSystemWideKeybindingResult { + const perWindow = new Map<string, INativeSystemWideKeybinding>(); + for (const keybinding of keybindings) { + if (!this.isValid(keybinding)) { + this.logService.warn(`[GlobalKeybindings] ignoring invalid system-wide keybinding: ${JSON.stringify(keybinding)}`); + continue; + } + if (perWindow.has(keybinding.accelerator)) { + this.logService.warn(`[GlobalKeybindings] duplicate accelerator '${keybinding.accelerator}' in window ${windowId}, keeping first`); + continue; + } + perWindow.set(keybinding.accelerator, keybinding); + } + + if (perWindow.size > 0) { + this.registry.set(windowId, perWindow); + } else { + this.registry.delete(windowId); + } + + this.reconcile(); + + const failed: string[] = []; + for (const keybinding of perWindow.values()) { + if (this.failedAccelerators.has(keybinding.accelerator)) { + failed.push(keybinding.userSettingsLabel ?? keybinding.accelerator); + } + } + + return { failed }; + } + + private isValid(keybinding: INativeSystemWideKeybinding): boolean { + return typeof keybinding.accelerator === 'string' && keybinding.accelerator.length > 0 + && typeof keybinding.commandId === 'string' && keybinding.commandId.length > 0; + } + + /** + * Reconciles the OS registrations against the union of all windows' desired accelerators. + * Unregisters only accelerators this service owns; never touches shortcuts owned elsewhere. + */ + private reconcile(): void { + const desired = new Set<string>(); + for (const perWindow of this.registry.values()) { + for (const accelerator of perWindow.keys()) { + desired.add(accelerator); + } + } + + // Unregister accelerators we own but no longer want + for (const accelerator of [...this.registeredAccelerators]) { + if (!desired.has(accelerator)) { + this.globalShortcut.unregister(accelerator); + this.registeredAccelerators.delete(accelerator); + this.failedAccelerators.delete(accelerator); + } + } + + // Register newly desired accelerators (retries previously failed ones too) + for (const accelerator of desired) { + if (this.registeredAccelerators.has(accelerator)) { + continue; + } + + let registered = false; + try { + // Register once with a stable callback that reads the CURRENT registry at fire time, + // so command/args are never captured from a stale snapshot. + registered = this.globalShortcut.register(accelerator, () => this.onTrigger(accelerator)); + } catch (error) { + this.logService.error(`[GlobalKeybindings] error registering '${accelerator}'`, error); + } + + if (registered) { + this.registeredAccelerators.add(accelerator); + this.failedAccelerators.delete(accelerator); + } else { + this.failedAccelerators.add(accelerator); + this.logService.warn(`[GlobalKeybindings] failed to register accelerator '${accelerator}' (already taken by the OS or another application)`); + } + } + } + + private onTrigger(accelerator: string): void { + const owners: number[] = []; + for (const [windowId, perWindow] of this.registry) { + if (perWindow.has(accelerator)) { + owners.push(windowId); + } + } + if (owners.length === 0) { + return; // stale registration; will be unregistered on next reconcile + } + + // Target window selection: + // 1. the focused window if it owns this accelerator (respect that window's own binding) + // 2. otherwise the deterministic winner (lowest window id) among alive owners + let target: ICodeWindow | undefined; + const focused = this.windowsMainService.getFocusedWindow(); + if (focused && this.registry.get(focused.id)?.has(accelerator)) { + target = focused; + } else { + target = owners + .map(windowId => this.windowsMainService.getWindowById(windowId)) + .filter((window): window is ICodeWindow => !!window) + .sort((a, b) => a.id - b.id) + .at(0); + } + + if (!target) { + this.logService.warn(`[GlobalKeybindings] no live window to handle accelerator '${accelerator}'`); + return; + } + + const binding = this.registry.get(target.id)?.get(accelerator); + if (!binding) { + return; + } + + this.logService.trace(`[GlobalKeybindings] trigger '${accelerator}' -> '${binding.commandId}' in window ${target.id}`); + + // We deliberately do NOT focus the routing window here. A system-wide keybinding fires while + // VS Code is typically unfocused, and force-focusing the routing window would pull it to the + // foreground even when the command opens or reveals a *different* window (e.g. + // `workbench.action.openAgentsWindow` reveals the agents window). Pulling the routing window + // forward first produces a visible flicker. Instead we let the command decide what to surface + // and focus — matching every other `vscode:runAction` sender (menubar, touchbar, mouse), none + // of which force-focus. `sendWhenReady` only needs the web contents to be ready, not focused. + const payload: INativeRunActionInWindowRequest = { + id: binding.commandId, + from: 'systemWideKeybinding', + args: binding.args === undefined ? undefined : [binding.args] + }; + target.sendWhenReady('vscode:runAction', CancellationToken.None, payload); + } + + private onDidDestroyWindow(window: ICodeWindow): void { + if (this.registry.delete(window.id)) { + this.reconcile(); + } + } + + private unregisterAll(): void { + for (const accelerator of this.registeredAccelerators) { + this.globalShortcut.unregister(accelerator); + } + this.registeredAccelerators.clear(); + this.failedAccelerators.clear(); + this.registry.clear(); + } +} diff --git a/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts new file mode 100644 index 00000000000000..99e34ee9b9cf22 --- /dev/null +++ b/src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts @@ -0,0 +1,252 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../base/common/event.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { INativeSystemWideKeybinding } from '../../../native/common/native.js'; +import { ICodeWindow } from '../../../window/electron-main/window.js'; +import { IWindowsMainService } from '../../../windows/electron-main/windows.js'; +import { ILifecycleMainService } from '../../../lifecycle/electron-main/lifecycleMainService.js'; +import { GlobalKeybindingsMainService, IGlobalShortcutRegistry } from '../../electron-main/globalKeybindingsMainService.js'; + +class FakeGlobalShortcut implements IGlobalShortcutRegistry { + readonly registered = new Map<string, () => void>(); + readonly failFor = new Set<string>(); + unregisterCalls: string[] = []; + + register(accelerator: string, callback: () => void): boolean { + if (this.failFor.has(accelerator)) { + return false; + } + this.registered.set(accelerator, callback); + return true; + } + + unregister(accelerator: string): void { + this.unregisterCalls.push(accelerator); + this.registered.delete(accelerator); + } + + isRegistered(accelerator: string): boolean { + return this.registered.has(accelerator); + } + + trigger(accelerator: string): void { + const callback = this.registered.get(accelerator); + if (!callback) { + throw new Error(`accelerator '${accelerator}' is not registered`); + } + callback(); + } +} + +class FakeCodeWindow { + focusCalls = 0; + readonly sent: { channel: string; args: unknown[] }[] = []; + + constructor(readonly id: number) { } + + focus(): void { + this.focusCalls++; + } + + sendWhenReady(channel: string, _token: unknown, ...args: unknown[]): void { + this.sent.push({ channel, args }); + } +} + +class FakeWindowsMainService { + private readonly _onDidDestroyWindow: Emitter<ICodeWindow>; + readonly onDidDestroyWindow; + + focused: FakeCodeWindow | undefined; + readonly windows = new Map<number, FakeCodeWindow>(); + + constructor(store: DisposableStore) { + this._onDidDestroyWindow = store.add(new Emitter<ICodeWindow>()); + this.onDidDestroyWindow = this._onDidDestroyWindow.event; + } + + addWindow(id: number): FakeCodeWindow { + const window = new FakeCodeWindow(id); + this.windows.set(id, window); + return window; + } + + getFocusedWindow(): ICodeWindow | undefined { + return this.focused as unknown as ICodeWindow | undefined; + } + + getWindowById(id: number): ICodeWindow | undefined { + return this.windows.get(id) as unknown as ICodeWindow | undefined; + } + + destroyWindow(window: FakeCodeWindow): void { + this.windows.delete(window.id); + this._onDidDestroyWindow.fire(window as unknown as ICodeWindow); + } +} + +class FakeLifecycleMainService { + private readonly _onWillShutdown: Emitter<{ reason: number; join(id: string, promise: Promise<void>): void }>; + readonly onWillShutdown; + + constructor(store: DisposableStore) { + this._onWillShutdown = store.add(new Emitter()); + this.onWillShutdown = this._onWillShutdown.event; + } + + shutdown(): void { + this._onWillShutdown.fire({ reason: 1, join: () => { } }); + } +} + +function binding(accelerator: string, commandId: string, args?: unknown, userSettingsLabel?: string): INativeSystemWideKeybinding { + return { accelerator, commandId, args, userSettingsLabel }; +} + +suite('GlobalKeybindingsMainService', () => { + + const ds = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(shortcut = new FakeGlobalShortcut()) { + const store = ds.add(new DisposableStore()); + const windows = new FakeWindowsMainService(store); + const lifecycle = new FakeLifecycleMainService(store); + const service = store.add(new GlobalKeybindingsMainService( + shortcut, + windows as unknown as IWindowsMainService, + lifecycle as unknown as ILifecycleMainService, + new NullLogService() + )); + return { service, windows, lifecycle, shortcut }; + } + + test('registers desired accelerators and reports no failures', () => { + const { service, shortcut } = createService(); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + + assert.deepStrictEqual(failed, []); + assert.deepStrictEqual([...shortcut.registered.keys()].sort(), ['Control+Cmd+A', 'Control+Cmd+B']); + }); + + test('reports and unregisters accelerators removed on a subsequent update', () => { + const { service, shortcut } = createService(); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + + assert.deepStrictEqual([...shortcut.registered.keys()], ['Control+Cmd+A']); + assert.deepStrictEqual(shortcut.unregisterCalls, ['Control+Cmd+B']); + }); + + test('returns the labels that failed to register and retries them later', () => { + const shortcut = new FakeGlobalShortcut(); + shortcut.failFor.add('Control+Cmd+A'); + const { service } = createService(shortcut); + + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + assert.deepStrictEqual(failed, ['ctrl+cmd+a']); + + // Now the accelerator becomes available; a re-sync should register it. + shortcut.failFor.delete('Control+Cmd+A'); + const { failed: failedAgain } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'a', undefined, 'ctrl+cmd+a')]); + assert.deepStrictEqual(failedAgain, []); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + }); + + test('deduplicates accelerators within a single window payload', () => { + const { service, shortcut } = createService(); + const { failed } = service.updateKeybindings(1, [binding('Control+Cmd+A', 'first'), binding('Control+Cmd+A', 'second')]); + + assert.deepStrictEqual(failed, []); + assert.strictEqual(shortcut.registered.size, 1); + }); + + test('trigger dispatches the run-action payload without force-focusing the routing window', () => { + const { service, windows, shortcut } = createService(); + const window = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'workbench.action.openAgentsWindow', { foo: 'bar' })]); + + shortcut.trigger('Control+Cmd+A'); + + // The command controls what is surfaced/focused (e.g. it reveals the agents window), so the + // routing window must NOT be pulled to the foreground — doing so would flicker the wrong window. + assert.strictEqual(window.focusCalls, 0); + assert.deepStrictEqual(window.sent, [{ + channel: 'vscode:runAction', + args: [{ id: 'workbench.action.openAgentsWindow', from: 'systemWideKeybinding', args: [{ foo: 'bar' }] }] + }]); + }); + + test('trigger passes undefined args when the binding has none', () => { + const { service, windows, shortcut } = createService(); + const window = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'cmd')]); + + shortcut.trigger('Control+Cmd+A'); + + assert.deepStrictEqual(window.sent[0].args, [{ id: 'cmd', from: 'systemWideKeybinding', args: undefined }]); + }); + + test('conflict across windows resolves to the focused owner', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + const window2 = windows.addWindow(2); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'from-window-1')]); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'from-window-2')]); + + windows.focused = window2; + shortcut.trigger('Control+Cmd+A'); + + assert.strictEqual(window1.sent.length, 0); + assert.strictEqual((window2.sent[0].args[0] as { id: string }).id, 'from-window-2'); + }); + + test('conflict without a focused owner resolves deterministically to the lowest window id', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + const window2 = windows.addWindow(2); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'from-window-2')]); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'from-window-1')]); + + windows.focused = undefined; + shortcut.trigger('Control+Cmd+A'); + + assert.strictEqual(window2.sent.length, 0); + assert.strictEqual((window1.sent[0].args[0] as { id: string }).id, 'from-window-1'); + }); + + test('closing a window unregisters accelerators no other window owns', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + + windows.destroyWindow(window1); + assert.strictEqual(shortcut.isRegistered('Control+Cmd+A'), false); + }); + + test('closing a window keeps accelerators still owned by another window', () => { + const { service, windows, shortcut } = createService(); + const window1 = windows.addWindow(1); + windows.addWindow(2); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a')]); + service.updateKeybindings(2, [binding('Control+Cmd+A', 'a')]); + + windows.destroyWindow(window1); + assert.ok(shortcut.isRegistered('Control+Cmd+A')); + }); + + test('shutdown unregisters all owned accelerators', () => { + const { service, lifecycle, shortcut } = createService(); + service.updateKeybindings(1, [binding('Control+Cmd+A', 'a'), binding('Control+Cmd+B', 'b')]); + + lifecycle.shutdown(); + assert.strictEqual(shortcut.registered.size, 0); + }); +}); diff --git a/src/vs/platform/keybinding/common/keybinding.ts b/src/vs/platform/keybinding/common/keybinding.ts index 6c4baf3a1ded34..d4ce2da9b88ab1 100644 --- a/src/vs/platform/keybinding/common/keybinding.ts +++ b/src/vs/platform/keybinding/common/keybinding.ts @@ -18,6 +18,12 @@ export interface IUserFriendlyKeybinding { command: string; args?: any; when?: string; + /** + * When `true`, the keybinding is registered as a system-wide (OS global) shortcut that fires + * even when VS Code does not have focus. Desktop only; ignored on web/server. Only honored for + * user `keybindings.json` entries (not extension-contributed keybindings). + */ + systemWide?: boolean; } export interface IKeyboardEvent { diff --git a/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts b/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts index 76a5e8ccb643cd..135db192b1d680 100644 --- a/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts +++ b/src/vs/platform/keybinding/common/resolvedKeybindingItem.ts @@ -19,8 +19,14 @@ export class ResolvedKeybindingItem { public readonly isDefault: boolean; public readonly extensionId: string | null; public readonly isBuiltinExtension: boolean; + /** + * Whether this keybinding was declared as a system-wide (OS global) shortcut via + * `keybindings.json`. Only ever `true` for user keybindings; defaults/extension keybindings + * are always `false`. + */ + public readonly systemWide: boolean; - constructor(resolvedKeybinding: ResolvedKeybinding | undefined, command: string | null, commandArgs: any, when: ContextKeyExpression | undefined, isDefault: boolean, extensionId: string | null, isBuiltinExtension: boolean) { + constructor(resolvedKeybinding: ResolvedKeybinding | undefined, command: string | null, commandArgs: any, when: ContextKeyExpression | undefined, isDefault: boolean, extensionId: string | null, isBuiltinExtension: boolean, systemWide: boolean = false) { this.resolvedKeybinding = resolvedKeybinding; this.chords = resolvedKeybinding ? toEmptyArrayIfContainsNull(resolvedKeybinding.getDispatchChords()) : []; if (resolvedKeybinding && this.chords.length === 0) { @@ -34,6 +40,7 @@ export class ResolvedKeybindingItem { this.isDefault = isDefault; this.extensionId = extensionId; this.isBuiltinExtension = isBuiltinExtension; + this.systemWide = systemWide; } } diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index c7a145e7dee716..a3f53de08e8958 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -666,6 +666,9 @@ export class Menubar extends Disposable { case StateType.Updating: return [new MenuItem({ label: nls.localize('miInstallingUpdate', "Installing Update..."), enabled: false })]; + case StateType.Cancelling: + return [new MenuItem({ label: nls.localize('miCancellingUpdate', "Cancelling Update..."), enabled: false })]; + case StateType.Ready: return [new MenuItem({ label: this.mnemonicLabel(nls.localize('miRestartToUpdate', "Restart to &&Update")), click: () => { diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 0647066e459409..2f2bcccde3b594 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -88,6 +88,39 @@ export const enum FocusMode { Force, } +export interface INativeSystemWideKeybinding { + + /** + * The keybinding in Electron accelerator format (e.g. `Control+Cmd+A`). + * See https://www.electronjs.org/docs/latest/api/accelerator. + */ + readonly accelerator: string; + + /** + * The command to execute when the global shortcut is triggered. + */ + readonly commandId: string; + + /** + * Optional command arguments as configured in `keybindings.json`. Must be JSON-serializable. + */ + readonly args?: unknown; + + /** + * The user settings label (e.g. `ctrl+cmd+a`) for diagnostics/notifications. + */ + readonly userSettingsLabel?: string; +} + +export interface INativeSystemWideKeybindingResult { + + /** + * The user settings labels (or accelerators) that could not be registered, e.g. because the + * accelerator is already taken by the OS or another application. + */ + readonly failed: string[]; +} + export interface ICommonNativeHostService { readonly _serviceBrand: undefined; @@ -141,6 +174,13 @@ export interface ICommonNativeHostService { openAgentsWindow(options?: { folderUri?: UriComponents; sessionResource?: UriComponents }): Promise<void>; + /** + * Registers this window's set of system-wide (OS global) keybindings with the main process, + * replacing any previously registered by this window. The shortcuts fire even when the + * application is not focused. Returns the set that could not be registered. + */ + syncSystemWideKeybindings(keybindings: INativeSystemWideKeybinding[]): Promise<INativeSystemWideKeybindingResult>; + isFullScreen(options?: INativeHostOptions): Promise<boolean>; toggleFullScreen(options?: INativeHostOptions): Promise<void>; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 4135c6d145a972..13ee6139050337 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -27,7 +27,8 @@ import { IEnvironmentMainService } from '../../environment/electron-main/environ import { createDecorator, IInstantiationService } from '../../instantiation/common/instantiation.js'; import { ILifecycleMainService, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; import { ILogService } from '../../log/common/log.js'; -import { FocusMode, ICommonNativeHostService, INativeHostOptions, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { FocusMode, ICommonNativeHostService, INativeHostOptions, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, IOSProperties, IOSStatistics, IStartTracingOptions, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../common/native.js'; +import { IGlobalKeybindingsMainService } from '../../globalKeybindings/electron-main/globalKeybindingsMainService.js'; import { IProductService } from '../../product/common/productService.js'; import { IPartsSplash } from '../../theme/common/themeService.js'; import { IThemeMainService } from '../../theme/electron-main/themeMainService.js'; @@ -48,7 +49,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { IProxyAuthService } from './auth.js'; import { AuthInfo, Credentials, IRequestService } from '../../request/common/request.js'; import { randomPath } from '../../../base/common/extpath.js'; -import { CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; export interface INativeHostMainService extends AddFirstParameterToFunctions<ICommonNativeHostService, Promise<unknown> /* only methods, not events */, number | undefined /* window ID */> { } @@ -71,7 +72,8 @@ export class NativeHostMainService extends Disposable implements INativeHostMain @IConfigurationService private readonly configurationService: IConfigurationService, @IRequestService private readonly requestService: IRequestService, @IProxyAuthService private readonly proxyAuthService: IProxyAuthService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IGlobalKeybindingsMainService private readonly globalKeybindingsMainService: IGlobalKeybindingsMainService ) { super(); @@ -275,7 +277,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain private async doOpenWindow(windowId: number | undefined, toOpen: IWindowOpenable[], options: IOpenWindowOptions = Object.create(null)): Promise<void> { if (toOpen.length > 0) { - await this.windowsMainService.open({ + const windows = await this.windowsMainService.open({ context: OpenContext.API, contextWindowId: windowId, urisToOpen: toOpen, @@ -294,6 +296,15 @@ export class NativeHostMainService extends Disposable implements INativeHostMain forceProfile: options.forceProfile, forceTempProfile: options.forceTempProfile, }); + + // Hand off a chat session to the opened window so it restores both the + // folder and the session (e.g. the Agents window "Open in VS Code" flow). + // Only meaningful when exactly one window is opened so the session is + // not sent to an ambiguous target. + const chatSessionToOpen = options.chatSessionToOpen; + if (chatSessionToOpen && windows.length === 1) { + windows[0].sendWhenReady('vscode:openChatSession', CancellationToken.None, URI.revive(chatSessionToOpen).toString()); + } } } @@ -315,6 +326,13 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } + async syncSystemWideKeybindings(windowId: number | undefined, keybindings: INativeSystemWideKeybinding[]): Promise<INativeSystemWideKeybindingResult> { + if (typeof windowId !== 'number') { + return { failed: [] }; + } + return this.globalKeybindingsMainService.updateKeybindings(windowId, keybindings); + } + async isFullScreen(windowId: number | undefined, options?: INativeHostOptions): Promise<boolean> { const window = this.windowById(options?.targetWindowId, windowId); return window?.isFullScreen ?? false; diff --git a/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts b/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts index bb5855347b6f60..b2c43c051ca153 100644 --- a/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts +++ b/src/vs/platform/otel/node/sqlite/otelSqliteStore.ts @@ -6,7 +6,6 @@ import { mkdirSync } from 'fs'; // The 'node:module' specifier is unresolvable by the Electron renderer // ESM loader (used by the unit test harness), so use the bare form. -// eslint-disable-next-line local/code-import-patterns import { createRequire } from 'module'; // eslint-disable-next-line local/code-import-patterns import type { DatabaseSync, StatementSync } from 'node:sqlite'; diff --git a/src/vs/platform/policy/common/copilotManagedSettings.ts b/src/vs/platform/policy/common/copilotManagedSettings.ts index a0b91f1c5c7d68..0af3bf67fef297 100644 --- a/src/vs/platform/policy/common/copilotManagedSettings.ts +++ b/src/vs/platform/policy/common/copilotManagedSettings.ts @@ -35,8 +35,13 @@ export const COPILOT_EXTRA_MARKETPLACES_KEY = 'extraKnownMarketplaces'; /** Managed-settings key for the strict-marketplace allowlist (carried as a JSON-encoded array of source entries; absent = no restrictions, `[]` = lockdown). */ export const COPILOT_STRICT_MARKETPLACES_KEY = 'strictKnownMarketplaces'; -/** Managed-settings key for the default chat model (carried as a plain string: `auto`, a model family name, or a full model id). */ -export const COPILOT_MODEL_KEY = 'model'; +/** + * Managed-settings key for the default chat model (carried as a plain string: `auto`, a model + * family name, or a full model id). Nested under `permissions` in the managed-settings schema + * (alongside {@link COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY}), so it flattens to the dot-path + * `permissions.model` in the normalized bag — the key policy `value()` callbacks must read. + */ +export const COPILOT_MODEL_KEY = 'permissions.model'; /** * Enterprise OTel managed-settings keys. These are the scalar leaves of the canonical @@ -93,6 +98,31 @@ export function managedSettingValue(key: string): (policyData: IPolicyData) => M return callback; } +let managedModelValueCallback: ((policyData: IPolicyData) => ManagedSettingValue | undefined) | undefined; + +/** + * `value` callback for the default-chat-model managed setting ({@link COPILOT_MODEL_KEY}). Like + * {@link managedSettingValue} it locks the setting to the managed value and otherwise falls through + * to the user's own value, but it additionally trims the string and treats a blank/whitespace-only + * value as "unset" (returns `undefined`) — an admin clearing the field must not lock the setting to + * an empty string. The model-specific normalization lives here, alongside the other managed-settings + * handling, rather than inline at the policy declaration, so every managed-settings control is wired + * the same way. + * + * Memoized (single key) so repeated calls return the SAME function reference, matching the + * reference-identity contract {@link managedSettingValue} relies on for `isSamePolicyDefinition`. + */ +export function managedModelValue(): (policyData: IPolicyData) => ManagedSettingValue | undefined { + if (!managedModelValueCallback) { + managedModelValueCallback = policyData => { + const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; + const trimmed = typeof model === 'string' ? model.trim() : undefined; + return trimmed ? trimmed : undefined; + }; + } + return managedModelValueCallback; +} + export const INativeManagedSettingsService = createDecorator<INativeManagedSettingsService>('nativeManagedSettingsService'); export interface INativeManagedSettingsService { @@ -197,14 +227,12 @@ export function projectManagedSettings(values: ManagedSettingsData, definitions: } /** - * The delivery channel that provided the active managed-settings bag. Managed settings can be - * delivered by more than one channel, so this names the known sources to give policy evaluation - * and the Policy Diagnostics report one shared vocabulary. Extend this union (and - * {@link selectManagedSettings}) when adding a new channel. + * A delivery channel that can provide managed settings. Managed settings can be delivered by more + * than one channel, so this names the known sources to give policy evaluation and the Policy + * Diagnostics report one shared vocabulary. Extend this union (and {@link MANAGED_SETTINGS_CHANNELS} + * / {@link pickManagedSettings}) when adding a new channel. */ -export type ManagedSettingsSource = - /** No channel currently provides managed settings. */ - | 'none' +export type ManagedSettingsChannel = /** GitHub `/copilot_internal/managed_settings` endpoint (server-delivered). */ | 'server' /** Native MDM: OS registry (Windows) / managed preferences (macOS) via `@vscode/policy-watcher`. */ @@ -212,34 +240,105 @@ export type ManagedSettingsSource = /** File on a well-known disk path (`managed-settings.json`). */ | 'file'; -export interface IManagedSettingsSelection { - /** Which channel won. */ - readonly source: ManagedSettingsSource; - /** The winning bag, or `undefined` when {@link source} is `'none'`. */ - readonly values: ManagedSettingsData | undefined; +/** + * The source attributed to an effective managed setting (or to the overall report). A + * {@link ManagedSettingsChannel} once a channel has won, or `'none'` when no channel contributes. + */ +export type ManagedSettingsSource = ManagedSettingsChannel | 'none'; + +/** + * The delivery channels in fixed precedence order (highest first): native MDM → server-delivered → + * file on disk. This single ordered list drives the per-key resolution in {@link pickManagedSettings} + * and is the one place to extend when a new channel is introduced. Rationale for the order: the + * server is harder to bypass than local MDM, and a local file is the most easily tampered with. + */ +export const MANAGED_SETTINGS_CHANNELS: readonly ManagedSettingsChannel[] = ['nativeMdm', 'server', 'file']; + +/** A single channel's contribution to a managed-settings key, for provenance in the resolution. */ +export interface IManagedSettingsContribution { + /** The channel that supplied this value. */ + readonly channel: ManagedSettingsChannel; + /** The value the channel supplied for the key. */ + readonly value: ManagedSettingValue; +} + +/** How a single managed-settings key was resolved across the delivery channels. */ +export interface IManagedSettingResolution { + /** The effective (winning) value applied for the key. */ + readonly value: ManagedSettingValue; + /** The channel whose value won (always the first {@link contributions} entry's channel). */ + readonly source: ManagedSettingsChannel; + /** Every channel that supplied this key, in precedence order (winner first, overridden after). */ + readonly contributions: readonly IManagedSettingsContribution[]; +} + +/** The result of merging managed settings from every delivery channel on a per-key basis. */ +export interface IManagedSettingsPick { + /** The effective merged bag: the winning value for each key contributed by any channel. */ + readonly values: ManagedSettingsData; + /** Per-key provenance: how each key resolved and which channels were overridden. */ + readonly resolutions: ReadonlyMap<string, IManagedSettingResolution>; + /** The channels that supplied at least one *winning* key, in precedence order. */ + readonly activeSources: readonly ManagedSettingsChannel[]; } /** - * Select the authoritative managed-settings bag from the available delivery channels. + * Merge the managed-settings bags from every delivery channel on a **per-key** basis. * - * Precedence (highest first): server-delivered → native MDM → file on disk. The channels are - * never merged — managed settings have a single authoritative source, so the first non-empty bag - * wins outright. Centralizing the precedence here (rather than inlining it at each call site) - * keeps policy evaluation ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics - * report from drifting apart, and gives one obvious place to extend when a new channel is - * introduced. + * Precedence (highest first): native MDM → server-delivered → file on disk. Unlike a single + * authoritative source, the channels *are* merged key-by-key: for each key the highest-precedence + * channel that supplies it wins, but a key that the higher channels never set is still filled in by + * a lower channel. A value an admin locks via native MDM therefore cannot be overwritten by the + * server or a file, while keys those higher channels leave unset remain available to lower ones. + * + * The parameter order matches the precedence so call sites read top-to-bottom. Centralizing the + * resolution here (rather than inlining it at each call site) keeps policy evaluation + * ({@link AccountPolicyService.getPolicyData}) and the Policy Diagnostics report from drifting apart, + * and gives one obvious place to extend when a new channel is introduced. Empty or absent channels + * contribute nothing. */ -export function selectManagedSettings(server: ManagedSettingsData | undefined, nativeMdm: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsSelection { - if (server && !isEmptyObject(server)) { - return { source: 'server', values: server }; - } - if (nativeMdm && !isEmptyObject(nativeMdm)) { - return { source: 'nativeMdm', values: nativeMdm }; +export function pickManagedSettings(nativeMdm: ManagedSettingsData | undefined, server: ManagedSettingsData | undefined, file: ManagedSettingsData | undefined): IManagedSettingsPick { + const bags: Record<ManagedSettingsChannel, ManagedSettingsData | undefined> = { nativeMdm, server, file }; + + // Walk channels highest-precedence first: the first channel to supply a key wins, and later + // channels are appended as overridden contributions for provenance. + const resolutions = new Map<string, { value: ManagedSettingValue; source: ManagedSettingsChannel; contributions: IManagedSettingsContribution[] }>(); + for (const channel of MANAGED_SETTINGS_CHANNELS) { + const bag = bags[channel]; + if (!bag) { + continue; + } + // Iterate own keys only (managed-settings bags are untrusted input): avoids enumerating + // inherited enumerable properties the way `for...in` would. + for (const key of Object.keys(bag)) { + const value = bag[key]; + if (value === undefined) { + continue; + } + const existing = resolutions.get(key); + if (existing) { + existing.contributions.push({ channel, value }); + } else { + resolutions.set(key, { value, source: channel, contributions: [{ channel, value }] }); + } + } } - if (file && !isEmptyObject(file)) { - return { source: 'file', values: file }; + + const activeSources = new Set<ManagedSettingsChannel>(); + const entries: [string, ManagedSettingValue][] = []; + for (const [key, resolution] of resolutions) { + entries.push([key, resolution.value]); + activeSources.add(resolution.source); } - return { source: 'none', values: undefined }; + + return { + // Build via Object.fromEntries (define-property semantics) rather than bracket assignment so + // an untrusted `__proto__` key can't corrupt the merged bag's prototype chain. + values: Object.fromEntries(entries), + resolutions, + // Preserve precedence order for a stable, readable report. + activeSources: MANAGED_SETTINGS_CHANNELS.filter(channel => activeSources.has(channel)), + }; } // --- File-based managed settings --- @@ -301,18 +400,37 @@ function encodeStringMap(value: unknown): Record<string, string> | undefined { return out; } +/** Pass an object value through unchanged; omit the key for any non-object value. */ +function encodeObject(value: unknown): object | undefined { + return isObject(value) ? value : undefined; +} + +/** Pass an array value through unchanged (including an empty array); omit the key otherwise. */ +function encodeArray(value: unknown): unknown[] | undefined { + return Array.isArray(value) ? value : undefined; +} + +/** + * Encode the schema's `{ [id]: { source } }` marketplace map into the canonical + * `{ [name]: url-or-shorthand }` dict; drops malformed entries (with an optional warning) and omits + * the key when there are none. + */ +function encodeExtraMarketplaces(value: unknown, onWarn?: (msg: string) => void): Record<string, string> | undefined { + return extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)); +} + const STRUCTURED_MANAGED_SETTINGS: readonly IStructuredManagedSetting[] = [ { key: COPILOT_ENABLED_PLUGINS_KEY, - encode: value => isObject(value) ? value : undefined, + encode: encodeObject, }, { key: COPILOT_STRICT_MARKETPLACES_KEY, - encode: value => Array.isArray(value) ? value : undefined, + encode: encodeArray, }, { key: COPILOT_EXTRA_MARKETPLACES_KEY, - encode: (value, onWarn) => extraKnownMarketplacesToConfigDict(normalizeExtraKnownMarketplaces(value, onWarn)), + encode: encodeExtraMarketplaces, }, { // Nested under `telemetry`; carried as a JSON-encoded `{ [k]: string }` map. Non-string diff --git a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts index 83755700f39659..b9981ec488109d 100644 --- a/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts +++ b/src/vs/platform/policy/test/common/copilotManagedSettings.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { IStringDictionary } from '../../../../base/common/collections.js'; import { IPolicyData } from '../../../../base/common/defaultAccount.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, managedSettingValue, projectManagedSettings, selectManagedSettings } from '../../common/copilotManagedSettings.js'; +import { collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, managedSettingValue, projectManagedSettings, pickManagedSettings } from '../../common/copilotManagedSettings.js'; import { PolicyDefinition } from '../../common/policy.js'; suite('Copilot managed settings projection', () => { @@ -107,32 +107,104 @@ suite('Copilot managed settings projection', () => { ); assert.strictEqual(warnings.length, 1); }); +}); + +suite('Copilot managed settings per-key precedence (pickManagedSettings)', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('distinct keys each win from their highest-precedence channel; a lower channel fills a gap the higher ones leave', () => { + // The headline per-key behavior: `shared` is contested by all three (native wins) while + // `nativeOnly`/`serverOnly`/`fileOnly` are each supplied by a single channel and all survive. + const pick = pickManagedSettings( + { 'shared': 'native', 'nativeOnly': 'n' }, + { 'shared': 'server', 'serverOnly': 's' }, + { 'shared': 'file', 'fileOnly': 'f' }, + ); + assert.deepStrictEqual(pick.values, { 'shared': 'native', 'nativeOnly': 'n', 'serverOnly': 's', 'fileOnly': 'f' }); + assert.deepStrictEqual(pick.activeSources, ['nativeMdm', 'server', 'file']); + assert.deepStrictEqual(pick.resolutions.get('shared'), { + value: 'native', + source: 'nativeMdm', + contributions: [ + { channel: 'nativeMdm', value: 'native' }, + { channel: 'server', value: 'server' }, + { channel: 'file', value: 'file' }, + ], + }); + }); + + test('with native absent, the mid-tier server wins a contested key over file', () => { + const pick = pickManagedSettings(undefined, { 'k': 'server' }, { 'k': 'file' }); + assert.deepStrictEqual(pick.resolutions.get('k'), { + value: 'server', + source: 'server', + contributions: [ + { channel: 'server', value: 'server' }, + { channel: 'file', value: 'file' }, + ], + }); + assert.deepStrictEqual(pick.activeSources, ['server']); + }); - test('selectManagedSettings: server wins over native MDM and file, never merged', () => { - const selection = selectManagedSettings( - { 'permissions.x': 'server' }, - { 'permissions.y': 'native' }, - { 'permissions.z': 'file' }, + test('falsy-but-present values are real contributions and win over a lower channel', () => { + // `false`, `0` and `''` must not be mistaken for "unset" — a higher channel that sets them + // still locks the key against a lower channel's value. + const pick = pickManagedSettings( + { 'flag': false, 'count': 0, 'name': '' }, + undefined, + { 'flag': true, 'count': 99, 'name': 'lower' }, ); - assert.deepStrictEqual(selection, { source: 'server', values: { 'permissions.x': 'server' } }); + assert.deepStrictEqual(pick.values, { 'flag': false, 'count': 0, 'name': '' }); + assert.deepStrictEqual(pick.activeSources, ['nativeMdm']); }); - test('selectManagedSettings: falls through to native MDM when server is absent or empty', () => { - const fromUndefined = selectManagedSettings(undefined, { 'permissions.y': 'native' }, undefined); - const fromEmptyObject = selectManagedSettings({}, { 'permissions.y': 'native' }, undefined); - assert.deepStrictEqual(fromUndefined, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); - assert.deepStrictEqual(fromEmptyObject, { source: 'nativeMdm', values: { 'permissions.y': 'native' } }); + test('an explicit `undefined` hole in a higher channel falls through to a lower channel', () => { + // A key present-but-undefined is skipped, so a lower channel can supply it. + const pick = pickManagedSettings( + { 'a': undefined as unknown as string, 'b': 'native' }, + { 'a': 'server' }, + undefined, + ); + assert.deepStrictEqual(pick.values, { 'a': 'server', 'b': 'native' }); + assert.strictEqual(pick.resolutions.get('a')!.source, 'server'); }); - test('selectManagedSettings: falls through to file when server and native MDM are absent or empty', () => { - const fromUndefined = selectManagedSettings(undefined, undefined, { 'permissions.z': 'file' }); - const fromEmptyObjects = selectManagedSettings({}, {}, { 'permissions.z': 'file' }); - assert.deepStrictEqual(fromUndefined, { source: 'file', values: { 'permissions.z': 'file' } }); - assert.deepStrictEqual(fromEmptyObjects, { source: 'file', values: { 'permissions.z': 'file' } }); + test('the merged bag is a fresh object, never an alias of an input channel bag', () => { + // AccountPolicyService projects `pick.values` directly, relying on it not aliasing/mutating a + // channel's bag. + const native = { 'a': 'native' }; + const pick = pickManagedSettings(native, undefined, undefined); + assert.notStrictEqual(pick.values, native); + assert.deepStrictEqual(pick.values, { 'a': 'native' }); + }); + + test('empty/absent channels contribute nothing and activeSources skips a non-contributing middle channel', () => { + assert.deepStrictEqual( + { + partial: pickManagedSettings({}, { 'b': 'server' }, undefined), + // native + file contribute, server does not — activeSources must skip the gap. + gap: pickManagedSettings({ 'x': 'n' }, undefined, { 'y': 'f' }).activeSources, + allUndefined: pickManagedSettings(undefined, undefined, undefined), + allEmpty: pickManagedSettings({}, {}, {}), + }, + { + partial: { values: { 'b': 'server' }, resolutions: new Map([['b', { value: 'server', source: 'server', contributions: [{ channel: 'server', value: 'server' }] }]]), activeSources: ['server'] }, + gap: ['nativeMdm', 'file'], + allUndefined: { values: {}, resolutions: new Map(), activeSources: [] }, + allEmpty: { values: {}, resolutions: new Map(), activeSources: [] }, + }, + ); }); - test('selectManagedSettings: reports `none` with no values when every channel is empty', () => { - assert.deepStrictEqual(selectManagedSettings(undefined, undefined, undefined), { source: 'none', values: undefined }); - assert.deepStrictEqual(selectManagedSettings({}, {}, {}), { source: 'none', values: undefined }); + test('a malicious `__proto__` key does not pollute any prototype chain', () => { + // Simulates a JSON-parsed bag carrying an own `__proto__` key with an object value (the + // classic prototype-pollution vector). Merging it must neither pollute Object.prototype nor + // corrupt the returned bag's own prototype. + const malicious = JSON.parse('{ "__proto__": { "polluted": true } }') as Record<string, string>; + const pick = pickManagedSettings(malicious, undefined, undefined); + assert.strictEqual(({} as Record<string, unknown>).polluted, undefined); + assert.strictEqual(Object.prototype.hasOwnProperty.call(Object.prototype, 'polluted'), false); + assert.strictEqual(Object.getPrototypeOf(pick.values), Object.prototype); }); }); diff --git a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts index 45a9c0ff3ddabf..3e718ae4dbadb5 100644 --- a/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts +++ b/src/vs/platform/policy/test/common/fileManagedSettingsService.test.ts @@ -15,7 +15,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { NullLogService } from '../../../log/common/log.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, normalizeManagedSettings } from '../../common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, managedModelValue, normalizeManagedSettings } from '../../common/copilotManagedSettings.js'; import { FileManagedSettingsService } from '../../common/fileManagedSettingsService.js'; import { FileManagedSettingsChannelClient } from '../../common/fileManagedSettingsIpc.js'; @@ -83,6 +83,20 @@ suite('normalizeManagedSettings', () => { }); }); + test('flattens the model setting nested under permissions', () => { + // The server/file managed-settings schema carries `model` under `permissions` + // (alongside disableBypassPermissionsMode); it must flatten to `permissions.model`, + // which is the key the ChatDefaultModel policy value callback reads. + const result = normalizeManagedSettings({ + permissions: { model: 'auto' } + }); + assert.deepStrictEqual(result, { + 'permissions.model': 'auto' + }); + assert.strictEqual(COPILOT_MODEL_KEY, 'permissions.model'); + assert.strictEqual(managedModelValue()({ managedSettings: result }), 'auto'); + }); + test('handles empty object', () => { assert.deepStrictEqual(normalizeManagedSettings({}), {}); }); diff --git a/src/vs/platform/request/test/node/requestService.test.ts b/src/vs/platform/request/test/node/requestService.test.ts index 50f7d72068ab9d..dfab989d694ab6 100644 --- a/src/vs/platform/request/test/node/requestService.test.ts +++ b/src/vs/platform/request/test/node/requestService.test.ts @@ -32,17 +32,42 @@ suite('Request Service', () => { test('Request cancellation during retry backoff', async () => { const cts = store.add(new CancellationTokenSource()); - const startTime = Date.now(); - setTimeout(() => cts.cancel(), 50); + let attemptCount = 0; + const mockRawRequest = (_opts: any, _callback: Function) => { + attemptCount++; + const mockReq: unknown = { + on: (event: string, handler: Function) => { + if (event === 'error') { + const err = new Error('Connection refused') as NodeJS.ErrnoException; + err.code = 'ECONNREFUSED'; + // Fail the first attempt with a transient error, then cancel while the + // retry backoff is pending so cancellation is observed during the backoff. + setTimeout(() => { + handler(err); + cts.cancel(); + }, 0); + } + }, + end: () => { }, + abort: () => { }, + setTimeout: () => { } + }; + return mockReq; + }; try { - await nodeRequest({ url: 'http://localhost:9999/nonexistent', callSite: 'requestService.test.cancellation' }, cts.token); + await nodeRequest({ + url: 'http://example.com', + type: 'GET', + getRawRequest: () => mockRawRequest as IRawRequestFunction, + callSite: 'requestService.test.cancellation' + }, cts.token); assert.fail('Request should have been cancelled'); } catch (err) { - const elapsed = Date.now() - startTime; - assert.ok(err instanceof CancellationError, 'Error should be CancellationError'); - assert.ok(elapsed < 200, `Request should be cancelled quickly, but took ${elapsed}ms`); + assert.ok(err instanceof CancellationError, 'Error should be a CancellationError'); } + + assert.strictEqual(attemptCount, 1, 'Request should be cancelled during backoff without further retries'); }); test('should retry GET requests on transient errors', async () => { diff --git a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts index 039f93ca6b73f8..3ccf743fa74c37 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxEngine.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxEngine.ts @@ -42,6 +42,16 @@ interface ITerminalSandboxFileSystemAccessPaths { export interface ITerminalSandboxRuntimeInfo { /** Directory that contains `node_modules/@vscode/sandbox-runtime` and `node_modules/@vscode/ripgrep`. */ appRoot: string; + /** + * Name of the directory (relative to {@link appRoot}) that holds the native + * binaries `ripgrep-universal` and `@microsoft/mxc-sdk`. In a packaged desktop + * build these are unpacked from the archive into `node_modules.asar.unpacked`; + * in dev and on remote they live in plain `node_modules`. Defaults to + * `node_modules`. Note the sandbox-runtime CLI itself is always resolved from + * plain `node_modules` (it is duplicated out of the archive) because it is + * spawned as a standalone Node subprocess without the ASAR resolution hook. + */ + nativeModulesDir?: string; /** Path of the node/electron executable used to run sandbox-runtime. */ execPath?: string; /** @@ -601,10 +611,11 @@ export class TerminalSandboxEngine extends Disposable { this._runAsNode = runtimeInfo.runAsNode ?? false; this._userHome = await this._host.getUserHome(); this._srtPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'sandbox-runtime', 'dist', 'cli.js'); + const nativeModulesDir = runtimeInfo.nativeModulesDir ?? 'node_modules'; const rgPlatform = this._os === OperatingSystem.Windows ? 'win32' : this._os === OperatingSystem.Macintosh ? 'darwin' : 'linux'; const rgBinary = this._os === OperatingSystem.Windows ? 'rg.exe' : 'rg'; - this._rgPath = this._pathJoin(this._appRoot, 'node_modules', '@vscode', 'ripgrep-universal', 'bin', `${rgPlatform}-${arch}`, rgBinary); - this._mxcPath = this._windowsMxcRuntime.getExecutablePath(this._appRoot, runtimeInfo.arch); + this._rgPath = this._pathJoin(this._appRoot, nativeModulesDir, '@vscode', 'ripgrep-universal', 'bin', `${rgPlatform}-${arch}`, rgBinary); + this._mxcPath = this._windowsMxcRuntime.getExecutablePath(this._appRoot, nativeModulesDir, runtimeInfo.arch); } private async _createSandboxConfig(): Promise<string | undefined> { @@ -681,7 +692,7 @@ export class TerminalSandboxEngine extends Disposable { }; if (this._os !== OperatingSystem.Windows) { const sandboxRuntimeSettings = sandboxSettings as Record<string, unknown>; - this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, allowNetwork ? this._withoutNetworkRuntimeSetting(runtimeSetting) : runtimeSetting); + this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, runtimeSetting); this._mergeAdditionalSandboxConfigProperties(sandboxRuntimeSettings, commandRuntimeSetting); if (this._os === OperatingSystem.Macintosh) { sandboxRuntimeSettings.allowPty ??= true; @@ -843,12 +854,6 @@ export class TerminalSandboxEngine extends Disposable { return paths.filter((path): path is string => typeof path === 'string'); } - private _withoutNetworkRuntimeSetting(runtimeSetting: Record<string, unknown>): Record<string, unknown> { - const sanitizedRuntimeSetting = { ...runtimeSetting }; - delete sanitizedRuntimeSetting.network; - return sanitizedRuntimeSetting; - } - private _mergeAdditionalSandboxConfigProperties(target: Record<string, unknown>, additional: Record<string, unknown>): void { for (const [key, value] of Object.entries(additional)) { if (!Object.prototype.hasOwnProperty.call(target, key)) { diff --git a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts index d109c521aa1fc8..38e066b27286b9 100644 --- a/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts +++ b/src/vs/platform/sandbox/common/terminalSandboxMxcRuntime.ts @@ -28,7 +28,7 @@ export const IWindowsMxcTerminalSandboxRuntime = createDecorator<IWindowsMxcTerm export interface IWindowsMxcTerminalSandboxRuntime { readonly _serviceBrand: undefined; - getExecutablePath(appRoot: string, arch: string | undefined): string; + getExecutablePath(appRoot: string, nativeModulesDir: string, arch: string | undefined): string; getRuntimeReadPaths(appRoot: string | undefined, executablePath: string | undefined): string[]; createConfig(options: IWindowsMxcConfigOptions, buildSandboxPayload: IWindowsMxcBuildSandboxPayload): Promise<IWindowsMxcConfig>; wrapCommand(executablePath: string, configPath: string): string; @@ -47,9 +47,9 @@ export class WindowsMxcTerminalSandboxRuntime implements IWindowsMxcTerminalSand private readonly _configVersion = '0.6.0-alpha'; - getExecutablePath(appRoot: string, arch: string | undefined): string { + getExecutablePath(appRoot: string, nativeModulesDir: string, arch: string | undefined): string { const binArch = arch === 'arm64' ? 'arm64' : 'x64'; - return win32.join(appRoot, 'node_modules', '@microsoft', 'mxc-sdk', 'bin', binArch, 'wxc-exec.exe'); + return win32.join(appRoot, nativeModulesDir, '@microsoft', 'mxc-sdk', 'bin', binArch, 'wxc-exec.exe'); } getRuntimeReadPaths(appRoot: string | undefined, executablePath: string | undefined): string[] { diff --git a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts index 83c2b65599fdc4..3e1600aa9ae6e7 100644 --- a/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts +++ b/src/vs/platform/sandbox/test/common/terminalSandboxEngine.test.ts @@ -254,6 +254,28 @@ suite('TerminalSandboxEngine', () => { strictEqual(config.allowPty, false); }); + test('sandbox config preserves advanced runtime network settings when allowNetwork is enabled', async () => { + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAllowNetwork, true); + setSandboxSetting(AgentSandboxSettingId.AgentSandboxAdvancedRuntime, { + network: { + allowAllUnixSockets: true, + enabled: true, + }, + }); + const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); + + const configPath = await engine.getSandboxConfigPath(); + ok(configPath, 'Config path should be defined'); + const config = JSON.parse(createdFiles.get(configPath)!); + + deepStrictEqual(config.network, { + allowedDomains: [], + deniedDomains: [], + enabled: false, + allowAllUnixSockets: true, + }); + }); + test('requestAllowNetwork keeps the command sandboxed and refreshes its network config', async () => { setSandboxSetting(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests, true); const engine = store.add(instantiationService.createInstance(TerminalSandboxEngine, createHost())); diff --git a/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts b/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts index 33971f8b07283d..fc5cb83fe088d7 100644 --- a/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts +++ b/src/vs/platform/telemetry/common/languageModelToolTelemetry.ts @@ -21,12 +21,14 @@ export type LanguageModelToolInvokedEvent = LanguageModelToolTelemetryData & { result: 'success' | 'error' | 'userCancelled'; prepareTimeMs?: number; invocationTimeMs?: number; + provider?: string; }; export type LanguageModelToolInvokedClassification = LanguageModelToolTelemetryClassification & { result: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether invoking the LanguageModelTool resulted in an error.' }; prepareTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in prepareToolInvocation method in milliseconds.' }; invocationTimeMs?: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time spent in tool invoke method in milliseconds.' }; + provider?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The agent host provider that invoked the tool (e.g. copilotcli, claude, codex), if applicable.' }; owner: 'roblourens'; comment: 'Provides insight into the usage of language model tools.'; }; diff --git a/src/vs/platform/theme/common/sizes/baseSizes.ts b/src/vs/platform/theme/common/sizes/baseSizes.ts index b783ac1d4aa411..bb583270dd9dea 100644 --- a/src/vs/platform/theme/common/sizes/baseSizes.ts +++ b/src/vs/platform/theme/common/sizes/baseSizes.ts @@ -8,17 +8,75 @@ import { registerSize, sizeForAllThemes } from '../sizeUtils.js'; // ------ Font Sizes +/** @deprecated Use {@link fontSizeBody1} instead. */ export const bodyFontSize = registerSize('bodyFontSize', sizeForAllThemes(13, 'px'), - nls.localize('bodyFontSize', "Base font size. This size is used if not overridden by a component.")); + nls.localize('bodyFontSize', "Base font size. This size is used if not overridden by a component."), + nls.localize('bodyFontSize.deprecated', "Deprecated: use `fontSize.body1` instead.")); +/** @deprecated Use {@link fontSizeLabel1} instead. */ export const bodyFontSizeSmall = registerSize('bodyFontSize.small', sizeForAllThemes(12, 'px'), - nls.localize('bodyFontSizeSmall', "Small font size for secondary content.")); + nls.localize('bodyFontSizeSmall', "Small font size for secondary content."), + nls.localize('bodyFontSizeSmall.deprecated', "Deprecated: use `fontSize.label1` instead.")); +/** @deprecated Use {@link fontSizeBody2} instead. */ export const bodyFontSizeXSmall = registerSize('bodyFontSize.xSmall', sizeForAllThemes(11, 'px'), - nls.localize('bodyFontSizeXSmall', "Extra small font size for less prominent content.")); + nls.localize('bodyFontSizeXSmall', "Extra small font size for less prominent content."), + nls.localize('bodyFontSizeXSmall.deprecated', "Deprecated: use `fontSize.body2` instead.")); + +// ------ Font ramp +// +// A generic font-size ramp (headings, body and labels) mirroring the agents +// window ramp. "Strong" variants are NOT separate size tokens: reuse the +// matching size token paired with `fontWeight.semiBold` (600). Regular text +// pairs with `fontWeight.regular` (400). + +export const fontSizeHeading1 = registerSize('fontSize.heading1', + sizeForAllThemes(26, 'px'), + nls.localize('fontSizeHeading1', "Heading 1 font size (largest heading).")); + +export const fontSizeHeading2 = registerSize('fontSize.heading2', + sizeForAllThemes(18, 'px'), + nls.localize('fontSizeHeading2', "Heading 2 font size (title).")); + +export const fontSizeHeading3 = registerSize('fontSize.heading3', + sizeForAllThemes(13, 'px'), + nls.localize('fontSizeHeading3', "Heading 3 font size (subtitle).")); + +export const fontSizeBody1 = registerSize('fontSize.body1', + sizeForAllThemes(13, 'px'), + nls.localize('fontSizeBody1', "Primary body font size.")); + +export const fontSizeBody2 = registerSize('fontSize.body2', + sizeForAllThemes(11, 'px'), + nls.localize('fontSizeBody2', "Secondary body font size.")); + +export const fontSizeLabel1 = registerSize('fontSize.label1', + sizeForAllThemes(12, 'px'), + nls.localize('fontSizeLabel1', "Label 1 font size (section title, tabs).")); + +export const fontSizeLabel2 = registerSize('fontSize.label2', + sizeForAllThemes(11, 'px'), + nls.localize('fontSizeLabel2', "Label 2 font size (metadata).")); + +export const fontSizeLabel3 = registerSize('fontSize.label3', + sizeForAllThemes(10, 'px'), + nls.localize('fontSizeLabel3', "Label 3 font size (badge).")); + +// ------ Font weights +// +// A two-weight ramp (regular/semiBold). "Strong" emphasis reuses the matching +// font-size token paired with `fontWeight.semiBold`. + +export const fontWeightRegular = registerSize('fontWeight.regular', + sizeForAllThemes(400, ''), + nls.localize('fontWeightRegular', "Regular font weight (400) for body, labels and metadata.")); + +export const fontWeightSemiBold = registerSize('fontWeight.semiBold', + sizeForAllThemes(600, ''), + nls.localize('fontWeightSemiBold', "SemiBold font weight (600) for headings and strong emphasis.")); export const codiconFontSize = registerSize('codiconFontSize', sizeForAllThemes(16, 'px'), diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index 607207dc44811b..f00f77df9862c2 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -21,7 +21,7 @@ configurationRegistry.registerConfiguration({ enum: ['none', 'manual', 'start', 'default'], default: 'default', scope: ConfigurationScope.APPLICATION, - description: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), + description: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), tags: ['usesOnlineServices'], enumDescriptions: [ localize('none', "Disable updates."), @@ -34,7 +34,7 @@ configurationRegistry.registerConfiguration({ category: PolicyCategory.Update, minimumVersion: '1.67', localization: { - description: { key: 'updateMode', value: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), }, + description: { key: 'updateMode', value: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), }, enumDescriptions: [ { key: 'none', @@ -60,7 +60,7 @@ configurationRegistry.registerConfiguration({ type: 'string', default: 'default', scope: ConfigurationScope.APPLICATION, - description: localize('updateMode', "Configure whether you receive automatic updates. Requires a restart after change. The updates are fetched from a Microsoft online service."), + description: localize('updateMode', "Configure whether you receive automatic updates. The updates are fetched from a Microsoft online service."), deprecationMessage: localize('deprecated', "This setting is deprecated, please use '{0}' instead.", 'update.mode') }, 'update.enableWindowsBackgroundUpdates': { diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index 92ae7b96f06ea3..1afbb0d3985c21 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -34,6 +34,7 @@ export interface IUpdate { * Ready: Code will be updated as soon as it restarts (win32, darwin). * Downloaded: There is an update ready to be installed in the background (win32). * Overwriting: A newer update is being downloaded to replace the pending update (darwin). + * Cancelling: Updates are being disabled at runtime; in-flight/pending work is being torn down before Disabled. */ export const enum StateType { @@ -47,6 +48,7 @@ export const enum StateType { Updating = 'updating', Ready = 'ready', Overwriting = 'overwriting', + Cancelling = 'cancelling', Restarting = 'restarting', } @@ -76,9 +78,10 @@ export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit export type Updating = { type: StateType.Updating; update: IUpdate; currentProgress?: number; maxProgress?: number; explicit: boolean }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; +export type Cancelling = { type: StateType.Cancelling }; export type Restarting = { type: StateType.Restarting; update: IUpdate }; -export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting | Restarting; +export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting | Cancelling | Restarting; export const State = { Uninitialized: upcast<Uninitialized>({ type: StateType.Uninitialized }), @@ -91,6 +94,7 @@ export const State = { Updating: (update: IUpdate, explicit: boolean, currentProgress?: number, maxProgress?: number): Updating => ({ type: StateType.Updating, update, explicit, currentProgress, maxProgress }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), + Cancelling: upcast<Cancelling>({ type: StateType.Cancelling }), Restarting: (update: IUpdate): Restarting => ({ type: StateType.Restarting, update }), }; diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 8cf5dceb0635d8..09971caf6b3e33 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -4,9 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import * as os from 'os'; -import { IntervalTimer, timeout } from '../../../base/common/async.js'; +import { CancelablePromise, IntervalTimer, Throttler, timeout } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { isCancellationError } from '../../../base/common/errors.js'; import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { isMacintosh, isWindows } from '../../../base/common/platform.js'; import { getWindowsReleaseSync } from '../../../base/node/windowsVersion.js'; import { IMeteredConnectionService } from '../../meteredConnection/common/meteredConnection.js'; @@ -75,7 +77,26 @@ export type UpdateErrorClassification = { comment: 'This is used to know how often VS Code updates have failed.'; }; -export abstract class AbstractUpdateService implements IUpdateService { +/** + * States representing in-flight or pending update work that takes time to tear down when updates + * are disabled at runtime. Used to decide whether to surface a transient `Cancelling` state. + */ +function isCancellableState(type: StateType): boolean { + switch (type) { + case StateType.CheckingForUpdates: + case StateType.AvailableForDownload: + case StateType.Downloading: + case StateType.Downloaded: + case StateType.Updating: + case StateType.Ready: + case StateType.Overwriting: + return true; + default: + return false; + } +} + +export abstract class AbstractUpdateService extends Disposable implements IUpdateService { declare readonly _serviceBrand: undefined; @@ -84,10 +105,19 @@ export abstract class AbstractUpdateService implements IUpdateService { private _state: State = State.Uninitialized; protected _overwrite: boolean = false; private _hasCheckedForOverwriteOnQuit: boolean = false; - private readonly overwriteUpdatesCheckInterval = new IntervalTimer(); + private readonly overwriteUpdatesCheckInterval = this._register(new IntervalTimer()); private _internalOrg: string | undefined = undefined; - private readonly _onStateChange = new Emitter<State>(); + /** Disabled for a non-reversible reason (e.g. not built, missing config); ignores `update.mode` changes. */ + private _disabledPermanently: boolean = false; + /** Whether one-time platform init (e.g. background update GC, pending update resume) has run. */ + private _postInitialized: boolean = false; + /** Cancels the pending scheduled update check, if any. */ + private readonly scheduler = this._register(new MutableDisposable<IDisposable>()); + /** Serializes reconfiguration so overlapping `update.mode` changes settle on the latest value. */ + private readonly reconfigureThrottler = this._register(new Throttler()); + + private readonly _onStateChange = this._register(new Emitter<State>()); readonly onStateChange: Event<State> = this._onStateChange.event; get state(): State { @@ -131,6 +161,8 @@ export abstract class AbstractUpdateService implements IUpdateService { @IMeteredConnectionService protected readonly meteredConnectionService: IMeteredConnectionService, protected readonly supportsUpdateOverwrite: boolean, ) { + super(); + lifecycleMainService.when(LifecycleMainPhase.AfterWindowOpen) .finally(() => this.initialize()); } @@ -142,51 +174,125 @@ export abstract class AbstractUpdateService implements IUpdateService { */ protected async initialize(): Promise<void> { if (!this.environmentMainService.isBuilt) { - this.setState(State.Disabled(DisablementReason.NotBuilt)); + this.setDisabledPermanently(DisablementReason.NotBuilt); return; // updates are never enabled when running out of sources } await this.trackVersionChange(); if (this.environmentMainService.disableUpdates) { - this.setState(State.Disabled(DisablementReason.DisabledByEnvironment)); + this.setDisabledPermanently(DisablementReason.DisabledByEnvironment); this.logService.info('update#ctor - updates are disabled by the environment'); return; } if (!this.productService.updateUrl || !this.productService.commit) { - this.setState(State.Disabled(DisablementReason.MissingConfiguration)); + this.setDisabledPermanently(DisablementReason.MissingConfiguration); this.logService.info('update#ctor - updates are disabled as there is no update URL'); return; } + // React to runtime `update.mode`/policy changes so switching to/from `none` applies without a restart. + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.mode')) { + this.reconfigure().catch(err => this.logService.error('update#reconfigure - failed to apply update mode change', err)); + } + })); + + // Apply the currently configured update mode. + await this.reconfigure(); + } + + /** + * Evaluates the current `update.mode` setting (and its policy) and brings the service into the matching state. + * Runs on startup and on every change, enabling or disabling updates without a restart. + */ + private reconfigure(): Promise<void> { + return this.reconfigureThrottler.queue(() => this.doReconfigure()); + } + + private async doReconfigure(): Promise<void> { + if (this._disabledPermanently) { + return; + } + const updateMode = this.configurationService.getValue<'none' | 'manual' | 'start' | 'default'>('update.mode'); const updateModeInspection = this.configurationService.inspect<'none' | 'manual' | 'start' | 'default'>('update.mode'); const policyDisablesUpdates = updateModeInspection.policyValue !== undefined && !this.getProductQuality(updateModeInspection.policyValue); const quality = this.getProductQuality(updateMode); if (!quality) { - if (policyDisablesUpdates) { - this.setState(State.Disabled(DisablementReason.Policy)); - this.logService.info('update#ctor - updates are disabled by policy'); - } else { - this.setState(State.Disabled(DisablementReason.ManuallyDisabled)); - this.logService.info('update#ctor - updates are disabled by user preference'); + const reason = policyDisablesUpdates ? DisablementReason.Policy : DisablementReason.ManuallyDisabled; + + // Skip if already disabled for this reason, so a repeated write or policy refresh is a no-op. + if (this._state.type === StateType.Disabled && this._state.reason === reason) { + return; } + + await this.disable(reason); return; } if (!this.buildUpdateFeedUrl(quality, this.productService.commit!)) { - this.setState(State.Disabled(DisablementReason.InvalidConfiguration)); + this.setDisabledPermanently(DisablementReason.InvalidConfiguration); this.logService.info('update#ctor - updates are disabled as the update URL is badly formed'); return; } this.quality = quality; - this.setState(State.Idle(this.getUpdateType())); + // Move to Idle so one-time platform init (which may resume a pending update) can act; it requires Idle. + if (this._state.type === StateType.Disabled || this._state.type === StateType.Uninitialized) { + this.setState(State.Idle(this.getUpdateType())); + } + + // One-time platform init, gated behind updates being enabled so a pending update is never resumed under `none`. + if (!this._postInitialized) { + this._postInitialized = true; + await this.postInitialize(); + } - await this.postInitialize(); + this.scheduleAccordingToMode(updateMode); + } + + /** + * Disables updates for a reversible reason (user preference or policy), cancelling the scheduled check loop + * and any in-flight or pending update before moving to Disabled. + */ + private async disable(reason: DisablementReason): Promise<void> { + this.scheduler.clear(); + + // Show a transient Cancelling state only when there is in-flight or pending work to tear down. + if (isCancellableState(this._state.type)) { + this.setState(State.Cancelling); + } + + try { + await this.cancelUpdate(); + } catch (err) { + this.logService.warn('update#disable - failed to cancel pending update', err); + } + + this.quality = undefined; + + if (reason === DisablementReason.Policy) { + this.logService.info('update#disable - updates are disabled by policy'); + } else { + this.logService.info('update#disable - updates are disabled by user preference'); + } + + this.setState(State.Disabled(reason)); + } + + /** Disables updates for a non-reversible reason; subsequent `update.mode` changes are ignored. */ + private setDisabledPermanently(reason: DisablementReason): void { + this._disabledPermanently = true; + this.scheduler.clear(); + this.setState(State.Disabled(reason)); + } + + private scheduleAccordingToMode(updateMode: 'none' | 'manual' | 'start' | 'default'): void { + this.scheduler.clear(); if (updateMode === 'manual') { this.logService.info('update#ctor - manual checks only; automatic updates are disabled by user preference'); @@ -197,10 +303,10 @@ export abstract class AbstractUpdateService implements IUpdateService { this.logService.info('update#ctor - startup checks only; automatic updates are disabled by user preference'); // Check for updates only once after 30 seconds - setTimeout(() => this.checkForUpdates(false), 30 * 1000); + this.scheduleCheckForUpdates(30 * 1000, false); } else { // Start checking for updates after 30 seconds - this.scheduleCheckForUpdates(30 * 1000).then(undefined, err => this.logService.error(err)); + this.scheduleCheckForUpdates(30 * 1000, true); } } @@ -276,12 +382,22 @@ export abstract class AbstractUpdateService implements IUpdateService { return updateMode === 'none' ? undefined : this.productService.quality; } - private scheduleCheckForUpdates(delay = 60 * 60 * 1000): Promise<void> { - return timeout(delay) + private scheduleCheckForUpdates(delay = 60 * 60 * 1000, repeat = true): void { + const promise: CancelablePromise<void> = timeout(delay); + this.scheduler.value = toDisposable(() => promise.cancel()); + + promise .then(() => this.checkForUpdates(false)) .then(() => { - // Check again after 1 hour - return this.scheduleCheckForUpdates(60 * 60 * 1000); + if (repeat) { + // Check again after 1 hour + this.scheduleCheckForUpdates(60 * 60 * 1000, true); + } + }) + .catch(err => { + if (!isCancellationError(err)) { + this.logService.error(err); + } }); } @@ -434,8 +550,7 @@ export abstract class AbstractUpdateService implements IUpdateService { const context = await this.requestService.request({ url, headers, callSite: 'updateService.isLatestVersion' }, token); const statusCode = context.res.statusCode; this.logService.trace('update#isLatestVersion() - response', { statusCode }); - // The update server replies with 204 (No Content) when no - // update is available - that's all we want to know. + // The update server replies with 204 (No Content) when no update is available. return statusCode === 204; } catch (error) { @@ -478,6 +593,14 @@ export abstract class AbstractUpdateService implements IUpdateService { // noop } + /** + * Aborts in-flight or pending update work when updates are being disabled at runtime. The default cancels a + * pending update; platform services override this to also abort in-flight checks/downloads. + */ + protected async cancelUpdate(): Promise<void> { + await this.cancelPendingUpdate(); + } + protected abstract buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined; protected abstract doCheckForUpdates(explicit: boolean, pendingCommit?: string): void; } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index e92c54e2fe8c2b..90634472658e35 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -8,7 +8,6 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { Event } from '../../../base/common/event.js'; import { hash } from '../../../base/common/hash.js'; -import { DisposableStore } from '../../../base/common/lifecycle.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { ILifecycleMainService, IRelaunchHandler, IRelaunchOptions } from '../../lifecycle/electron-main/lifecycleMainService.js'; @@ -23,8 +22,6 @@ import { AbstractUpdateService, createUpdateURL, getUpdateRequestHeaders, IUpdat export class DarwinUpdateService extends AbstractUpdateService implements IRelaunchHandler { - private readonly disposables = new DisposableStore(); - @memoize private get onRawError(): Event<string> { return Event.fromNodeEventEmitter(electron.autoUpdater, 'error', (_, message) => message); } @memoize private get onRawCheckingForUpdate(): Event<void> { return Event.fromNodeEventEmitter<void>(electron.autoUpdater, 'checking-for-update'); } @memoize private get onRawUpdateNotAvailable(): Event<void> { return Event.fromNodeEventEmitter<void>(electron.autoUpdater, 'update-not-available'); } @@ -71,11 +68,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau protected override async initialize(): Promise<void> { await super.initialize(); - this.onRawError(this.onError, this, this.disposables); - this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this.disposables); - this.onRawUpdateAvailable(this.onUpdateAvailable, this, this.disposables); - this.onRawUpdateDownloaded(this.onUpdateDownloaded, this, this.disposables); - this.onRawUpdateNotAvailable(this.onUpdateNotAvailable, this, this.disposables); + this.onRawError(this.onError, this, this._store); + this.onRawCheckingForUpdate(this.onCheckingForUpdate, this, this._store); + this.onRawUpdateAvailable(this.onUpdateAvailable, this, this._store); + this.onRawUpdateDownloaded(this.onUpdateDownloaded, this, this._store); + this.onRawUpdateNotAvailable(this.onUpdateNotAvailable, this, this._store); } private onCheckingForUpdate(): void { @@ -86,6 +83,11 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); this.logService.error('UpdateService error:', err); + // Only react while actively checking/downloading; a late error must not clobber Disabled or Ready. + if (this.state.type !== StateType.CheckingForUpdates && this.state.type !== StateType.Downloading && this.state.type !== StateType.Overwriting) { + return; + } + // only show message when explicitly checking for updates const message = (this.state.type === StateType.CheckingForUpdates && this.state.explicit) ? err : undefined; this.setState(State.Idle(UpdateType.Archive, message)); @@ -205,8 +207,4 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau this.logService.trace('update#quitAndInstall(): running raw#quitAndInstall()'); electron.autoUpdater.quitAndInstall(); } - - dispose(): void { - this.disposables.dispose(); - } } diff --git a/src/vs/platform/update/electron-main/updateService.linux.ts b/src/vs/platform/update/electron-main/updateService.linux.ts index 2be53f613228d8..dbb08664cc5be6 100644 --- a/src/vs/platform/update/electron-main/updateService.linux.ts +++ b/src/vs/platform/update/electron-main/updateService.linux.ts @@ -14,7 +14,7 @@ import { IProductService } from '../../product/common/productService.js'; import { asJson, IRequestService } from '../../request/common/request.js'; import { IApplicationStorageMainService } from '../../storage/electron-main/storageMainService.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; -import { AvailableForDownload, IUpdate, State, UpdateType } from '../common/update.js'; +import { AvailableForDownload, IUpdate, State, StateType, UpdateType } from '../common/update.js'; import { AbstractUpdateService, createUpdateURL, IUpdateURLOptions } from './abstractUpdateService.js'; export class LinuxUpdateService extends AbstractUpdateService { @@ -51,6 +51,11 @@ export class LinuxUpdateService extends AbstractUpdateService { this.requestService.request({ url, callSite: 'updateService.linux.checkForUpdates' }, CancellationToken.None) .then<IUpdate | null>(asJson) .then(update => { + // If updates were disabled mid-check, ignore the result so we don't leave the Disabled state. + if (this.state.type !== StateType.CheckingForUpdates) { + return; + } + if (!update || !update.url || !update.version || !update.productVersion) { this.setState(State.Idle(UpdateType.Archive, undefined, explicit || undefined)); } else { @@ -58,6 +63,10 @@ export class LinuxUpdateService extends AbstractUpdateService { } }) .then(undefined, err => { + if (this.state.type !== StateType.CheckingForUpdates) { + return; + } + this.logService.error(err); // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 1911ac0a6bcd1f..b8c2c8722e1f4e 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -10,8 +10,9 @@ import { mkdir, readFile, unlink } from 'fs/promises'; import { release, tmpdir } from 'os'; import { Delayer, ProcessTimeRunOnceScheduler, timeout } from '../../../base/common/async.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; +import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; +import { isCancellationError } from '../../../base/common/errors.js'; import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; import { basename } from '../../../base/common/path.js'; @@ -59,6 +60,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun private availableUpdate: IAvailableUpdate | undefined; private updateCancellationTokenSource: CancellationTokenSource | undefined; + /** Cancels an in-flight check/download chain (e.g. when updates are disabled at runtime). */ + private checkCancellationTokenSource: CancellationTokenSource | undefined; + /** Settles when the in-flight check/download chain has fully unwound; used by the cancel path. */ + private checkPromise: Promise<unknown> | undefined; private readonly readyMutexName: string; private readonly updatingMutexName: string; @@ -168,25 +173,41 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // updatingVersionPath will be deleted by inno setup. } } else { - const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - // GC for background updates in system setup happens via inno_setup since it requires - // elevated permissions. - if (fastUpdatesEnabled && this.productService.target === 'user' && this.productService.commit) { - const versionedResourcesFolder = this.productService.commit.substring(0, 10); - const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); - const exeName = basename(exePath); - await new Promise<void>(resolve => { - const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName], { - stdio: ['ignore', 'ignore', 'ignore'], - windowsHide: true, - timeout: 2 * 60 * 1000 - }); - child.once('exit', () => resolve()); - }); - } + await this.collectGarbage(); } } + private async collectGarbage(): Promise<void> { + if (!this.productService.win32VersionedUpdate) { + return; + } + + const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); + // GC for background updates in system setup happens via inno_setup since it requires elevated permissions. + if (!fastUpdatesEnabled || this.productService.target !== 'user' || !this.productService.commit) { + return; + } + + const exePath = app.getPath('exe'); + const exeDir = path.dirname(exePath); + const versionedResourcesFolder = this.productService.commit.substring(0, 10); + const innoUpdater = path.join(exeDir, versionedResourcesFolder, 'tools', 'inno_updater.exe'); + const exeName = basename(exePath); + await new Promise<void>(resolve => { + const child = spawn(innoUpdater, ['--gc', exePath, versionedResourcesFolder, exeName], { + stdio: ['ignore', 'ignore', 'ignore'], + windowsHide: true, + timeout: 2 * 60 * 1000 + }); + // Resolve on 'error' too (missing inno_updater / permission denied) so the awaited promise always settles. + child.once('error', err => { + this.logService.error('update#collectGarbage - failed to spawn inno_updater', err); + resolve(); + }); + child.once('exit', () => resolve()); + }); + } + protected buildUpdateFeedUrl(quality: string, commit: string, options?: IUpdateURLOptions): string | undefined { let platform = `win32-${process.arch}`; @@ -213,12 +234,21 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.CheckingForUpdates(explicit)); } + // Track this check/download chain so it can be cancelled if updates are disabled at runtime. + this.checkCancellationTokenSource?.dispose(true); + const cts = this.checkCancellationTokenSource = new CancellationTokenSource(); + const token = cts.token; + const headers = getUpdateRequestHeaders(this.productService.version); - this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, CancellationToken.None) + const promise = this.requestService.request({ url, headers, callSite: 'updateService.win32.checkForUpdates' }, token) .then<IUpdate | null>(asJson) .then(update => { const updateType = getUpdateType(); + if (token.isCancellationRequested) { + return Promise.resolve(null); + } + if (!update || !update.url || !update.version || !update.productVersion) { // If we were checking for an overwrite update and found nothing newer, // restore the Ready state with the pending update @@ -256,7 +286,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; - return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, CancellationToken.None) + return this.requestService.request({ url: update.url, callSite: 'updateService.win32.downloadUpdate' }, token) .then(context => { // Get total size from Content-Length header const contentLengthHeader = context.res.headers['content-length']; @@ -288,6 +318,10 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => updatePackagePath); }); }).then(packagePath => { + if (token.isCancellationRequested) { + return; + } + this.availableUpdate = { packagePath }; this.saveUpdateMetadata(update); this.setState(State.Downloaded(update, explicit, this._overwrite)); @@ -302,6 +336,11 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); }) .then(undefined, err => { + // The chain was cancelled because updates are being disabled; leave state to the disable flow. + if (token.isCancellationRequested || isCancellationError(err)) { + return; + } + this.telemetryService.publicLog2<{ messageHash: string }, UpdateErrorClassification>('update:error', { messageHash: String(hash(String(err))) }); this.logService.error(err); @@ -317,6 +356,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun this.setState(State.Idle(getUpdateType(), message)); } }); + + this.checkPromise = promise; + + promise.finally(() => { + if (this.checkCancellationTokenSource === cts) { + this.checkCancellationTokenSource = undefined; + } + if (this.checkPromise === promise) { + this.checkPromise = undefined; + } + cts.dispose(); + }); } protected override async doDownloadUpdate(state: AvailableForDownload): Promise<void> { @@ -462,6 +513,42 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); } + protected override async cancelUpdate(): Promise<void> { + // Abort an in-flight check/download so it never reaches the background installer. + const hadInFlightCheck = !!this.checkCancellationTokenSource; + const hadPendingUpdate = !!this.availableUpdate; + this.checkCancellationTokenSource?.dispose(true); + this.checkCancellationTokenSource = undefined; + + // Only clean up if a check/download was in flight; avoids creating the cache dir when just disabled. + if (hadInFlightCheck) { + try { + await this.checkPromise; + } catch { + // the chain swallows its own errors; ignore + } + await this.cleanupTempFiles(); + } + + // Tear down any pending (downloaded/applying) update. + await this.cancelPendingUpdate(); + + // Reclaim a partial versioned-resource folder a cancelled update may leave; only after real teardown. + if (hadInFlightCheck || hadPendingUpdate) { + this.collectGarbage().catch(err => this.logService.error('update#collectGarbage - failed to collect garbage', err)); + } + } + + private async cleanupTempFiles(): Promise<void> { + try { + const cachePath = await this.cachePath; + const files = await pfs.Promises.readdir(cachePath); + await Promise.all(files.filter(file => file.endsWith('.tmp')).map(file => this.unlink(path.join(cachePath, file)))); + } catch (err) { + this.logService.warn('update#cleanupTempFiles: failed to remove temporary download files', err); + } + } + protected override async cancelPendingUpdate(): Promise<void> { if (!this.availableUpdate) { return; diff --git a/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts new file mode 100644 index 00000000000000..2766d433ed5379 --- /dev/null +++ b/src/vs/platform/update/test/electron-main/abstractUpdateService.test.ts @@ -0,0 +1,293 @@ +/*--------------------------------------------------------------------------------------------- + * 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 * as sinon from 'sinon'; +import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IConfigurationChangeEvent, IConfigurationOverrides, IConfigurationValue } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IEnvironmentMainService } from '../../../environment/electron-main/environmentMainService.js'; +import { ILifecycleMainService } from '../../../lifecycle/electron-main/lifecycleMainService.js'; +import { NullLogService } from '../../../log/common/log.js'; +import { IMeteredConnectionService } from '../../../meteredConnection/common/meteredConnection.js'; +import { IProductService } from '../../../product/common/productService.js'; +import { IRequestService } from '../../../request/common/request.js'; +import { IApplicationStorageMainService } from '../../../storage/electron-main/storageMainService.js'; +import { NullTelemetryService } from '../../../telemetry/common/telemetryUtils.js'; +import { DisablementReason, State, StateType } from '../../common/update.js'; +import { AbstractUpdateService, IUpdateURLOptions } from '../../electron-main/abstractUpdateService.js'; + +class TestUpdateService extends AbstractUpdateService { + + private readonly _initialized = new DeferredPromise<void>(); + get whenInitialized(): Promise<void> { return this._initialized.p; } + + private _checkCount = 0; + get checkCount(): number { return this._checkCount; } + + private _cancelCount = 0; + get cancelCount(): number { return this._cancelCount; } + + /** When set, `cancelUpdate` blocks on this promise so tests can observe the transient Cancelling state. */ + private _cancelGate: Promise<void> | undefined; + blockCancelUpdate(gate: Promise<void>): void { this._cancelGate = gate; } + + /** Forces the service into a given state so tests can exercise cancellation from a cancellable state. */ + forceState(state: State): void { this.setState(state); } + + feedUrl: string | undefined = 'https://update.example/feed'; + + protected override async initialize(): Promise<void> { + try { + await super.initialize(); + } finally { + this._initialized.complete(); + } + } + + protected buildUpdateFeedUrl(_quality: string, _commit: string, _options?: IUpdateURLOptions): string | undefined { + return this.feedUrl; + } + + protected doCheckForUpdates(): void { + this._checkCount++; + } + + protected override async cancelUpdate(): Promise<void> { + this._cancelCount++; + if (this._cancelGate) { + await this._cancelGate; + } + await super.cancelUpdate(); + } +} + +suite('AbstractUpdateService', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + class PolicyTestConfigurationService extends TestConfigurationService { + policyValue: string | undefined; + + override getValue<T>(arg1?: string | IConfigurationOverrides, arg2?: IConfigurationOverrides): T | undefined { + // Mirror the real configuration service: a policy value overrides the user setting. + if (arg1 === 'update.mode' && this.policyValue !== undefined) { + return this.policyValue as T; + } + return super.getValue<T>(arg1, arg2); + } + + override inspect<T>(key: string, overrides?: IConfigurationOverrides): IConfigurationValue<T> { + const result = super.inspect<T>(key, overrides); + if (key === 'update.mode') { + return { ...result, policyValue: this.policyValue as T }; + } + return result; + } + } + + let configurationService: PolicyTestConfigurationService; + + function createService(mode: string, options?: { isBuilt?: boolean; disableUpdates?: boolean; updateUrl?: string }): TestUpdateService { + configurationService = new PolicyTestConfigurationService(); + configurationService.setUserConfiguration('update.mode', mode); + + const lifecycleMainService = { + when: () => Promise.resolve(), + setRelaunchHandler: () => { }, + quit: () => Promise.resolve(false), + onWillShutdown: Event.None + } as unknown as ILifecycleMainService; + + const environmentMainService = { + isBuilt: options?.isBuilt ?? true, + disableUpdates: options?.disableUpdates ?? false + } as unknown as IEnvironmentMainService; + + const requestService = { + request: () => Promise.reject(new Error('not expected')) + } as unknown as IRequestService; + + const productService = { + updateUrl: options?.updateUrl ?? 'https://update.example', + commit: 'abc123', + quality: 'stable', + version: '1.0.0', + target: 'user' + } as unknown as IProductService; + + const applicationStorageMainService = { + whenReady: Promise.resolve(), + get: () => undefined, + store: () => { } + } as unknown as IApplicationStorageMainService; + + const meteredConnectionService = { isConnectionMetered: false } as unknown as IMeteredConnectionService; + + const service = new TestUpdateService( + lifecycleMainService, + configurationService, + environmentMainService, + requestService, + store.add(new NullLogService()), + productService, + NullTelemetryService, + applicationStorageMainService, + meteredConnectionService, + false + ); + + return store.add(service); + } + + function changeMode(service: TestUpdateService, mode: string): Promise<unknown> { + configurationService.setUserConfiguration('update.mode', mode); + const next = Event.toPromise(service.onStateChange); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + return next; + } + + function setPolicy(service: TestUpdateService, policyValue: string | undefined): Promise<unknown> { + configurationService.policyValue = policyValue; + const next = Event.toPromise(service.onStateChange); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + return next; + } + + teardown(() => { + sinon.restore(); + }); + + test('mode none disables updates at startup', async () => { + const service = createService('none'); + await service.whenInitialized; + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + }); + + test('mode default enables updates at startup', async () => { + const service = createService('default'); + await service.whenInitialized; + + assert.strictEqual(service.state.type, StateType.Idle); + }); + + test('policy forces updates off even when the user setting keeps them enabled', async () => { + const service = createService('default'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Idle); + + // User setting stays 'default' (enabled); policy alone forces 'none'. + await setPolicy(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.Policy }); + }); + + test('switching to none at runtime cancels and disables', async () => { + const service = createService('default'); + await service.whenInitialized; + + await changeMode(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.strictEqual(service.cancelCount, 1); + }); + + test('switching from none to default at runtime re-enables', async () => { + const service = createService('none'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Disabled); + + await changeMode(service, 'default'); + + assert.strictEqual(service.state.type, StateType.Idle); + }); + + test('default schedules a background check, none does not', async () => { + const clock = sinon.useFakeTimers(); + try { + const service = createService('default'); + await service.whenInitialized; + await clock.tickAsync(30 * 1000); + assert.strictEqual(service.checkCount, 1, 'default should schedule a check'); + + await changeMode(service, 'none'); + await clock.tickAsync(60 * 60 * 1000); + assert.strictEqual(service.checkCount, 1, 'none should not schedule further checks'); + } finally { + clock.restore(); + } + }); + + test('permanent disablement ignores runtime mode changes', async () => { + const service = createService('default', { isBuilt: false }); + await service.whenInitialized; + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.NotBuilt }); + + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.NotBuilt }); + }); + + test('redundant update.mode write does not re-disable', async () => { + const service = createService('none'); + await service.whenInitialized; + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + + const cancelsAfterInit = service.cancelCount; + let stateChanges = 0; + store.add(service.onStateChange(() => stateChanges++)); + + // Re-write the same 'none' value: this affects `update.mode` but does not change the outcome. + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await timeout(0); + + assert.strictEqual(service.cancelCount, cancelsAfterInit, 'should not cancel again while already disabled'); + assert.strictEqual(stateChanges, 0, 'should not re-fire the Disabled state'); + }); + + test('surfaces Cancelling while tearing down in-flight work, then Disabled', async () => { + const service = createService('default'); + await service.whenInitialized; + + // Put the service into a cancellable state and make cancellation block until we release it. + service.forceState(State.CheckingForUpdates(false)); + const gate = new DeferredPromise<void>(); + service.blockCancelUpdate(gate.p); + + const states: StateType[] = []; + store.add(service.onStateChange(s => states.push(s.type))); + + configurationService.setUserConfiguration('update.mode', 'none'); + configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true } as unknown as IConfigurationChangeEvent); + await timeout(0); + + assert.strictEqual(service.state.type, StateType.Cancelling, 'should show Cancelling while cancellation is in progress'); + + gate.complete(); + await timeout(0); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.deepStrictEqual(states, [StateType.Cancelling, StateType.Disabled]); + }); + + test('does not enter Cancelling when nothing is in flight', async () => { + const service = createService('default'); + await service.whenInitialized; + assert.strictEqual(service.state.type, StateType.Idle); + + const states: StateType[] = []; + store.add(service.onStateChange(s => states.push(s.type))); + + await changeMode(service, 'none'); + + assert.deepStrictEqual(service.state, { type: StateType.Disabled, reason: DisablementReason.ManuallyDisabled }); + assert.deepStrictEqual(states, [StateType.Disabled], 'should go straight to Disabled without a Cancelling flash'); + }); +}); diff --git a/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts b/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts index 99238ddfbb51f2..d21f940b41f558 100644 --- a/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts +++ b/src/vs/platform/webWorker/browser/webWorkerServiceImpl.ts @@ -103,6 +103,7 @@ function getWorkerBootstrapUrl(label: string, workerScriptUrl: string, workerLoa `globalThis._VSCODE_NLS_MESSAGES = ${JSON.stringify(getNLSMessages())};`, `globalThis._VSCODE_NLS_LANGUAGE = ${JSON.stringify(getNLSLanguage())};`, `globalThis._VSCODE_FILE_ROOT = ${JSON.stringify(globalThis._VSCODE_FILE_ROOT)};`, + `globalThis._VSCODE_PRODUCT_JSON = ${JSON.stringify(globalThis._VSCODE_PRODUCT_JSON)};`, `const ttPolicy = globalThis.trustedTypes?.createPolicy('defaultWorkerFactory', { createScriptURL: value => value });`, `globalThis.workerttPolicy = ttPolicy;`, diff --git a/src/vs/platform/window/common/window.ts b/src/vs/platform/window/common/window.ts index 291648bca96e32..2187c2fb6c903f 100644 --- a/src/vs/platform/window/common/window.ts +++ b/src/vs/platform/window/common/window.ts @@ -69,6 +69,13 @@ export interface IOpenWindowOptions extends IBaseOpenWindowsOptions { readonly gotoLineMode?: boolean; readonly waitMarkerFileURI?: URI; + + /** + * When set, the opened window is asked to open the chat session identified + * by this resource once it is ready. Used to hand off a session (e.g. from + * the Agents window) so the new window restores both the folder and session. + */ + readonly chatSessionToOpen?: URI; } export interface IAddRemoveFoldersRequest { @@ -394,7 +401,7 @@ export interface INativeOpenFileRequest extends IOpenFileRequest { export interface INativeRunActionInWindowRequest { readonly id: string; - readonly from: 'menu' | 'touchbar' | 'mouse'; + readonly from: 'menu' | 'touchbar' | 'mouse' | 'systemWideKeybinding'; readonly args?: unknown[]; } diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 49647d396f543d..9b0537607d3c89 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -69,6 +69,8 @@ The management editor opens as a compact modal editor. The modal title and welco The first sidebar entry is a static `Overview` navigation item. It is styled like the other sidebar labels and does not mirror the active harness label; harness identity is represented by the modal title and welcome heading instead. +The Tools section can browse the Marketplace in the core workbench, where extension gallery browsing and installation are available. The Sessions window hides Tools Marketplace browsing and only shows the tool enablement list. + ### IAICustomizationWorkspaceService The `IAICustomizationWorkspaceService` interface controls per-window behavior: @@ -210,6 +212,10 @@ AHP Remote Server ──────────────────── - **`customizationHarnessService.ts`** (common layer) — Defines `ICustomizationItem`, `ICustomizationItemProvider`, `ICustomizationDisableProvider`, and `IHarnessDescriptor`. A harness descriptor optionally carries an `itemProvider`; when absent, the widget falls back to `PromptsServiceCustomizationItemProvider`. +### MCP server list active-session controls + +The MCP Servers tab merges local/workspace MCP configuration with MCP servers reported by the active agent-host session. When a listed server also exists in the active session, row status follows the session-backed server and lifecycle controls (start/stop) target the agent host. Other VS Code-owned actions such as enable/disable, configuration, and install/uninstall stay on the local MCP row; model-access and sampling-log actions are hidden for session-backed rows because those are not inline session controls. + ### Structured Detail Preview For markdown-backed customizations (`.agent.md`, `SKILL.md`, `.instructions.md`, `.prompt.md`), the management editor opens a **structured preview** by default instead of showing the raw file immediately. @@ -258,7 +264,7 @@ The MCP Servers section combines locally known MCP servers with MCP servers repo ### Sidebar Customizations Section -The Agents sidebar `AICustomizationShortcutsWidget` appears as a collapsible, vertically resizable section below the sessions list. Its resize sash is the horizontal separator above the section and uses the same `SplitView` styling as the Checks section in the changes view, with a 4px separator and sash inset on each side. The section's expanded minimum height is 129px, while its initial and maximum height are capped to the rendered content height so the pane does not open with empty space. When collapsed, the section shrinks to its header height and shows the total customization count to the left of the hover-revealed chevron. +The Agents sidebar `AICustomizationShortcutsWidget` appears as a collapsible, vertically resizable section below the sessions list. Its resize sash is the horizontal separator above the section and uses the same `SplitView` styling as the Checks section in the changes view, with a 4px separator and sash inset on each side. The section's expanded minimum height is 129px, while its initial and maximum height are capped to the rendered content height so the pane does not open with empty space. When collapsed, the section shrinks to its header height and shows the total customization count to the left of the hover-revealed chevron. The collapsed/expanded state is persisted per profile (`StorageScope.PROFILE`) and restored on reload. The first sidebar entry is `Overview`, which opens the AI Customization management editor welcome page. The remaining per-category rows deep-link directly to their corresponding management editor section. All entries keep the active customization harness in sync with the active session before opening the editor. diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index f0c7bf5783b7a9..d896673f0d137c 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -71,6 +71,8 @@ The workbench grid is built with `proportionalLayout: false` (see `createWorkben | Editor | `Normal` | Keeps its user-set width (`600` default); only resized via its own sash. | | Auxiliary Bar | `Low` | Keeps its user-set width (`340` default); only resized via its own sash. | +In the single-pane detail-panel layout, first-run sidebar width is slightly narrower (280px) so a typical window keeps roughly balanced chat and third-pane widths when the pane is shown. Persisted `_savedPartSizes` always win over these defaults. + **Invariant — exactly one `High` view in the horizontal chain.** A grid branch derives its priority from its children (`BranchNode.priority` in [base/browser/ui/grid/gridview.ts](src/vs/base/browser/ui/grid/gridview.ts)): `High` if any child is `High`, else `Low` if any child is `Low`, else `Normal`. The Top Right row contains a `Low` auxiliary bar, so unless the Sessions Part is `High` the whole Right Section derives to `Low`. The Content Section would then be `Sidebar (Low) | Right Section (Low)` — two equal-priority views — and with no high-priority absorber the resize delta spreads across **both**, growing the sidebar toward half the window. The Sessions Part being `High` is what lifts the Right Section to `High` so it (not the sidebar) absorbs the delta. > **Pitfall:** the `High` role must live on the Sessions Part, not the editor. It was previously on the editor, but that made the editor drift to its 300px minimum when the auxiliary bar was toggled across session switches. When moving the role, set the Sessions Part to `High` **and** the editor to `Normal` together — removing `High` from the editor without adding it to the Sessions Part leaves the chain with no `High` view and reintroduces the growing-sidebar bug. @@ -100,10 +102,22 @@ The center section shows a clickable session picker widget. When a session is ac When no session is active (new chat view) the widget hides its chrome so the center is empty. Clicking opens the session switcher quick pick. +When the primary side bar is hidden and at least one session is **blocked** the widget instead switches to a **requires-input** state (see [Blocked Sessions](#blocked-sessions-center) below). + +After the user approves a pending action on a session from the sessions list (e.g. the **Allow** button on an approval row), the widget briefly shows a green "Approved N sessions" confirmation. Each approval within the rolling 3s window increments the count and restarts the countdown; while visible it takes precedence over the requires-input state. Driven by `ISessionActionFeedbackService` (`contrib/sessions`), whose `approvedCount` observable the widget reads. + +In the single-pane layout, activating the session header **Changes** pill is treated as an explicit +editor open: it reveals the docked editor area and opens the Changes multi-diff editor even though +managed Changes tab activations remain excluded from automatic reveal. + ### Agent Host Filter (Left) When multiple remote agent hosts are known, a dropdown pill in the left toolbar scopes the workbench to a specific host. When no hosts are known the pill acts as a re-discover trigger. +### Blocked Sessions (Center) + +When at least one session is **blocked**, the center session picker widget (`SessionsTitleBarWidget`) switches from the active-session pill to an orange "N sessions require input" state (orange foreground, background and border), and blinks twice whenever a newly blocked session appears. A session counts as blocked when it needs input, or — while not in progress — has failing CI checks or unresolved pull request comments. Raw detection is owned by the `BlockedSessions` model (`contrib/blockedSessions`), which reuses the shared, background-polled GitHub CI / review-thread models via `computePullRequestIconStatus`. The widget refines this into what the title bar surfaces via the `BlockedSessionsIndicatorModel` (`blockedSessionsIndicatorModel.ts`) it instantiates: it drops sessions the user can already see, applies optimistic approval dismissals, classifies the homogeneous requires-input reason (for the specific message), builds the pill label, and decides when the attention blink plays. Blink detection keys off the underlying model's blocked-session ids (independent of visibility) so it fires only when a session *genuinely* becomes blocked — never merely because the user navigated to a different session. Clicking the widget opens those sessions rendered exactly like the sessions list but flat — no sections, groups or workspace headers — via the reusable `SessionsFlatList` (exported from `sessionsList.ts`) in a dropdown anchored below the command center box using `IContextViewService`; clicking a row opens the session like the main list. The flat list passes `toolbarActions: false` so its rows render no inline action toolbar (pin, mark as done, etc.), which don't apply to blocked sessions. When no session is blocked, the widget behaves as the normal active-session pill. Whether the widget enters this state is driven by the `BlockedSessionsIndicatorModel`'s `blockedSessions` observable. + ### Account Widget (Right) Shows the signed-in GitHub profile image (falls back to the account codicon). Clicking opens a combined account and Copilot status panel with sign-in/sign-out and settings actions. @@ -124,12 +138,14 @@ The Sessions Part (`SessionsPart` in [browser/parts/sessionsPart.ts](src/vs/sess A `SessionView` ([browser/parts/sessionView.ts](src/vs/sessions/browser/parts/sessionView.ts)) is a single leaf in the Sessions Part's internal grid. It hosts: -- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#<number>` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#<number>` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. -- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **open chats**: it is shown as soon as the session has more than one open chat (**including in-composer draft chats**) and hidden again when chats are removed back down to just the main chat. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single open chat); once the strip is shown (more than one open chat, including drafts) the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionHasMultipleOpenChatsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. +- A **session header** at the top ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts)) — the session status icon + title, a meta row (the contributed workspace folder / changes / pull request / terminals buttons), and the session toolbars (Run, Open in VS Code, New Chat). The status icon ([browser/sessionStatusIcon.ts](src/vs/sessions/browser/sessionStatusIcon.ts)) shows the live spinner/status glyph for in-progress / needs-input / error states; in terminal/default states the title shows the read/unread **dot indicator** (filled link-colored dot when unread, small muted dot when read) — neither the session type icon nor the PR icon is shown in the title, since the pull request is surfaced in the meta row instead. (The status icon's `completedStateIcon` argument is generic: the header passes nothing so it falls back to the dot indicator, while the sessions list still passes the PR icon.) The meta row hosts a generic `Menus.SessionHeaderMeta` toolbar that any feature can contribute actions into; by default each contributed action renders as a consistent compact secondary `Button` with an inline `icon title` label via `SessionHeaderMetaActionViewItem` ([browser/parts/sessionHeaderMetaActionViewItem.ts](src/vs/sessions/browser/parts/sessionHeaderMetaActionViewItem.ts)) unless it registers its own action view item (spacing between the pills comes from the meta row's `gap`, no separator dot). The files view contributes the workspace folder pill (order -10, so it leads the row, gated by the per-view `SessionHasWorkspaceContext` key which `SessionView` sets when the session has a workspace label, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the workspace icon — cloud / folder / worktree per workspace kind — plus the workspace label, and a hover showing the working-directory path and git branch, registered from `contrib/files/browser/workspaceFolderActions.ts`) that, when activated, opens the Files view. The changes view contributes the diff stats as a clickable menu item (order 0, gated by the per-view `SessionHasChangesContext` key, which `SessionView` sets from the session's **Branch Changes** changeset, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the diff-multiple icon, a `{n} files` label, and the live `+insertions -deletions` counts, registered via `IActionViewItemService` from `contrib/changes/browser/changesActions.ts`) that, when activated, opens the multi-file diff editor for the session. The pill always reflects the **Branch Changes** changeset (the branch-vs-base diff) — located in `IActiveSession.changesets` by the shared `BRANCH_CHANGES_CHANGESET_ID` (`services/sessions/common/session.ts`), falling back to `IActiveSession.changes` when absent — so it is independent of whichever changeset the Changes view currently has selected. The GitHub contribution similarly contributes a pull request button (order 1, so it follows the changes button) showing the PR icon + `#<number>` (gated by the per-view `SessionHasPullRequestContext` key, which `SessionView` sets from the session's GitHub info, with a custom action view item that extends `SessionHeaderMetaActionViewItem` to render the live `#<number>` as its label, registered from `contrib/github/browser/pullRequestActions.ts`) that, when activated, opens the pull request on GitHub; its leading icon reads `gitHubInfo.pullRequest.icon` and renders its themed color (set as an inline `color` with `!important` priority) so the glyph reflects the live PR state; its hover is owned by the GitHub contribution and shows the repository link/date, PR title, up to three lines of description, and target/source branch pills. The sessions terminal contribution similarly contributes a terminals button (order 2, so it follows the pull request button) showing the terminal icon + `{n} terminals` (gated by the per-view `SessionHasTerminalsContext` key, which `SessionView` sets from the terminal counts exposed by `ISessionTerminalsService` — backed by `SessionsTerminalContribution`). The label counts the session's terminals that have had at least one command sent in them (empty terminals that never ran a command are excluded), while the hover reports how many of those are currently running something (active) via `ITerminalInstance.hasChildProcesses` — e.g. a watch task or an in-progress `npm install`. The contribution records "has had a command" stickily per terminal (from executed text, command detection or a started child process) and recomputes the active subset live; the custom action view item extends `SessionHeaderMetaActionViewItem` to render the live count and the `{n} active terminals` hover, registered from `contrib/terminal/browser/terminalMetaActions.ts`) that, when activated, reveals the terminal view for the session. Visible once the bound session is created. It is also the drag handle for the session. Right-clicking the header opens `Menus.SessionHeaderContext`, which surfaces pin view / close (`1_view`), rename (`2_edit`), and mark read / unread (`3_read`). The built-in rename action is registered from `contrib/sessions/browser/sessionsActions.ts` and uses `ISessionsPartService` to find the matching `SessionView`, which delegates to the header's inline rename control. +- A **chat composite bar** below the header ([browser/parts/chatCompositeBar.ts](src/vs/sessions/browser/parts/chatCompositeBar.ts)) — the chat tab strip. Visibility tracks the number of **chats** in the session (**including in-composer draft chats**, and **counting closed chats**): it is shown as soon as the session has more than one chat and stays shown when chats are **closed** back down to a single open chat, hiding only when there is exactly one chat overall whose title matches the session title. It is **also** shown for a single remaining chat whose **title diverged** from the session title (so that chat's title stays visible somewhere). This rule is a single shared observable `IActiveSession.shouldShowChatTabs` ([services/sessions/browser/visibleSessions.ts](src/vs/sessions/services/sessions/browser/visibleSessions.ts)), read by both the composite bar and the `SessionShouldShowChatTabsContext` context key. The strip's own trailing **New Chat** action follows this visibility. The header's **New Chat** action is shown while the tab strip is hidden (a single chat with no diverged title); once the strip is shown the strip's trailing **New Chat** action offers it instead. The **New Chat** and **Conversations** controls are therefore split across the header and the tab strip on the same `SessionShouldShowChatTabsContext` boundary: the **Conversations** menu appears once the session has more than one **committed (non-draft)** chat — in the session header while the tab strip is hidden, and in the **chat tab bar action menu** at the end of the tab strip (`Menus.SessionChatTabBar`, rendered by the chat composite bar) once the strip is shown. While the tab strip is shown the chat tabs are keyboard-navigable from the active session: `Ctrl/Cmd+Shift+]` / `Ctrl/Cmd+Shift+[` go to the next / previous chat (wrapping), `Ctrl/Cmd+W` closes the active chat tab (deleting an in-composer draft, hiding a committed chat) instead of the session — the same command (`sessions.chatCompositeBar.closeChat`) is contributed to the per-tab `Menus.SessionChatTab`, which the chat tab strip renders as each non-main tab's close button (forwarding the tab's chat as the action argument), and `Ctrl+Tab` / `Ctrl+Shift+Tab` open a **chat switcher** — a no-input, editor-switcher (MRU) quick pick over the session's **open** chats (skipping in-composer drafts), each shown with a chat icon (hold the modifier, press `Tab` to cycle, release to select), winning over the session-history secondary on that chord while the session has multiple open chats and falling back to session navigation otherwise (and to the editor's own `Ctrl+Tab` switcher while a quick pick is already open, since the open chords are gated on `inQuickOpen` negated); the **Go to Chat in Session** palette command (`sessions.showChatsPicker`, `Ctrl/Cmd+Shift+O`, gated on more than one committed chat) opens a **searchable** variant that additionally lists **Closed** chats in a separate group (selecting one reopens it) — these commands (`sessions.chatCompositeBar.navigateNextChat` / `navigatePreviousChat` / `closeChat` and `sessions.showChatsPicker` in `contrib/sessions/browser/sessionsActions.ts`) outrank the session-level navigation/close chords via a higher keybinding weight. Chat-to-chat navigation (next/previous chat and the `Ctrl+Tab` switcher) is gated on `SessionHasMultipleOpenChatsContext` (more than one **open** tab) — distinct from the broader `SessionShouldShowChatTabsContext` that drives strip visibility — so it stays a no-op when only a single open chat remains (e.g. a diverged-title single chat, or one open + one closed chat); `closeChat` is gated on `SessionActiveChatIsClosableContext`, and the searchable palette command on `SessionHasMultipleCommittedChatsContext`. - A **chat view** below the bars, swapped in/out based on session state. - A floating toolbar overlay ([browser/parts/sessionHeader.ts](src/vs/sessions/browser/parts/sessionHeader.ts), `SessionViewFloatingToolbar`) shown for not-yet-created sessions in place of the header. -The header and the composite bar are deliberately separate widgets: the header represents the session identity/actions and is always present, while the tab strip is a per-chat navigation concern that appears (and then stays, per the sticky rule above) once a session has multiple chats or a diverged default-chat title. They share visual tokens via `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) and stylesheet ([browser/parts/media/chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). `SessionView` sums each widget's reported height to lay out the chat view below them. The header and tab strip are centered and capped to 990px via their own CSS classes (`.chat-composite-bar.session-header-bar` / `.chat-composite-bar.session-chat-tabs-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). The chat view itself is still laid out at full session width so its scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. +The header and the composite bar are deliberately separate widgets: the header represents the session identity/actions and is always present, while the tab strip is a per-chat navigation concern that appears (and then stays, per the sticky rule above) once a session has multiple chats or a diverged default-chat title. They share visual tokens via `applySessionBarThemeColors` ([browser/parts/sessionBarStyles.ts](src/vs/sessions/browser/parts/sessionBarStyles.ts)) and stylesheet ([browser/parts/media/chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). `SessionView` sums each widget's reported height to lay out the chat view below them. The header and tab strip are centered and capped to 990px via their own CSS classes (`.chat-composite-bar.session-header-bar` / `.chat-composite-bar.session-chat-tabs-bar` in [chatCompositeBar.css](src/vs/sessions/browser/parts/media/chatCompositeBar.css)). The chat view itself is still laid out at full session width so its scrollable viewport (and scrollbar) stays flush to the far-right edge; only the inner chat content (message/input cards, via `.interactive-item-container`, capped to 950px in [browser/media/style.css](src/vs/sessions/browser/media/style.css)) is width-constrained and centered via CSS. Each constrained message row is also the positioning context for request overlays such as steering-message actions, keeping those controls anchored to the message instead of the full-width scroll viewport. + +**Pitfall:** absolute request overlays must not remain positioned against the full-width `.interactive-session` after message rows are independently constrained. Make the constrained row their positioning context or hover actions drift into the viewport gutter. Request rows must also override the tree's `.monaco-tl-contents { overflow: hidden; }`, otherwise controls positioned above the request are clipped at the row boundary. **Pitfall:** don't cap the chat viewport width in `SessionView` layout when you need edge-aligned scrollbars. Keep the viewport full-width and center only the inner chat content so alignment and scroll ergonomics both hold. @@ -183,14 +199,37 @@ Editors open as modal overlays rather than occupying grid space. The configurati | Editor opens (no explicit group) | Opens in modal overlay | | All editors closed / Escape / backdrop click | Modal closes and is disposed | -When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Secondary Side Bar** action for the auxiliary bar, and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). +When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Details** action for the auxiliary bar (labelled "Toggle Secondary Side Bar" in the non-single-pane layout), and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). When the auxiliary bar is hidden the editor becomes the rightmost card and expands into the freed space; the workbench's 10px right gutter still applies, and a `.noauxiliarybar` rule in `browser/media/style.css` restores the editor's right border and right corner radii so it keeps its card appearance. -The Toggle Secondary Side Bar action collapses or restores the secondary side bar while the editor stays open. When a session's editor working set is restored on session switch, the editor part is revealed programmatically and the session's saved auxiliary bar visibility is honored (a side bar the user hid for a session stays hidden when returning to it). +The Toggle Details action (Toggle Secondary Side Bar in the non-single-pane layout) collapses or restores the secondary side bar while the editor stays open. In the single-pane layout it also has a default keybinding (**`⌥⌘L`**), and maximize/restore of the editor area has a default toggle keybinding (**`⌥⌘E`**, active only while the editor area is visible); both are scoped to the main sessions window with the single-pane setting enabled. When a session's editor working set is restored on session switch, the editor part is revealed programmatically and the session's saved auxiliary bar visibility is honored (a side bar the user hid for a session stays hidden when returning to it). The main editor part can be explicitly revealed for workflows that target it directly. +### Single-pane redesign (experimental — `sessions.layout.singlePaneDetailPanel`, default OFF) + +> See [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) for the full scenario/state/transition catalog and the manual validation checklist. + +The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **off** (default) the Agents window renders exactly as documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). When **on**, the third pane becomes a **single pane with one full-width tab bar**: + +- The auxiliary bar is removed from the workbench grid and **docked inside the editor part** (absolutely positioned on the right, below the editor tab strip); the grid's top-right row becomes `Sessions | Editor`, and the editor part spans the editor + detail-panel width. +- The editor group's **title/tab strip spans the full width** while its content is inset on the right by the detail-panel width, via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). +- A **full-width header** sits below the tab bar, spanning the editor content and the docked detail panel, and hosts contributed actions. **The header menus are a group-level configuration; opting in is per-editor.** An editor part configures its groups with optional menu ids via `IEditorGroupViewOptions.menuIds` (`{ headerPrimary, headerSecondary, editorActions, tabsBarContext }`) — the core `EditorGroupView` never references any concrete menu point, it just renders whatever menu ids it was constructed with. `EditorPart.getGroupViewOptions()` is a protected hook (default `undefined`) that supplies these options to every group the part creates; `SinglePaneMainEditorPart` overrides it to return `Menus.SessionsEditorHeaderPrimary` / `Menus.SessionsEditorHeaderSecondary` / `Menus.SessionsEditorTitle` / `Menus.SessionsEditorTabsBarContext` (all defined in the sessions layer's shared menu registry, `browser/menus.ts`, not in core `platform/actions`). A header only renders while the **active editor opts in** via `IEditorPane.getHeaderActions()`, which returns just `{ instantiationService }` (the editor-scoped instantiation service so the header actions' `when` clauses evaluate in the editor's context) or `undefined` for no header; `EditorGroupView._renderEditorHeader` (run on every active-editor change) renders the group's configured menus as leading/trailing `MenuWorkbenchToolBar`s (`.editor-group-header-primary` / `.editor-group-header-secondary`, wrap-reversed so trailing actions float up) using that scoped service, hiding the whole header while both menus are empty. The header is a **real flow row inside the editor group** — `EditorGroupView` renders an optional `.editor-group-header` between its `.title` (tabs) and `.editor-container`, and **owns the header rendering and sizing**: the internal `setHeaderContent(render)` creates the inner content element, runs the render callback, and keeps the row **auto-sized to the content** via a `ResizeObserver` (wrapping and growing as needed, firing `onDidChangeHeaderHeight`); `headerHeight` exposes the reserved height. The group lays it out in flow (no absolute positioning) and shifts the editor pane down by its height. `SinglePaneMainEditorPart` renders no header DOM; it only offsets the docked auxiliary bar + sash down by `group.headerHeight` (`IDockedAuxiliaryBarHost.getHeaderHeight()`, re-applied on `onDidChangeHeaderHeight` via `_registerGroupHeader()`). The **Changes editor** (`SessionChangesEditor`) implements `getHeaderActions()` in single-pane (returning its scoped instantiation service), so the group renders `Menus.SessionsEditorHeaderPrimary` (the *Branch Changes* dropdown + diff-stats) and `Menus.SessionsEditorHeaderSecondary` (an anchor action rendered as the primary/PR `ChangesActionsBar`) for the Changes tab only. The custom action view items (picker, single-pane diff-stats pill, actions bar) are registered globally by `(menuId, actionId)` via `IActionViewItemService` in `ChangesEditorHeaderContribution` (`contrib/changes/browser/changesView.ts`), so the group's generic menu toolbars resolve them. The same *Branch Changes* picker and diff-stats actions are also contributed to the classic aux-bar Changes view menus (`ChatEditingSessionChangesFileHeaderToolbar` / `…RightToolbar`), which that view renders with its own action view items — so the two surfaces stay independent. +- A vertical **sash** on the left edge of the docked panel resizes it (`DockedAuxiliaryBarController` in `browser/dockedAuxiliaryBarController.ts` owns `layout()` / `_ensureSash()`, created/driven by `SinglePaneMainEditorPart`). The preferred first-open width is 300px; explicit user resizes persist via the part-sizes snapshot. While the panel is visible it clamps to `[220px, editorWidth - 300px]`; dragging the raw sash width down to ~0 hides the docked detail panel, leaving the editor content visible. Temporary width growth from collapsing the sessions list is restored before persistence and must not become the user's detail width. +- Collapsing the sessions list transfers the freed sidebar width to the editor grid node when the editor content is **visible**, and to the **detail panel** (`_dockedAuxiliaryBarWidth`, with the editor node kept equal to it) when the editor content is **hidden** (detail-only). Reopening the sessions list restores the pre-collapse editor-node width / detail width. Keeping the hidden-editor node equal to the detail width ensures the width-based reveal-sync never mistakes a wide detail-only node for a revealed editor. +- When the editor part is hidden while the docked detail panel remains visible, the editor grid node stays visible for the shared tab strip but shrinks to the persisted detail-panel width, letting the Sessions part absorb the freed editor-content space. The detail panel fills that narrowed node below the tab strip, the editor content area collapses to zero, and the sash is disabled without overwriting the persisted detail-panel width. If the user drags the left workbench grid sash until a visible editor node is squeezed back to the detail width, the editor content is hidden the same way, leaving detail-only. +- If the user drags the workbench grid sash to widen that narrowed editor node, the width-based reveal-sync (`_syncEditorVisibility`) reveals the editor content again — but only once the node is widened past the detail width by a reveal margin (`nodeWidth >= detailWidth + _EDITOR_REVEAL_MARGIN`, i.e. 200px). This mirrors the squeeze-to-hide threshold. The detail keeps its width and the editor takes the remainder (the docked controller shrinks the detail toward its minimum if needed to keep the editor content); there is no even-split jump. The wide gap between the reveal margin and the hide threshold (`detailWidth + 4`) gives hysteresis so a small drag can't oscillate. The sync bails while `suppressEditorPartAutoVisibility` is held (session-switch/reload restore), so only a genuine user sash drag drives it. +- Revealing the side pane from *closed* (`setEditorHidden(false)`, e.g. the session-header Changes button opening the Changes editor) gives the editor a comfortable **even split** with the chat via `_applyEditorSplitSize(mainAreaWidthBeforeReveal)` (`max(300, mainArea/2)`). In docked mode this runs on **every** reveal that has no `_dockedEditorSizeBeforeHide` to restore — not just the first per window — because hiding collapses the editor node to the detail width and the grid caches it, so a later reveal (including in another session, where the per-window `_hasAppliedInitialEditorSplit` flag is already set) would otherwise restore the narrow cached width. A genuinely user-chosen width captured on hide (`_dockedEditorSizeBeforeHide`) takes precedence and is restored as-is. Non-docked layout keeps the original first-reveal-only gating via `_hasAppliedInitialEditorSplit`. +- `_dockedEditorSizeBeforeHide` is captured on hide **only for "Hide Editor"** (detail/auxiliary bar still visible, so the editor node stays visible at a real user-chosen width). When the **whole** side pane closes (auxiliary bar also hidden — e.g. **Toggle Side Panel** or the last-tab close, where `setEditorHidden(true)` runs with `partVisibility.auxiliaryBar === false`), the editor grid node collapses to `0px`, so capturing it would restore a bogus/cramped width; instead `_dockedEditorSizeBeforeHide` is cleared and the stale sidebar-collapse grow snapshots (`_editorSizeGrownForSidebarHide` / `_detailWidthGrownForSidebarHide`) are dropped, so reopening falls through to the even split. This matters when the **sessions list is collapsed**: the sessions part then spans nearly the full width, so half of it is a comfortable width — reopening the side pane is a generous even split rather than the cramped node a captured `0px` (or a stale pre-collapse snapshot) would restore. +- The shared editor title's inline layout cluster orders the Hide Editor chevron before maximize/restore, followed by the detail-panel toggle. No chevron is shown while the editor is hidden; opening a file or diff from the detail panel reveals the editor again. If the detail-panel toggle hides the detail while editor content is hidden, it reveals the editor content instead of leaving the pane empty; **Toggle Side Panel** remains the separate action that can hide both. +- Changes opens as a **custom `SessionChangesEditor`** (the multi-diff editor; in single-pane its *Branch Changes* dropdown + diff-stats + primary actions render in the full-width header part above, so the editor itself is header-less and the diff fills the pane), and clicking a Branch Changes file always reveals that file in this multi-diff editor (ignoring `sessions.changes.openSingleFileDiff` and the Alt inversion used by the standard layout). The auxiliary bar's composite tab strip + title are hidden, and `DetailPanelController` maps the active editor tab to the detail container (Changes → files + Checks, File → Explorer, Browser → hidden). Activating a File or Changes editor reveals the matching detail panel once; after that, an explicit detail-panel hide is respected until the active editor changes. Browser-driven hides are transient: switching back to File or Changes re-opens the detail panel. +- Closing the last editor tab hides both the editor content and the docked detail panel, leaving the Agents window chat-only. Opening any tab reveals the editor part again, and `DetailPanelController` restores the matching detail content for File/Changes tabs. +- **Editor-area tab collapse:** when the editor area is hidden (detail-only), the single-pane controller closes the non-managed (real file) editor tabs so only the managed Changes and Files tabs remain, capturing each one's untyped input **and tab index** (`editor.toUntyped()`); when the editor area is shown again they are reopened **at their original positions** (`SinglePaneLayoutController._registerEditorAreaTabCollapse` / `_collapseNonManagedTabs` / `_restoreCollapsedTabs`). It is serialized on the managed-tab `Sequencer`, skipped during a layout-driven restore (`_isRestoringSessionLayout`), skips dirty editors, and the capture is dropped on a session change. +- While a new (uncreated) workspace session view is active, the editor content is kept hidden **continuously** so the Files detail panel and editor tab bar remain visible without showing editor content by default. The rule is **level-triggered** on the active editor + editor-part visibility (in `singlePaneLayoutController.ts`): it hides the editor whenever the active editor is **not real content** (a `FileEditorInput` for a real file or a `BrowserEditorInput`), treating the managed empty landing tab (`EmptyFileEditorInput`) and "no active editor" as not real content. Because it re-reads visibility + active editor, any spurious reveal (a session-switch working-set restore, a layout race, the reveal-good-size even split) is **re-hidden** — so reopening a new session after visiting a created session keeps the editor closed. While there is no real content the width-based reveal-sync is also suppressed (`setSuppressDockedEditorRevealSync(true)`), so sidebar-collapse, grid relayout, and grid-sash drags never re-reveal the editor there. Once a real file/diff is the active editor the hide **short-circuits** (and the suppress flag clears), so a real open (via `onWillOpenEditor` → `setEditorHidden(false)`) or the detail-panel toggle reveals it and sticks. On submit, a Changes tab is added and the Changes detail is shown, but the editor content stays closed. The auto-managed Changes and File tabs never reveal the editor content. +- CSS is scoped by a `.dock-detail-panel` class on the workbench container; `:not(.dock-detail-panel)` reproduces the original grid-based styling. +- The docked auxiliary bar draws its own left and top borders with `--vscode-agentsPanel-border` so the detail panel reads as a bordered region connected to the middle divider. + --- ## 6. Feature Support @@ -262,6 +301,8 @@ Each session independently remembers whether the auxiliary bar is visible and wh **Default view on new sessions:** An untitled (new-session) session opens the side pane by default — the Files view, or the Changes view once it has changes — and that choice sticks until the user changes it. When a new session is submitted (it converts to a real session while staying active) the side pane is kept as the user left it: if it was open it stays open and switches to the Changes view so changes are visible as soon as they land; if it was closed it stays closed. +The Changes view's body is a vertical `SplitView` of File Changes, Other Files, and Checks. Other Files is the flexible middle pane: while it is expanded, File Changes is capped to its content height and Checks is capped to its checks content height, so Other Files receives the remaining space; when Other Files is hidden or collapsed, File Changes receives the remaining space after Checks reaches its content height. When File Changes has no changed files, it keeps a 140px minimum height for the empty state. + **Editor maximized:** While the editor area is maximized (`IAgentWorkbenchLayoutService.isEditorMaximized()`), the Changes view is always shown in the auxiliary bar, **irrespective of the session's previous or saved state**. This is driven directly from the auxiliary-bar sync autorun, so it holds across session changes and changes-state updates while maximized. The forced visibility is never captured as the session's per-session preference, so when the editor is un-maximized the autorun re-runs and restores the session's real auxiliary bar state. `setEditorMaximized` (in `browser/workbench.ts`) treats maximize as a fully reversible state: on entering it snapshots the editor part's size and the surrounding parts' visibility, and on exiting it restores the auxiliary bar to its pre-maximize visibility and resizes the editor part back to its captured width. Without this, the auxiliary bar that the controller forces visible while maximized would otherwise remain (and shrink the editor) after un-maximizing, so the editor would not return to its previous size. @@ -272,7 +313,7 @@ The panel (terminal / debug output) is hidden by default for all sessions. Each ### Editor Working Sets -When `workbench.editor.useModal` is not `'all'`, each session remembers which editors were open. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. +Each session remembers which editors were open, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid editor part even when other editors are forced modal (`useModal: 'all'`), so their tabs still need per-session tracking. On session switch the previous session's open editors are saved as a named working set and the incoming session's working set is restored. Archived or deleted sessions have their working sets removed. A session also remembers whether its editor part was hidden (e.g. the user closed the Side Panel while keeping editors open). Restoring such a session keeps the editor part hidden rather than forcing it back open with the working set. diff --git a/src/vs/sessions/LAYOUT_CONTROLLER.md b/src/vs/sessions/LAYOUT_CONTROLLER.md index dc015458955922..064e91dcd20e12 100644 --- a/src/vs/sessions/LAYOUT_CONTROLLER.md +++ b/src/vs/sessions/LAYOUT_CONTROLLER.md @@ -8,7 +8,7 @@ a separate "Implementation notes" section); the code and tests reference these r | File | Spec | Rules | |------|------|-------| | `contrib/layout/browser/baseSessionLayoutController.ts` (`BaseLayoutController`) | [baseSessionLayoutController.md](contrib/layout/browser/baseSessionLayoutController.md) | `B1`–`B5` | -| `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D7` | +| `contrib/layout/browser/desktopSessionLayoutController.ts` (`LayoutController`) | [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) | `D1`–`D11` | | `contrib/layout/browser/mobileSessionLayoutController.ts` (`MobileLayoutController`) | [mobileSessionLayoutController.md](contrib/layout/browser/mobileSessionLayoutController.md) | `M1`–`M2` | The abstract `BaseLayoutController` owns the platform-agnostic mechanics (panel, editor working sets, @@ -68,6 +68,22 @@ cleared — they survive multi-session mode. Skipped entirely on mobile web (`isWeb && isMobile`) to avoid disruptive auto-expand on narrow viewports. +> **Docked detail panel (experimental).** With `sessions.layout.singlePaneDetailPanel` enabled, the auxiliary +> bar is docked inside the editor part rather than being a grid column (see [LAYOUT.md](../LAYOUT.md) §5), and +> `DetailPanelController` drives which container it shows from the active editor tab. The controller here is +> unchanged and still toggles visibility via `IWorkbenchLayoutService.setPartHidden(AUXILIARYBAR_PART)`; the +> workbench fires `onDidChangePartVisibility` for the docked part so these capture/restore rules apply in both +> modes. When the setting is off, everything below applies unchanged. +> The docked detail panel opens at a 300px preferred width unless the user explicitly resized it; cached editor +> node sizes and temporary sidebar-collapse growth are not allowed to widen the first/opened detail-only pane. +> Docked sash collapse is also expressed through the same visibility API: the left grid sash hides editor content +> when the editor node reaches the detail width, and the middle docked sash hides the auxiliary bar when the raw +> dragged detail width reaches ~0. +> Single-pane also keeps new-session views Files-first: when an uncreated workspace session is entered, +> `SinglePaneLayoutController` hides the editor content once under editor-auto-visibility +> suppression so the editor tab bar and Files detail panel remain visible. Later user reveals are respected. +> The shared new-session hide memory (`sessions.newSessionViewState`) remains unchanged. + ### 3.1 Switching away — capture `_captureViewState(previousSession)` records, for the **outgoing** session: @@ -102,10 +118,13 @@ strict priority order: ### 3.3 New-session submit -When the active new session becomes created (`isCreated` changes from false to true for the same -session), the side pane stays in whatever visibility state the user left it. If it is visible, the -controller switches it to Changes immediately. If it is hidden, the controller records Changes as that -session's default active container so opening the side pane later shows Changes. +When the active new session becomes created (either `isCreated` changes from false to true for the +same session, or the provider replaces the draft with a new committed resource), the side pane stays +in whatever visibility state the user left it. If it is visible, the controller switches it to Changes +immediately. If it is hidden, the controller records Changes as that session's default active container +so opening the side pane later shows Changes. In single-pane mode, the submit transition keeps editor +content closed: the managed Changes tab opens under editor-auto-visibility suppression, while the +visible side pane maps to the Changes detail. ### 3.4 No auto-reveal on changes @@ -127,14 +146,55 @@ visible state is captured, so a hidden-on-submit session opens to Changes. The editor part is revealed programmatically when a session's editor working set is restored on a session **switch** (`_revealEditorPartForWorkingSet`, §5) — **unless** that session left the editor part -hidden. Each session's editor part hidden state is captured on switch-away (`_saveWorkingSet` records -`_editorPartHiddenBySession`); a session whose editor part was hidden (e.g. by closing the Side Panel, -which hides both the auxiliary bar and the editor part while keeping the editors open) keeps the editor -part hidden when restored. It is also **not** revealed on the initial restore after a reload (§5.2) — +hidden. Each session's editor part hidden state is captured **eagerly** by the `[B2]` +`onDidChangePartVisibility(EDITOR_PART)` listener the moment the user changes it — writing +`_editorPartHiddenBySession` while a single session is visible and outside a session-switch restore +(`_isRestoringSessionLayout`). Capturing lazily at switch-away instead would race the switch derive +(`activeSessionForWorkingSet` lags the raw active session), letting the incoming session's layout +overwrite the outgoing session's value. A session whose editor part was hidden (e.g. by closing the Side +Panel, which hides both the auxiliary bar and the editor part while keeping the editors open) keeps the +editor part hidden when restored — and in single-pane it is **actively re-hidden** on switch +(`_shouldHideEditorPartOnApply`) so returning from a session that had it open does not leave it visible. +It is also **not** revealed on the initial restore after a reload (§5.2) — the editor part visibility the workbench restored is preserved. The editor part visibility otherwise follows direct editor open/close events and the user's chevron toggle. Each session's saved aux-bar visibility wins on switch — a side bar the user hid for a session stays hidden when they return to it. +### 3.7 Empty auxiliary bar (D10) + +The auxiliary-bar **part** is kept hidden whenever it has **no active view container** — for example a +workspace-less quick chat, where the Changes and Files containers are gated off by their `when` clauses. +`_hasActiveAuxViewContainers()` (base) counts active aux-bar containers via +`IViewDescriptorService.getViewContainersByLocation(AuxiliaryBar)` + `IViewsService.isViewContainerActive` +(the same rule the workbench uses: `!hideIfEmpty || activeViewDescriptors.length > 0`). +`_registerAuxiliaryBarPartVisibility` (desktop) re-checks it reactively — on container add/remove, location +moves, each container model's `onDidChangeActiveViewDescriptors` (the gating signal), aux-bar +`onDidChangeViewContainerVisibility`, and the aux-bar **part itself becoming visible** +(`onDidChangePartVisibility`) — and `_syncAuxiliaryBarPartVisibility` hides the part (routing +through `_hideAuxiliaryBarForRestore` so §3.5 does not record it as a choice). The part-visibility trigger +closes a gap: the part can become visible without any container-/descriptor-change signal firing (a bare +detail toggle that shows the column before a container opens, or a restore that shows it while its +containers are gated off), which would otherwise leave the toggle/context key reading "on" over a blank +panel. The empty-part hide runs under `suppressEditorPartAutoVisibility()` so reconciling away an empty +column never, as a side effect, pops the editor open (editor visibility stays governed by §3.2 / D8). It +**only hides**; a container becoming active again lets the normal restore rules (§3.2 / D8) reveal the +part. Symmetrically, the docked host (`setAuxiliaryBarHidden`) never force-opens a `hideIfEmpty` container +with no active views when the aux bar is shown, so a show can never present a blank docked panel. Together +these guarantee the invariant: **in single-pane docked mode `partVisibility.auxiliaryBar` (⇒ +`AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true iff the docked detail panel is rendered with an +active view container.** In single-pane +detail-panel mode, a Browser tab can hide the part transiently, but switching back to Changes re-opens it, +and activating a File or Changes editor reveals the matching detail panel once while respecting later +explicit hides for the same active editor. When the main editor part has no tabs, the docked detail panel is +hidden with the editor so the whole side pane closes to chat-only; opening a tab restores it through the +normal editor-open and active-tab detail mapping. The detail-panel toggle reveals editor content when it hides the +detail from an editor-hidden state; the `toggleSidePane` re-open path +(§ base) guards the aux-bar un-hide with +`_hasActiveAuxViewContainers()` symmetric to `hasEditors`, and its "ensure a visible effect" fallback +prefers the editor and never reveals an empty aux bar. The `Toggle Side Panel` command is additionally +**disabled** for quick chats (`precondition: IsQuickChatSessionContext.negate()`), since a quick chat has +no side pane to toggle. + --- ## 4. Panel @@ -153,8 +213,11 @@ session (suppressed while multiple sessions are visible). ## 5. Editor Working Sets -Active only when `workbench.editor.useModal` is **not** `'all'` (editors live in the grid editor -part rather than as modal overlays). Driven by `_useModalConfigObs`. +Always active, regardless of `workbench.editor.useModal`: browser editors dock in the shared grid +editor part even when editors are otherwise forced modal (`useModal: 'all'`) — they except themselves +from the modal part — so their tabs still need per-session capture/restore. `_useModalConfigObs` is +consulted only inside `_applyWorkingSet`, to decide whether to auto-reveal the editor part on switch +(skipped in modal mode, since modal editors manage their own visibility). ### 5.1 Workspace-folder ordering @@ -169,15 +232,21 @@ Using `runOnChange(activeSessionForWorkingSet, ...)`: - **Outgoing session** (skip untitled): `_saveWorkingSet` snapshots the currently open editors as a named working set (`session-working-set:<resource>`); sessions with no visible editors store nothing. - It also records whether the editor part is currently hidden in `_editorPartHiddenBySession`, but only - while a single session is visible — in multi-session mode the editor area is shared, so its visibility - is not captured as a per-session choice. + The editor part hidden state is **not** captured here (it would race the switch derive) — it is + captured eagerly by the `[B2]` part-visibility listener (§3.6) the moment the user changes it, only + while a single session is visible (in multi-session mode the editor area is shared, so its visibility + is not a per-session choice). - **Incoming session**: `_applyWorkingSet` restores its saved working set (or `'empty'`). All applies are serialized through a `Sequencer`. When not in modal mode, the working set is non-empty, **and the session did not leave the editor part hidden**, the editor part is revealed before/after applying via `_revealEditorPartForWorkingSet`, which suppresses the editor→aux-bar invariant (§3.4) so the session's saved aux-bar visibility is honored. A session whose - `_editorPartHiddenBySession` entry is `true` keeps the editor part hidden on switch. + `_editorPartHiddenBySession` entry is `true` keeps the editor part hidden on switch — and via the + `_shouldHideEditorPartOnApply` hook (single-pane) is **actively re-hidden** (`_hideEditorPartForWorkingSet`) + if it was left visible by the previously-active session. When a provider replaces an active + uncreated draft with a committed session resource, the draft's editor-part hidden state is copied + to the committed resource before this apply runs, so single-pane detail-only submit does not fall + through to the first-visit created-session Editor-only default. On initial load (no previous session) the controller only applies a working set if one is already saved for the incoming session — it never applies `'empty'`, to avoid closing editors being restored. @@ -185,6 +254,13 @@ On this initial restore the working set is applied under `suppressEditorPartAuto editor part is **not** revealed, so whatever visibility the workbench restored (possibly hidden, because the user closed the Side Panel) is preserved across reloads. +In single-pane mode, layout-driven managed Changes/File tab opens remain excluded from automatic +editor reveal. The session header **Changes** pill is an explicit user open, so its action reveals the +editor part before opening the managed Changes editor; this keeps tab activation/layout restores +non-revealing while the pill reliably shows the multi-diff editor. The `+` Add Tab managed-tab actions +are also explicit tab-add gestures: they pass the active group's end index so a re-added managed +Changes/Files tab lands after the existing tabs rather than at the automatic Changes default position. + ### 5.3 Cleanup `onDidChangeSessions` removes working sets, per-session view state, **and** the editor part hidden @@ -231,3 +307,20 @@ does, causing the aux bar to fall back to the default-visible logic (§3.2) on t experimental setting `sessions.layout.autoCollapseSessionsSidebar` (default on in non-stable builds). See [desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md) D7. - Working-set save/apply waits for **workspace folders** to catch up with the active session. +- **An empty auxiliary bar is hidden (desktop, [D10])** — when the aux bar has no active view container + (e.g. a workspace-less quick chat where Changes/Files are gated off), the `AUXILIARYBAR_PART` is kept + hidden instead of showing an empty column, updating reactively as the active session flips — including + when the part itself becomes visible (a bare toggle / restore that shows the column before a container + opens), so the detail toggle never reads "on" over a blank panel. The empty-part hide runs under + `suppressEditorPartAutoVisibility()` so it never resurrects the editor as a side effect, and the docked + host never force-opens a `hideIfEmpty` container with no active views. The controller only hides an empty + aux bar (reveals stay with D3/D8), and **Toggle Side Panel** only reveals the part that has content — + never an empty aux bar, and is **disabled entirely for quick chats** + (`IsQuickChatSessionContext.negate()`). Invariant: `partVisibility.auxiliaryBar` + (⇒ `AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true iff the docked detail panel is rendered with + an active view container. +- **Single-pane new-session views are Files-first (desktop, [D11])** — when an uncreated workspace + session is entered in single-pane mode (single session visible, not maximized, not a quick chat), the + editor content is hidden once under `suppressEditorPartAutoVisibility()`. D3b keeps the Files detail + panel active unless the shared new-session side pane state says it is hidden; later user editor reveals + are respected until the controller exits and re-enters a new-session resource. diff --git a/src/vs/sessions/SESSIONS.md b/src/vs/sessions/SESSIONS.md index 64294922c647ed..dbe2e7609f9b57 100644 --- a/src/vs/sessions/SESSIONS.md +++ b/src/vs/sessions/SESSIONS.md @@ -55,12 +55,12 @@ The **view** counterpart, **`SessionsService`** (services, `services/sessions/br #### Model vs View (session services) -| `ISessionsManagementService` (model — `services/sessions`) | `ISessionsService` (view — `services/sessions/browser/`) | -|---|---| -| providers, getters, recently-opened, session types, `resolveWorkspace` | canonical `activeSession` (= active visible slot wrapper) + active-session context keys; `isNewChatSession` (new-draft ctx key) | -| `createNewSession` + new-session draft (`newSession` observable, `discardNewSession`) | `visibleSessions` (slots/arrangement) + active-slot wrappers | -| `sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest` (provider calls + send events) | `openSession`/`openChat`/`openNewSession`/`openNewChatInSession`; `insertAt`, `toggleSessionStickiness`, `closeSession`/`closeAllSessions`, `setActive` | -| CRUD: archive/delete/rename + events; recency history; provider subscriptions | focus mechanics (drives the part); `preserveFocus`; Back/Forward navigation (`SessionsNavigation`); `restoreVisibleSessions` + per-session view persistence; reflects send/replace **reactively** | +| `ISessionsManagementService` (model — `services/sessions`) | `ISessionsService` (view — `services/sessions/browser/`) | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| providers, getters, recently-opened, session types, `resolveWorkspace` | canonical `activeSession` (= active visible slot wrapper) + active-session context keys; `isNewChatSession` (new-draft ctx key) | +| `createNewSession` + new-session draft (`newSession` observable, `discardNewSession`) | `visibleSessions` (slots/arrangement) + active-slot wrappers | +| `sendNewChatRequest`/`createAndSendNewChatRequest`/`sendRequest` (provider calls + send events) | `openSession`/`openChat`/`openNewSession`/`openNewChatInSession`; `insertAt`, `toggleSessionStickiness`, `closeSession`/`closeAllSessions`, `setActive` | +| CRUD: archive/delete/rename + events; recency history; provider subscriptions | focus mechanics (drives the part); `preserveFocus`; Back/Forward navigation (`SessionsNavigation`); `restoreVisibleSessions` + per-session view persistence; reflects send/replace **reactively** | **Data-flow contract:** @@ -93,7 +93,7 @@ src/vs/sessions/contrib/providers/ Providers can import from all layers below them (core, services, non-provider contribs). **Non-provider contribs must NOT import from providers.** Shared symbols should be extracted to `services/` or `common/`. -The sessions-layer `AgentHostCustomizationService` adapts the workbench customization service contract to `IAgentHostSessionsProvider`. It reads session MCP servers through the owning provider and writes root MCP server definitions by merging the provider's current root `mcpServers` config map before calling `setRootConfigValue`, so additions preserve existing host-level servers. +The sessions-layer `AgentHostCustomizationService` adapts the workbench customization service contract to `IAgentHostSessionsProvider`. It reads session MCP servers through the owning provider, including optional start/stop lifecycle actions, and writes root MCP server definitions by merging the provider's current root `mcpServers` config map before calling `setRootConfigValue`, so additions preserve existing host-level servers. #### Provider internals stay in the provider (`IAgentSessionsService`) @@ -130,11 +130,39 @@ ISession ``` Session-level properties are derived from chats: + - Most properties (`title`, `changes`, `changesets`, `modelId`, etc.) come from the main chat - `updatedAt` and `lastTurnEnd` are the latest across all chats - `status` is aggregated (`NeedsInput` > `InProgress` > other) - `isRead` is `true` only when all chats are read +#### Read-only and hidden chats + +Each `IChat` exposes `interactivity: IObservable<ChatInteractivity>` — a provider-agnostic tri-state (`Full` / `ReadOnly` / `Hidden`) that mirrors the agent host protocol's `ChatInteractivity` but is decoupled from it so any provider can report it. Providers that don't distinguish interactivity report `Full`. + +- **`Full`** — the user can send messages (default). Composer shown. +- **`ReadOnly`** — the chat is shown but the composer is hidden: the agents-window chat view (`ChatView`) calls `ChatWidget.setReadOnly(true)`, which removes the composer (via an inline `display` style so it wins over the stylesheet without a specificity battle), focuses the message list, and sets the widget-scoped `chatIsReadonly` context key. That context key gates mutating per-request actions so read-only chats do not offer **Start Over**, **Restore Checkpoint**, **Restore to Last Checkpoint**, or **Undo Requests** (their menus and keybindings negate `ChatContextKeys.readOnly`). The tab shows a lock icon (`chatCompositeBar`). This supports the agent-team pattern where worker chats are observable but not directly steerable. +- **`Hidden`** — an internal worker chat that must not be surfaced in the UI at all. The visible session model (`VisibleSession`) filters `Hidden` chats out of `openChats` (the tab strip) and never selects one as the active chat (the close-chat and active-chat fallbacks skip them). `Hidden` is a *visibility* concern handled by the UI layer; providers still report it faithfully on `IChat`. + +`ChatView` treats any non-`Full` interactivity as read-only (`setReadOnly(interactivity !== Full)`); `Hidden` chats are filtered before they reach a `ChatView`. + +In the agent host, the real producer of read-only chats is **subagent (worker) chats**: when an agent's tool spawns a subagent, `AgentSideEffects._handleSubagentStarted` (`src/vs/platform/agentHost/node/agentSideEffects.ts`) calls `stateManager.addChat(...)` with `interactivity: ChatInteractivity.ReadOnly` and an `origin` of `{ kind: Tool, ... }`. The lead chat stays `Full` (the user steers the agent there) while the subagent chat is observable but read-only. The interactivity flows on the protocol `ChatSummary` into `applyChatCatalog` and through the provider-agnostic `IChat.interactivity` mapping above. + +**Surfacing subagent chats as tabs.** Subagent chats are surfaced as read-only peer tabs (in addition to the inline `ChatSubagentContentPart` rendering in the parent chat). Two pieces make this work: + +- `applyChatCatalog` (`baseAgentHostSessionsProvider.ts`) surfaces a non-default chat as a peer when the session supports multiple chats (`copilotcli`) **or** the chat is a subagent (`origin.kind === Tool`). So subagent chats appear as peers even in single-chat session types (e.g. `claude`), while ordinary user/fork peers still require multi-chat support. +- `chatCompositeBar` renders those tabs (it no longer filters out `origin.kind === Tool` chats) but keeps the trailing **New Chat** action gated to `capabilities.supportsMultipleChats`, so single-chat sessions that merely host a subagent don't expose chat creation. + +Subagent chats **persist** in the session catalog after the subagent completes (completion only marks the chat's turn complete; the chat is removed only when the whole session is disposed), so the read-only tab stays reviewable for the lifetime of the session. + +**Opening a subagent chat from the transcript.** The inline subagent block (`ChatSubagentContentPart`) renders a small pill (`OpenSubagentChatActionViewItem`) that reveals the subagent's read-only tab. The pill is inserted at the **start** of the subagent header row (before the streaming title) so it keeps a fixed position instead of shifting as the title grows; its label reactively shows the **subagent chat's own title** (resolved from the forwarded chat resource via `findSubagentChat`), so in the Agents window the duplicate inline header title is hidden (single chip — the pill is only contributed there, so the CSS is scoped to `.agent-sessions-workbench`), with the subagent's **agent name** (e.g. "General-purpose", "Task"; forwarded on the toolbar context, falling back to "Subagent") rendered as a prefix before the pill. The pill itself is a standalone chip styled like the chat file/diff pill (`chat-codeblock-pill-widget`) — a colorless, bordered chip rather than a filled button (`OpenSubagentChatActionViewItem` extends `BaseActionViewItem` and renders its own DOM, avoiding the meta-button's inline-style foreground that CSS can't override) — with a leading conversation icon by default, swapped for a **spinner** while the subagent is still running. The running state is driven by the pill's own `chat-subagent-running` class, which `OpenSubagentChatActionViewItem` toggles reactively from the resolved subagent chat's own `SessionStatus.InProgress` status (the same `findSubagentChat` autorun that resolves the title); the enclosing `.chat-subagent-part`'s `chat-thinking-active` class is kept only as a CSS fallback because the spawning tool call completes as soon as the subagent is dispatched, so it stops early while the worker keeps running (see `media/openSubagentChat.css`). The subagent chat resource is carried to the widget on `IChatSubagentToolInvocationData.chatResource` (populated in `stateToProgressAdapter` from `ToolResultSubagentContent.resource`). Because the chat widget is provider-agnostic and lower-layer, the link invokes the plain-string command `CHAT_OPEN_AGENT_HOST_CHAT_COMMAND_ID` (`workbench.action.chat.openAgentHostChat`) with the subagent chat URI; the sessions layer registers the handler (`openSubagentChat.ts`), which derives the chatId from the URI (handling the AHP, synthetic-fragment, and backend-path forms), finds the matching surfaced peer across the visible sessions, and calls `sessionsService.openChat` to activate the tab. The command no-ops when no handler is registered (e.g. the widget hosted outside the Agents window). + +**Restoring subagent chats.** Subagent chats are in-memory only; on restart the agent host restores them as separate sessions but no longer re-adds them to the parent catalog. `AgentService._registerRestoredSubagent` mirrors the live `_handleSubagentStarted` flow on restore — it re-adds the subagent to the parent session's catalog (same `ahp-chat://subagent/...` chat URI, `origin: Tool`, `interactivity: ReadOnly`, restored turns) so it reappears as a read-only tab. + +**Subagents in the Conversations menu.** Subagents spawned by the **currently-active** chat are shown as a separate group (`2_subagents`) at the bottom of the **Conversations** submenu, below the session's regular chats (`1_chats`); a separator divides the two groups. Per-chat association uses `IChatOrigin.parentChat` — the sessions-layer origin carries the spawning chat's resource (mapped from the protocol `ChatOrigin.chat` by the agent host provider's `_resolveParentChatResource`) — so the group changes as the active chat changes. Selecting a subagent entry toggles its read-only tab open/closed like any other chat entry. The entries are populated per session by `SessionConversationsMenuContribution` (only when the active chat has subagents). The chat tab strip is shown as soon as the session has any subagent (`IActiveSession.shouldShowChatTabs`), so the Conversations menu surfaces in the tab bar; `SessionActiveChatHasSubagentsContext` also keeps the menu available even when the parent is the only committed chat. + +Read-only is honored on both rendering paths: `SessionView` only routes an `Untitled` chat to the editable new-chat composer (`NewChatView`) when the chat is also `Full` — a non-interactive chat always uses the standard `ChatView` (whose `setReadOnly(true)` hides the input). Without this guard a freshly-added read-only peer chat (which is briefly `Untitled`) would surface the new-chat composer and remain editable. + The active session (`IActiveSession`) extends `ISession` with an `activeChat` observable that tracks which chat the user is viewing. Chat input history in the Agents Window is scoped by `ISession.sessionId`. Pressing Up/Down in a chat input only navigates prompts previously submitted in the same session, including across multiple chats in that session. Users can disable `chat.agentSessions.scopedInputHistory` to restore shared input history across sessions. When a provider replaces a temporary untitled session with a committed session after the first send, history is moved from the temporary session id to the committed session id. @@ -157,6 +185,23 @@ The session type picker persists the last selection as `{ providerId, sessionTyp On reload, providers register asynchronously and agent hosts connect lazily, so the preferred provider may not have surfaced its session types when the restored draft is created. Rather than blocking on a "ready" gate, `NewChatWidget` creates the draft immediately with the best available provider, then upgrades it in place once the preferred `(providerId, sessionTypeId)` pair becomes servable (driven by `onDidChangeSessionTypes`). The upgrade listener lives for the widget's lifetime — there is **no** timeout or `LifecyclePhase` give-up, since an agent host can connect arbitrarily late — and is cancelled if the user picks a different type or the draft is sent. +### Quick Chats + +A **quick chat** is a workspace-less session — one that is not scoped to any folder, so `ISession.workspace` resolves to `undefined`. Quick chats let the user start a conversation immediately, without first picking a repository or worktree. + +The contract is small and provider-agnostic: + +- **`ISessionsProvider.supportsQuickChats`** (optional `boolean`) — whether the provider can mint quick chats. Providers that truly change capabilities at runtime can signal that via the optional **`onDidChangeCapabilities`** event. The local agent-host provider snapshots `chat.agentHost.enabled` at startup, so its value is stable for the life of the provider instance. +- **`ISessionsProvider.createQuickChat(sessionTypeId)`** — required when `supportsQuickChats` is `true`. Returns an untitled draft (like `createNewSession`) that is not added to the session list until the first request is sent. +- **`ISessionsManagementService.createQuickChat(options?)`** — selects the first quick-chat-capable provider (honouring `order` and `options.providerId`), resolves the session type from `options.sessionTypeId` or the last-used / first advertised type, persists the resolved type as last-used, and mints a new quick-chat session **per call** (New Quick Chat = new session). +- **`ISessionsManagementService.getQuickChatSessionTypes()`** — every session type advertised by quick-chat-capable providers, for the inline composer type picker. +- **`ISessionsService.openQuickChat(options?)`** — view-layer entry point; opens the quick chat as a normal session. +- **`ISession.isQuickChat`** (optional `IObservable<boolean>`) — set only by quick-chat-capable providers (absent ⇒ `false`). Consumers read it via the `isQuickChatSession(session)` helper. The agent-host adapter derives it from the host's `workspaceless` tag, **not** from `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too. + +Presentation: a quick chat is a **single-chat** session that uses the normal session header (no peer-chat tab strip); only the Done/archive affordance is hidden. Its untitled-title fallback is **"New Chat"** (not "New Session") — every fallback site (titlebar, session header, list hover, sessions picker) routes through the shared `getUntitledSessionTitle(isQuickChat)` helper (`services/sessions/common/session.ts`). **Cmd+N always creates a new session** (`NewChatInSessionsWindowAction` → `openNewSession`); a quick chat is created **only** via the "Chats"-section **"+"** (`NewQuickChatAction`, also bound to **Cmd+K Cmd+N**), which opens the composer with the inline session-type picker feeding `openQuickChat({ sessionTypeId })` on send. Peer chats within a session are a third gesture (chat **"+"** / Cmd+T). Keep these three creation actions distinct. + +On the agent host, workspace-less is **inferred from an absent `workingDirectory`** at session start (forks are excluded — they inherit the source context), not from any wire flag. The host tags such sessions with `workspaceless` in the session `_meta` bag, gives each a stable per-session scratch directory, and uses a repo-less system prompt. See [`AGENT_HOST_SESSIONS_PROVIDER.md`](contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md) for the host-side details and [`SESSIONS_LIST.md`](SESSIONS_LIST.md) for the in-list "Chats" section. + ### Changesets Sessions produce file changes organized into **`ISessionChangeset`** groups — named, togglable collections of file modifications that let users review and selectively apply changes. @@ -201,12 +246,13 @@ Sessions produce file changes organized into **`ISessionChangeset`** groups — → Management fires onDidStartSession(committedSession) + onDidSendRequest(...) → isNewChatSession context → false ``` + Follow-up messages to an existing chat go through `SessionsManagementService.sendRequest(session, chat, options)`. The view makes the sent chat the active chat by reacting to the send events. When `options.background` is set, the send is **fire-and-forget** and skips the `onWillSendRequest` notification, so the view's send-follow never navigates the -visible slot into the sent chat — see *Adding a Chat to an Existing Session* +visible slot into the sent chat — see _Adding a Chat to an Existing Session_ below. Explicit user-initiated "new session" gestures (Ctrl/Cmd+N, the **New** button, @@ -238,7 +284,7 @@ its in-memory closed set) before the next storage flush; keeping (`_restoreClosedChats`) with the right closed chats, so closed tabs stay hidden across both reloads and session switches. The set is updated on the close/open action itself rather than derived from the `closedChats` observable (which -intersects with the session's *loaded* chats), so it never depends on chats +intersects with the session's _loaded_ chats), so it never depends on chats having loaded or on autorun timing. Stale URIs for chats that were later deleted are harmless: restore intersects the persisted set with the live chat list. @@ -261,8 +307,8 @@ longer referenced by `_pendingNewSession`. the provider's send-request options). Providers do not interpret the flag; it is purely a management/UI concern. The gesture is **Alt+Enter** (or **Alt-click** the Send button); plain Enter / click sends in the foreground. It is offered both -by the new-session composer and by the new-chat-in-session composer (see *Adding -a Chat to an Existing Session* below). +by the new-session composer and by the new-chat-in-session composer (see _Adding +a Chat to an Existing Session_ below). For callers outside the new-session composer, `createAndSendNewChatRequest(folderUri, options, createOptions?)` creates a fresh @@ -277,8 +323,9 @@ can react. Providers that set `capabilities.supportsMultipleChats` can host several peer chats inside one session that share a single backend scope (workspace, model, -config). For the local agent host provider this is enabled for the -`copilotcli` session type only. +config). For the agent host providers this is enabled for the `copilotcli` and +`claude` session types, whose backends (`CopilotAgent` / `ClaudeAgent`) +implement the peer-chat lifecycle (`createChat` / `disposeChat` / `getChats`). ``` 1. User adds a chat to a running session @@ -359,7 +406,7 @@ a peer with `map.clear()`/`map.delete()` — use `clearAndDisposeAll()`/ #### Forking into a new chat (multi-chat sessions) For sessions that support multiple chats, the **Fork Conversation** gesture -creates a new **peer chat** in the *same* session — seeded with the source +creates a new **peer chat** in the _same_ session — seeded with the source chat's history up to the fork point — instead of a brand-new session. The single-chat fork (which mints a new session via `createSession({ fork })`) is kept as the fallback for non-multi-chat sessions. @@ -373,7 +420,7 @@ resolves the owning `ISession`, and only for agent-host sessions that returns the new chat or throws (for example when the session does not support multi-chat forking); it never returns `undefined`. Non-agent-host sessions keep the new-session fork path. The `turnId` is the **last turn to keep**: forking -from a selected request forks *before* it (so `turnId` is the previous request's +from a selected request forks _before_ it (so `turnId` is the previous request's id), matching the new-session fork path (`AgentHostSessionHandler._forkSession`); forking the whole conversation keeps everything up to the source chat's last request. @@ -449,18 +496,18 @@ because providers are free to choose a different default-chat URI shape. The session title and each chat's title are independent: - **`ISessionsManagementService.renameSession(session, title)` → `ISessionsProvider.renameSession`** - renames the *session* only. The agent host provider dispatches + renames the _session_ only. The agent host provider dispatches `SessionTitleChanged` on the **session URI**; the host persists it as the session's `customTitle`. Used by the sessions-list "Rename Session" action and the session header inline-rename. -- **`renameChat(session, chatUri, title)`** renames a single *chat tab*. The +- **`renameChat(session, chatUri, title)`** renames a single _chat tab_. The provider dispatches `SessionTitleChanged` on that **chat channel** (`buildChatUri`/`buildDefaultChatUri`). The host detects the chat channel (`chatChannel` is set in `agentSideEffects.handleAction`) and translates it to a per-chat `SessionChatUpdated` via `AgentHostStateManager.updateChatTitle`, so the session title is untouched. Used by the chat composite bar (per-tab rename). -The default chat starts with an **empty** catalog title so it *inherits* the +The default chat starts with an **empty** catalog title so it _inherits_ the session title for display (`_ensureDefaultChat` seeds `title: ''`). The provider's `mainChat.title` is `derived(_defaultChatTitleOverride ?? session.title)`, and `applyChatCatalog` only sets the override when the default chat's catalog title is @@ -470,18 +517,21 @@ title onto the still-inheriting default chat** (via `updateChatTitle`), so once session is multi-chat the session title and the default chat tab title are fully independent — renaming the session no longer moves the default chat tab and vice-versa. Auto-titling from the first message -titles the *session* for the default chat and the *chat itself* (via +titles the _session_ for the default chat and the _chat itself_ (via `updateChatTitle`) for additional chats — see `agentHostSessionTitleController`. Single-chat providers (`copilotChatSessions`, `localChatSessions`) implement `renameSession` by renaming their single main chat. `renameSession` is a mandatory `ISessionsProvider` method (no optional methods — see the interface guideline). -Whether the rename UI is *offered* is gated on `capabilities.supportsRename`, not -on the provider id. The session header inline-rename (`SessionHeader._isTitleEditable`) -and the sessions-list "Rename..." action (gated on the -`sessionSupportsRename` context-menu-overlay key, set from -`element.capabilities.supportsRename` in `sessionsList`) both read this flag. +Whether the rename UI is _offered_ is gated on `capabilities.supportsRename`, not +on the provider id. `ISession.capabilities` is an `IObservable<ISessionCapabilities>` +so consumers react when a provider's advertised capabilities hydrate or change after +the session is first surfaced (e.g. an agent host whose root state arrives after the +session's first state update). The session header inline-rename +(`SessionHeader._isTitleEditable`) and the sessions-list "Rename..." action (gated on +the `sessionSupportsRename` context-menu-overlay key, set from +`element.capabilities.get().supportsRename` in `sessionsList`) both read this flag. Providers declare it truthfully: agent-host and `localChatSessions` sessions are always renameable; `copilotChatSessions` sets it only for the CopilotCLI and Claude session types, since `renameChat` throws for other backends. Omitting the flag means @@ -502,6 +552,8 @@ Backend state change (turn complete, status update, etc.) Providers may fire `onDidReplaceSession` when a temporary (untitled) session is atomically replaced by a committed one after the first turn. +Provider add notifications are authoritative upserts. A provisional `listSessions()` entry may already be cached when the backend publishes its materialized project and working directory, so providers update the existing session adapter in place and report it as changed rather than replacing its identity. + --- ## Adding a New Provider @@ -511,17 +563,27 @@ Providers may fire `onDidReplaceSession` when a temporary (untitled) session is 3. **Place code under `contrib/providers/<name>/`** 4. **Register via a workbench contribution** at `WorkbenchPhase.AfterRestored`: ```typescript - class MyProviderContribution extends Disposable implements IWorkbenchContribution { - constructor( - @IInstantiationService instantiationService: IInstantiationService, - @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, - ) { - super(); - const provider = this._register(instantiationService.createInstance(MyProvider)); - this._register(sessionsProvidersService.registerProvider(provider)); - } + class MyProviderContribution + extends Disposable + implements IWorkbenchContribution + { + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @ISessionsProvidersService + sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + const provider = this._register( + instantiationService.createInstance(MyProvider), + ); + this._register(sessionsProvidersService.registerProvider(provider)); + } } - registerWorkbenchContribution2(MyProviderContribution.ID, MyProviderContribution, WorkbenchPhase.AfterRestored); + registerWorkbenchContribution2( + MyProviderContribution.ID, + MyProviderContribution, + WorkbenchPhase.AfterRestored, + ); ``` 5. Use `toSessionId(providerId, resource)` for session IDs 6. Fire `onDidChangeSessions` on every session change and `onDidReplaceSession` from the provider on untitled→committed transitions @@ -539,7 +601,7 @@ Every method on `ISessionsProvider` is part of the mandatory contract. Do **not* ### Any addition to `ISession` or `ISessionsProvider` must be consumed in the agents window core workbench -The **agents window core workbench** is defined as all sessions code *outside* `src/vs/sessions/contrib/providers/` — that is, code in `src/vs/sessions/services/`, `src/vs/sessions/browser/`, `src/vs/sessions/common/`, and non-provider `src/vs/sessions/contrib/*` folders (views, UI contributions, toolbars, etc.). +The **agents window core workbench** is defined as all sessions code _outside_ `src/vs/sessions/contrib/providers/` — that is, code in `src/vs/sessions/services/`, `src/vs/sessions/browser/`, `src/vs/sessions/common/`, and non-provider `src/vs/sessions/contrib/*` folders (views, UI contributions, toolbars, etc.). When you add a property or method to `ISession` or `ISessionsProvider`, it **must** be referenced by at least one file in the core workbench, not only within provider implementations. @@ -549,7 +611,7 @@ When you add a property or method to `ISession` or `ISessionsProvider`, it **mus Context keys are an output/gating mechanism, **not** a source of truth. Do **not** mirror dynamic state (e.g. "the active session has models", a count, a selection) into a context key only to read it back in imperative code, and do not call `IContextKeyService.getContextKeyValue(...)` to drive logic. Instead, read state directly from the owning service or observable (`ISessionsService.activeSession`, `ISessionsProvider.getModels`, etc.) and react with `autorun`/`derived`. -Context keys remain the correct tool for **declarative** `when` clauses on menu, command, and keybinding contributions — there is no alternative there, because those are evaluated by the platform. The rule targets *imperative* code: a component that already has access to a service must consult the service, not a context key that shadows it. +Context keys remain the correct tool for **declarative** `when` clauses on menu, command, and keybinding contributions — there is no alternative there, because those are evaluated by the platform. The rule targets _imperative_ code: a component that already has access to a service must consult the service, not a context key that shadows it. **Example:** the sessions-core model picker (`contrib/chat/browser/modelPicker.ts`) does not maintain an `activeSessionHasModels` context key. It reads `provider.getModels(...)` directly and toggles its own visibility, while its menu `when` clause only gates on genuinely declarative conditions (phone layout, and whether the provider offers a combined config picker). @@ -561,4 +623,6 @@ Core (non-provider) code must **not** branch on a provider's identity or session **Example:** the sessions-core model picker presentation (grouping, featured models, the "Manage Models" action) is not decided in core. The core picker asks the active session's provider via `ISessionsProvider.getModelPickerOptions(sessionId)`, which returns an `ISessionModelPickerOptions`. The local provider returns `showManageModelsAction: true`; the others return `false`. Core never inspects the session type to make this choice. +Every model-picker trigger identifies the selected model's vendor with a leading provider icon derived from the model metadata (for example OpenAI, Claude, Gemini, or Copilot), including editor chat, active sessions, new sessions, and phone-layout pickers. Auto is provider-agnostic and always uses the Copilot icon, regardless of provider metadata or generic-icon presentation. Standard editor and Agents-window chat inputs collapse the trigger to that provider icon below 280px while retaining the full accessible model label. + **Rationale:** Hardcoding provider identity in core re-couples the orchestration layer to specific providers, defeating the pluggable provider model. New providers would silently get wrong defaults and require edits to core. Delegating keeps each provider authoritative over its own behavior and keeps core provider-agnostic. diff --git a/src/vs/sessions/SESSIONS_LIST.md b/src/vs/sessions/SESSIONS_LIST.md index ddc94a4b14506d..46eea385d3aaa2 100644 --- a/src/vs/sessions/SESSIONS_LIST.md +++ b/src/vs/sessions/SESSIONS_LIST.md @@ -34,21 +34,28 @@ Each session row displays: - **Status description or timestamp** — InProgress/NeedsInput/Error show a status message; otherwise a relative timestamp - **Approval row** (optional) — pending agent approvals with an "Allow" button +`SessionsFlatList` reuses the same session row renderer for sectionless surfaces, including the approval row and dynamic row height updates. Consumers that size their own container listen for content-height changes and relayout the list. When embedded inside another hover, consumers disable row hovers so moving over the list does not replace the parent hover. + ### Grouping Sessions are organized into sections with fixed priority: ``` 1. Pinned ← always first, not reorderable -2. Groups ← user-created groups, user-ordered (see below) -3. Regular ← grouped by workspace or date -4. Done/Archived ← always last, not reorderable +2. Chats ← workspace-less "quick chat" sessions; always-visible, directly below Pinned +3. Groups ← user-created groups, user-ordered (see below) +4. Regular ← grouped by workspace or date +5. Done/Archived ← always last, not reorderable ``` +The **Chats** section holds workspace-less quick-chat sessions, detected via the `isQuickChatSession(session)` helper (which reads the session's own `ISession.isQuickChat` observable — **not** `workspace === undefined`, which can be transiently undefined for workspace-bound sessions too). It renders **inside the Sessions list directly below the Pinned section** (above the workspace/date groups) in **both** grouping modes — quick chats are neither a workspace nor a date bucket, so they are partitioned out of workspace/date grouping and rendered as their own entry right after Pinned. The section is **always visible** (even with no quick chats) whenever a provider advertises `supportsQuickChats` — subject to the `sessions.list.showEmptyDefaultGroups` setting (default `true`; when `false` the empty Pinned and Chats sections are hidden). Both the Pinned and Chats section headers carry a **leading icon** (`Codicon.pinned` for Pinned, `Codicon.commentDiscussion` for Chats) and share the standard section-header font/styling (the two headers look consistent — no prominent top-title variant). The Chats header shows the chat icon, the label "Chats", and a **"+" New Quick Chat** action in its section toolbar (also bound to **Cmd+K Cmd+N**) — the *only* create affordance for quick chats (Cmd+N always creates a new **session**, not a quick chat; there is no quick-chat action in the top Sessions header). "Mark All as Done" is not offered on the Chats section. When the section has **no quick chats**, it shows a muted, centered **"No chats" placeholder row** (a synthetic non-session list item, like the "show more" rows) instead of an empty section. A pinned quick chat still appears in Pinned (pin wins), and an archived one still goes to Done (archive wins). Quick-chat rows use a comment/chat icon instead of the folder/worktree/cloud workspace icon and carry no workspace badge. That per-row chat icon is **suppressed when the row renders under the Chats section** (whose header already carries a chat icon) and shown only where the chat identity is useful — a quick chat pinned to Pinned or moved into a custom group. + +Each quick chat is its **own single-chat session** (New Quick Chat = a new session per create), so it occupies one list row like any other session — there are no chat-level (`IChat`) rows. A quick chat is pinned/grouped/archived as a whole session: a pinned quick chat appears in Pinned (pin wins), an archived one goes to Done (archive wins). The earlier "single quick-chat container session whose peer `IChat`s become their own rows" model was descoped. + Two grouping modes (user-switchable): - **By Workspace** (default) — user groups and one section per workspace label share a single, freely-reorderable user-managed order below Pinned. By default groups come first and workspaces are alphabetical ("Unknown" workspace last) until the user drags them. -- **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Today, Yesterday, Last 7 Days, Older). Groups never mix into the date sections. +- **By Date** — user groups form a contiguous, user-ordered block directly below Pinned; the non-grouped sessions follow in the fixed date sections (Recent, Older), where Recent holds up to 10 sessions from the last 7 days and Older holds the rest. Groups never mix into the date sections. User groups are **fully user-managed**: their order is owned by `ISessionSectionOrderService`, defaults to newest-first, and is shared across both grouping modes (it no longer derives from the recency of a group's member sessions). @@ -88,7 +95,9 @@ A built-in find widget filters the list by session title and section label. When ### Pinning -Pinned sessions appear in a dedicated "Pinned" section at the top. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). +Pinned sessions appear in a dedicated "Pinned" section at the top. The section is **always visible** (even with no pinned sessions), mirroring the "Chats" section — subject to the `sessions.list.showEmptyDefaultGroups` setting (default `true`; when `false` the empty Pinned/Chats sections are hidden). When empty it shows a muted, centered **"No pinned sessions" placeholder row**. Pin state is managed by `ISessionsListModelService` and persisted locally (not synced to providers). + +The **Pinned** and **Chats** sections start **collapsed on first open** (their default collapse state is `PreserveOrCollapsed` when no saved state exists). Once the user expands or collapses either section, that choice is persisted per-section under `sessionsListControl.sectionCollapseState` and honored on subsequent loads. ### Manual Reordering (Drag & Drop) @@ -101,7 +110,7 @@ Regular sessions can be reordered by dragging them up or down within the list. P - **Storage** — reordering stores a synthetic numeric *sort key* per session in `ISessionsListModelService` (persisted locally, not synced). It is used **only** for sorting; the provider's real `createdAt`/`updatedAt` are never modified. A separate override map is kept for each sort mode (Created vs Updated). - **Sort key** — on drop, the new key is the midpoint between the effective keys of the sessions immediately above and below the drop point. Dropping above the first session uses the current time (so it sorts to the top). Dropping below the last session steps below the last key. - **Dropping the fake value** — if a session's natural timestamp already sorts it into the dropped slot (e.g. after dragging it down and back), the stored override is removed so the list falls back to natural ordering. -- **Grouping by Date** — the regular list is one continuous sequence, so dragging can move a session across date buckets (e.g. to the top makes it "Today"). +- **Grouping by Date** — the regular list is one continuous sequence, so dragging can move a session across date buckets (e.g. to the top makes it "Recent"). - **Grouping by Workspace** — reordering is restricted to within the same workspace group; drops onto another workspace are rejected. - **Pinned** — dropping a non-archived session on the Pinned header pins it and lets it sort naturally. Dropping it on a pinned session shows an insertion line, pins it, and stores the sort key needed to place it at that location. - **User groups** — dropping a non-archived session on a group header adds or moves it into the group and lets it sort naturally. Dropping it on a session inside the group shows an insertion line for the exact slot and highlights only the group header to indicate the receiving group. @@ -220,7 +229,7 @@ Context keys available for `when` clauses when contributing to session list menu | Key | Type | Description | |-----|------|-------------| -| `sessionSection.type` | string | `'pinned'`, `'archived'`, `'workspace:<label>'`, `'today'`, etc. | +| `sessionSection.type` | string | `'pinned'`, `'quickchats'`, `'archived'`, `'workspace:<label>'`, `'recent'`, etc. | ### View-Level diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md new file mode 100644 index 00000000000000..bfbece224ccc08 --- /dev/null +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -0,0 +1,246 @@ +# Single-Pane Detail Panel — Scenarios + +This document enumerates the user-facing scenarios, states, and transitions for the **single-pane +detail panel** layout of the Agents window (the third pane redesigned as one pane with a single tab +bar spanning the editor content and a docked detail panel). + +- The whole feature is gated behind the experimental setting **`sessions.layout.singlePaneDetailPanel`** + (const `DOCK_DETAIL_PANEL_SETTING`), read **once at startup** — a window reload applies a change. +- When the setting is **OFF** (default), the Agents window renders exactly as before (auxiliary bar as + its own grid column with its composite tab strip; the standard multi-diff Changes editor). Nothing in + this document applies. +- Companion specs: [LAYOUT.md](LAYOUT.md) §5, [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md), and + [contrib/layout/browser/desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md). + +--- + +## 1. The three regions + +The third pane is a single visual card containing three regions: + +| Region | What it is | Owner | +|--------|-----------|-------| +| **Tab bar** | One tab strip spanning the full width (Changes / File / Browser tabs + trailing `+`) | Editor group title (`MainEditorPart` / `EditorGroupView`) | +| **Editor content** | The editor pane below the tab bar (multi-diff Changes, a file, a browser) | Editor part, inset on the right by the detail width | +| **Detail panel** | The docked auxiliary bar on the right (Branch Changes + Checks, or Explorer) | `DockedAuxiliaryBarController` (docks the aux bar inside the editor part) | + +**Invariant:** the **tab bar is always visible** whenever the pane is shown — including when the editor +content is hidden and in the new-session view. It is kept laid out by `MainEditorPart.layout`'s +`keepForDockedTabBar` path (single-pane + detail visible), even while the editor part is logically +hidden. + +--- + +## 2. Pane visibility states + +Let **E** = editor content visible, **D** = detail panel visible. The pane supports: + +| State | E | D | Meaning | +|-------|---|---|---------| +| **Editor + Detail** | ✅ | ✅ | Normal working state: editor content on the left, detail on the right, tab bar across the top. | +| **Detail only** | ❌ | ✅ | Editor content collapsed (Hide Editor); tab bar + detail shown; the chat reclaims the freed editor width. The detail **keeps its width** (it does not stretch to fill the pane). | +| **Editor only** | ✅ | ❌ | Detail toggled off; editor content fills the pane; tab bar across the top. **This is the default state for a created session** — opening the side pane shows the Changes editor with the detail panel closed; the detail is opened only via **Toggle Details** (or restored per-session). | +| **Side pane closed** | ❌ | ❌ | The whole third pane is closed (chat-only). Reached via **Toggle Side Panel** or when the last editor tab closes; never via the detail toggle. | + +A created session opens the side pane to **Editor only** (Changes editor, detail closed) by default; a Changes/file editor becoming active never force-opens the detail (the one exception is restoring the detail after a transient browser-tab hide). A new-session view opens to the **Files detail** (its editor content stays hidden by R1). + +**Size distribution when opening the side pane.** Opening the side pane from *closed* (e.g. clicking +**Changes** while the chat is full-width) gives it a comfortable **~even split** with the chat, so the +editor content is readable beside the detail — never the collapsed detail-only width. This applies on +**every** such reveal that has no user-chosen width to restore (not just the first in a window): +hiding the editor collapses its grid node to the detail width and the grid caches that, so a later +reveal — including in a different session — must re-apply the even split rather than restore the narrow +cached width. A width the user **deliberately set** (captured on hide as `_dockedEditorSizeBeforeHide`) +always takes precedence and is restored as-is. + +**Reopening after the sessions list is collapsed.** Closing the **whole** side pane collapses the editor +grid node to `0px`, so its size at that moment is **not** a real user width — closing the whole pane +therefore does **not** capture `_dockedEditorSizeBeforeHide` (and clears any stale sidebar-collapse grow +snapshots). This matters when the **sessions list is collapsed**: reopening the side pane falls through to +the **even split**, and because the collapsed list makes the sessions part span nearly the full width, half +of it is a **comfortable** width — not the cramped/narrow node that a captured `0px` (or a stale +pre-collapse snapshot) would otherwise restore. Only **Hide Editor** (detail stays visible, node stays +visible at a real width) captures a width to restore later. + +--- + +## 3. Controls + +| Control | Location | Effect | +|---------|----------|--------| +| **Hide Editor** (chevron `>`) | Editor title bar, primary inline, **before** Maximize | Closes the editor content, keeps the detail (→ *Detail only*). The docked side pane shrinks to the detail width so the freed editor width goes to the **chat** (not the detail), and the **sessions list is reshown** (it may have been auto-collapsed when details was opened). Shown **only** when the active tab is **Changes or Files** (not Browser). Hidden when the editor is already closed, and hidden while the editor area is **maximized**. | +| **Toggle Details** (`≡`) | Editor title bar, primary inline, after Maximize | Shows/hides the detail panel (default keybinding **`⌥⌘L`**). Hiding the detail **while the editor is hidden reveals the editor** (→ *Editor only*), so the pane is never left empty — this applies in the **new-session view** too (revealing the empty editor rather than closing the whole pane). Opening the detail panel via this action auto-collapses the **sessions list** to free width for the editor area; closing it restores the sessions list. Its `toggled` state (`AuxiliaryBarVisibleContext`) is kept **in sync with the actual rendering**: the toggle reads "on" iff the detail panel is rendered with an active view container — an empty (gated-off) container is never shown, and the layout controller (D10) reconciles the part away if it becomes visible with nothing to render. | +| **Maximize / Restore** | Editor title bar, primary inline | Maximizes the editor area (forces the Changes detail while maximized; restores on un-maximize). Default keybinding **`⌥⌘E`** toggles maximize/restore while the editor area is visible. | +| **Collapse All Diffs** | Changes editor header, primary inline | Collapses every file in the Changes multi-diff (`SessionChangesEditor.collapseAllDiffs`). | +| **`+` Add Tab** | End of the tab strip | Opens the Add Tab menu (Browser `⇧⌘K B`, Search `⌘K S`; a **Changes** entry when the Changes editor tab is closed, and a **Files** entry `⌘K B` when the Files tab is closed — both for a created workspace session). Re-added managed Changes/Files tabs are inserted at the **end** of the tab strip. Search opens a new Search editor. **Hidden when the editor area is closed.** | +| **Toggle Side Panel** | Command / keybinding | Closes/opens the **whole** side pane (editor + detail together) → chat-only and back. | +| **Toggle Sessions List** | Title bar / command | Collapses/opens the left sessions list. Collapsing it gives the freed width to the editor/detail side pane (not the chat); reopening restores the previous editor/detail width so the chat gets that space back. The list is **also** auto-collapsed when the user opens the detail panel via **Toggle Details**, or when they open a real file/diff into the editor area **in an existing (created) session while the editor area is currently closed** (and restored when they close it), unless the user has since reopened it manually. An auto-collapsed list is **also restored once the side pane becomes fully hidden** (both editor and detail closed) — e.g. switching to a quick chat, which has no side pane — so the list is never left collapsed with nothing to make room for. A list the user closed **manually** stays closed. | +| **Grid sash** | Between the chat and the third pane | In a **created** session, dragging it wider re-reveals the editor content and re-syncs state (the Hide Editor chevron reappears); dragging it narrow enough that the editor content is squeezed to the detail width **hides** the editor content (mirroring the reveal), which hides all editor-title actions. In the **new-session** view a width reveal is momentary — R1 re-hides the editor, which stays closed until a file is opened. | +| **Changes pill** | Session header meta row | Opens the managed Changes multi-diff editor and explicitly reveals the editor area when the side pane was closed or in detail-only mode. The managed Changes tab still remains excluded from automatic reveal-on-open, so merely activating its tab does not reveal the editor. | + +**Editor-title action visibility.** All single-pane editor-title actions (Maximize/Restore, Toggle Details, Hide Editor, Open in Modal) are hidden while the **editor area is closed** (`MainEditorAreaVisibleContext`). Hide Editor is additionally shown only when the active tab is **Changes or Files** (`SinglePaneDetailChangesOrFilesActiveContext`) and only while the editor area is **not maximized** (`EditorMaximizedContext` negated). + +**Managed Files tab.** The empty Files placeholder tab is shown only when the editor area is **closed** or **no real (non-managed) editor is open**; once a real file/diff is opened into a visible editor area it is removed as redundant, and re-added when the editor area closes again. + +**Closing managed tabs.** The user can close the managed Changes and Files tabs (they are non-preview, not sticky). A user-initiated close is remembered (`_dismissedManagedTabs`) so the controller does not immediately re-create it; the dismissal is cleared — and the tabs re-populate — on a **session change** or when the **side pane is reopened** from fully closed. While a managed tab is closed for a created workspace session, the `+` Add Tab menu offers a matching entry to reopen it — **Changes** (gated on `SinglePaneChangesTabMissingContext`) and **Files** (gated on `SinglePaneFilesTabMissingContext`); reopening clears its dismissal so the controller resumes managing it. + +**Per-session detail state.** A created session's detail-panel (aux-bar) visible/hidden choice is captured per session and restored on switch-back and reload (a detail-closed session stays detail-closed when returning to it), even if an external component transiently reveals the aux bar during the working-set restore or a queued detail-container sync from the previous session runs later. + +**Reopening after closing all tabs.** Closing all tabs closes the whole side pane; the managed Changes (created) / Files (new-session) tabs are re-ensured, so reopening the side pane shows the Changes editor or Files tab — never an empty editor. + +**Side-pane-closed persists across reload.** Closing the whole side pane is remembered across a window reload. On reload the restored managed tab does **not** re-reveal the detail: the detail-panel forced reveal is gated on the editor content being visible, so a fully-closed side pane stays closed until the user reopens it. + +**Opening a file.** The **Files** add-tab entry opens its tab **pinned** (not a preview tab). + +Actions **not** present in single-pane mode: **Close Editor Area**, **Show Editor** (the standard +layout keeps *Close Editor Area*). + +--- + +## 4. Tabs + +- **Changes** — a custom `SessionChangesEditor` (Branch Changes dropdown + diff stats + embedded + multi-diff). Pinned first, present for **created** sessions with a workspace. +- **File** — the empty File tab (`EmptyFileEditorInput`) as a landing tab, plus real file editors the + user opens. Opened **pinned, inactive, preserve-focus** so it never steals focus from the chat. +- **Browser** — the integrated browser (`BrowserEditorInput`). + +The **auto-managed** tabs (the pinned Changes tab and the default File tab) are opened under +`suppressEditorPartAutoVisibility()` — they **never reveal the editor content**. Only a user action +(opening an actual file/diff, or dragging the sash) reveals the editor. + +--- + +## 5. Detail panel content (driven by the active tab) + +The single-pane layout controller (`SinglePaneLayoutController`) maps the active editor tab to the detail content. By default the detail panel is **closed** for a created session (Editor-only); it is opened via **Toggle Details** (or restored per-session), and while visible its container follows the active tab (the one exception is restoring the detail after a transient browser-tab hide): + +| Active tab | Detail panel | +|-----------|--------------| +| **Changes** | Branch Changes file list + Checks — shown (Changes container) while the detail is visible | +| **File** (Explorer) | Files/Explorer tree — shown (Files container) while the detail is visible | +| **Browser** | **Hidden** (transiently) while the Browser tab is active; restored when switching back | + +Rules: +- **Reveal on activate, respect after.** Switching to a Changes/File tab reveals the detail with the + right container. While the **same** tab stays active, an explicit user hide of the detail (via the + detail toggle) is **respected** — it is not re-forced. Switching tabs reveals it again. +- **Browser is transient.** A Browser tab hides the detail panel; switching back to Files/Changes + **restores** it. + +--- + +## 6. Layout rules (new-session lifecycle) + +### R1 — New-session (uncreated) view +When the new-session composer is active (uncreated session, has a workspace, not a quick chat): +- **Initial state:** **File tab** active + **Files detail** open + **editor content closed** (*Detail + only*). Tab bar visible. The composer keeps focus (the File tab is inactive/preserve-focus). +- The editor is kept hidden while this view is active, but the hide is **transition-triggered**: it fires + when the editor **just became visible**, or when the new-session view was **just entered** with the editor + already visible (an inherited-visible editor from the previous session) — where *real content* is a real + file (`FileEditorInput`) or the integrated browser (`BrowserEditorInput`); the managed empty landing tab + (`EmptyFileEditorInput`) and "no active editor" are **not** real content. Any **spurious reveal** (a + session-switch working-set restore, a layout race, the reveal-good-size even split) is **re-hidden** — + fixing the case where reopening a new session after visiting a created session left the editor open. + Crucially, **switching to a managed tab (e.g. the Files placeholder) while the editor is already visible + does NOT hide it** — only a visibility transition or entering the view does, so the user can keep the + editor open and switch tabs. R1 wins in the new-session view: the editor stays closed until the user + **explicitly opens a file/diff**. A width-based reveal (e.g. a sash drag) may momentarily reveal the + editor, but R1 re-hides it (it was a non-explicit reveal). Once a real file is the active editor the hide + **short-circuits**, so a user action that reveals the editor via a real editor open **sticks**: + - **Opening a file** from the Files view → editor content shows (via `onWillOpenEditor` → + `setEditorHidden(false)`) (→ *Editor + Detail* or *Editor only*). + - **Detail toggle** → reveals the editor (→ *Editor only*). + - **Sash drag** in the new-session view does **not** keep the editor revealed (the sash-reveal sticks for + *created* sessions only); R1 re-hides it. +- **Collapsing the sessions list** while the editor is closed gives the freed width to the **detail + panel** (not the editor node), keeping the editor node width equal to the detail width so it is never + mistaken for a revealed editor. Reopening the sessions list restores the pre-collapse detail width. + +### R2 — New session submitted (uncreated → created) +When the new session is submitted: +- A **Changes tab** is added and the **Changes detail** is shown. +- The **editor content stays closed** (*Detail only*) — neither the submit nor the auto-opened Changes + editor reveals it. This also applies when the provider commits the draft by replacing it with a new + session resource. The user opens the editor when they want it (open a file/diff, or drag the sash). + +### Quick chats / no workspace +No side pane at all — the detail panel and managed tabs are not shown; the chat is +full-width. Switching to a quick chat never auto-reveals the docked editor part +(`_shouldRevealEditorPartOnApply` excludes quick chats), and if a prior session left the +editor part visible it is hidden once the quick chat's editor group is empty +(`_registerQuickChatEditorHide`), so the whole side pane collapses. Because the side pane is +then hidden, an auto-collapsed **sessions list** is restored (see Toggle Sessions List). + +--- + +## 7. Transition matrix (single-session, not maximized) + +| From | Action | To | +|------|--------|-----| +| — | Enter new-session view | *Detail only* (File tab + Files detail, editor closed) | +| *Detail only* (new session) | Open a file from Files | *Editor + Detail* (editor revealed, stays open) | +| *Detail only* / *Side pane closed* (created session) | Click **Changes** pill | *Editor only* (Changes editor revealed, detail stays closed unless separately restored/opened) | +| *Detail only* (new session) | Toggle Details (hide detail) | *Editor only* (empty editor revealed — the side pane does not vanish) | +| *Detail only* (new session) | Drag grid sash wider | *Detail only* (editor stays closed; a momentary width reveal is re-hidden by R1 in the new-session view) | +| *Detail only* (new session) | Toggle Sessions List closed | *Detail only*; the **detail panel** widens by the sessions-list width (editor stays closed) | +| *Detail only* | Toggle Details (hide detail) | *Editor only* (editor revealed) | +| *Editor + Detail* | Hide Editor chevron | *Detail only* (detail keeps width, chat expands) | +| *Editor + Detail* | Toggle Details (hide detail) | *Editor only* | +| *Editor only* | Toggle Details (show detail) | *Editor + Detail* | +| *Detail only* / *Editor only* / *Editor + Detail* | Toggle Side Panel | *Side pane closed* | +| *Side pane closed* | Toggle Side Panel | previous state restored | +| *Side pane closed* (session A) | Switch to session B (side pane open), then back to A | A's *Side pane closed* is restored (the editor part is actively re-hidden on switch, not left open from B) | +| editor/detail side pane visible | Toggle Sessions List closed | same pane state; editor/detail side pane widens by the sessions-list width | +| sessions list closed after side-pane growth | Toggle Sessions List open | same pane state; editor/detail side pane returns to its pre-collapse width | +| any | Close the last editor tab | *Side pane closed* (chat-only; opening a tab restores the pane) | +| *Detail only* (created session) | Drag grid sash wider | *Editor + Detail* (editor content re-revealed) | +| any | Activate **Browser** tab | detail hidden (transient) | +| Browser active (detail hidden) | Activate **Files/Changes** tab | detail restored | +| new-session *Detail only* | **Submit** the session | *Detail only* + Changes tab + Changes detail | + +--- + +## 8. Manual validation checklist + +1. **New session view:** File tab + Files detail open + **no editor content**; tab bar visible; the + "What are you building?" composer keeps focus. +2. **Open a file** from the Files view in the new-session view → the editor content appears and stays. +3. **Detail toggle** in the new-session view → the editor content appears (detail hides). +4. **Submit** a new session → a Changes tab appears with the Changes detail; the editor content is + **still closed**. +5. **Hide Editor** chevron → editor content closes, detail **keeps its width**, chat expands, tab bar + stays; the chevron then hides. +6. **Detail toggle** from *Editor + Detail* → detail hides, editor stays (*Editor only*); toggle again + → detail returns. +7. **Toggle Side Panel** → the whole side pane closes (chat-only); toggle again → it restores. +8. **Browser tab** → detail hides; switch back to Files/Changes → detail restores. +9. **File tab** active → the Explorer detail is shown (revealed on activation). +10. **Close the last editor tab** → the whole side pane closes (chat-only); opening any tab restores it. +11. **`+` button** hidden while the editor area is closed; reappears when the editor is open. +12. **Sash drag** to widen the third pane in a **created** session while the editor is closed → editor + content re-reveals and the Hide Editor chevron reappears; hiding the editor never leaves a + corrupted/overlapping layout. In the **new-session** view the same drag widens the detail panel and + the editor stays closed. +13. **Toggle Sessions List** while the side pane is visible → when the editor content is visible the + editor/detail pane widens by the sessions-list width; when the editor is closed (new-session / + detail-only) the **detail panel** widens instead and the editor stays closed. Toggle it back → the + pane returns to its previous width and the chat regains the space. +14. **Setting OFF** → the Agents window is the original layout, unchanged. + +--- + +## 9. Where it lives (implementation map) + +| Concern | File | +|---------|------| +| Docked layout, hide/show editor, detail width, sash-reveal sync, grid | `browser/workbench.ts` | +| Docked panel overlay + resize sash | `browser/dockedAuxiliaryBarController.ts` | +| Editor tab bar kept visible when content hidden; sash-reveal trigger | `browser/parts/editorPart.ts` | +| Active tab → detail container mapping (browser transient) | `contrib/layout/browser/singlePaneLayoutController.ts` | +| Managed Changes + File tabs (suppressed opens) | `contrib/layout/browser/singlePaneLayoutController.ts` | +| Startup controller selection | `contrib/layout/browser/sessions.layout.contribution.ts` | +| New-session transition-triggered editor hide (R1) | `contrib/layout/browser/singlePaneLayoutController.ts` | +| Hide Editor chevron, Maximize, add-tab actions | `contrib/editor/browser/editor.contribution.ts`, `contrib/editor/browser/addTabActions.ts` | +| Toggle Details command + editor-title item | `contrib/layout/browser/singlePaneLayoutController.ts` | diff --git a/src/vs/sessions/browser/actions/vscodeActions.ts b/src/vs/sessions/browser/actions/vscodeActions.ts index b8fecad2738719..59d159c1035472 100644 --- a/src/vs/sessions/browser/actions/vscodeActions.ts +++ b/src/vs/sessions/browser/actions/vscodeActions.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../base/common/codicons.js'; +import { Schemas } from '../../../base/common/network.js'; +import { URI } from '../../../base/common/uri.js'; import { ServicesAccessor } from '../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../nls.js'; import { Action2 } from '../../../platform/actions/common/actions.js'; @@ -23,7 +25,7 @@ import { Disposable } from '../../../base/common/lifecycle.js'; import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution } from '../../../workbench/common/contributions.js'; -import { getOpenInVSCodeUri, getVSCodeProtocolScheme, resolveRemoteAuthority } from '../openInVSCodeUtils.js'; +import { resolveRemoteAuthority } from '../openInVSCodeUtils.js'; import { OpenInVSCodeTitleBarWidget } from '../widget/openInVSCodeWidget.js'; export class OpenInVSCodeAction extends Action2 { @@ -53,11 +55,21 @@ export class OpenInVSCodeAction extends Action2 { const sessionsService = accessor.get(ISessionsService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const scheme = getVSCodeProtocolScheme(productService); + + const scheme = productService.quality === 'stable' + ? 'vscode' + : productService.quality === 'exploration' + ? 'vscode-exploration' + : productService.quality === 'insider' + ? 'vscode-insiders' + : productService.urlProtocol; + + const params = new URLSearchParams(); + params.set('windowId', '_blank'); const activeSession = sessionsService.activeSession.get(); if (!activeSession) { - await openerService.open(getOpenInVSCodeUri(scheme, undefined, undefined, undefined), { openExternal: true }); + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); return; } @@ -66,7 +78,7 @@ export class OpenInVSCodeAction extends Action2 { const rawFolderUri = workspace?.isVirtualWorkspace ? undefined : folder?.workingDirectory; if (!rawFolderUri) { - await openerService.open(getOpenInVSCodeUri(scheme, undefined, undefined, undefined), { openExternal: true }); + await openerService.open(URI.from({ scheme, query: params.toString() }), { openExternal: true }); return; } @@ -74,7 +86,23 @@ export class OpenInVSCodeAction extends Action2 { const remoteAuthority = resolveRemoteAuthority( activeSession.providerId, sessionsProvidersService, remoteAgentHostService); - await openerService.open(getOpenInVSCodeUri(scheme, folderUri, remoteAuthority, activeSession.resource), { openExternal: true }); + params.set('session', activeSession.resource.toString()); + + if (remoteAuthority) { + await openerService.open(URI.from({ + scheme, + authority: Schemas.vscodeRemote, + path: `/${remoteAuthority}${folderUri.path}`, + query: params.toString(), + }), { openExternal: true }); + } else { + await openerService.open(URI.from({ + scheme, + authority: Schemas.file, + path: folderUri.path, + query: params.toString(), + }), { openExternal: true }); + } } } diff --git a/src/vs/sessions/browser/dockedAuxiliaryBarController.ts b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts new file mode 100644 index 00000000000000..429bc86a67c3e8 --- /dev/null +++ b/src/vs/sessions/browser/dockedAuxiliaryBarController.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../base/common/lifecycle.js'; +import { ISashEvent, IVerticalSashLayoutProvider, Sash, SashState, Orientation as SashOrientation } from '../../base/browser/ui/sash/sash.js'; +import { Part } from '../../workbench/browser/part.js'; + +/** Accessors the controller uses to read/write the docked panel width and query visibility. */ +export interface IDockedAuxiliaryBarHost { + getWidth(): number; + setWidth(width: number): void; + /** Whether the editor area (editor or docked aux bar) is visible. */ + isEditorAreaVisible(): boolean; + /** Whether the editor part itself is visible, excluding the docked aux bar. */ + isEditorVisible(): boolean; + /** Whether the docked auxiliary bar (detail panel) is visible. */ + isAuxiliaryBarVisible(): boolean; + /** Hide the docked auxiliary bar via the workbench part-visibility API. */ + hideAuxiliaryBar(): void; + /** + * Reserves an inset (px) on the right of the editor content while the editor + * tab bar keeps the full width, so the docked panel can sit beside it. `0` + * restores full-width content. + */ + setEditorContentRightInset(px: number): void; + /** Extra top offset (px) below the tab bar, e.g. reserved by the full-width header. */ + getHeaderHeight(): number; +} + +/** + * Owns the single-pane "docked detail panel" behaviour: reparenting the auxiliary + * bar into the editor part as an absolutely-positioned overlay on the right (below + * the editor tab strip), sizing it, insetting the editor content, and the draggable + * resize sash. Created by the workbench only in single-pane mode; the standard + * layout never constructs it. + */ +export class DockedAuxiliaryBarController extends Disposable { + + static readonly TOP = 34; + /** Thickness (px) of the header/tab-bar bottom divider the aux bar starts below. */ + static readonly DIVIDER = 1; + static readonly MIN_WIDTH = 220; + static readonly EDITOR_MIN_WIDTH = 300; + static readonly DEFAULT_WIDTH = 300; + static readonly COLLAPSE_WIDTH = 4; + + private _docked = false; + private _sash: Sash | undefined; + private _sashStartWidth = 0; + + constructor( + private readonly editorPartContainer: HTMLElement, + private readonly auxiliaryBarPart: Part, + private readonly host: IDockedAuxiliaryBarHost, + ) { + super(); + } + + /** + * Position the auxiliary bar inside the editor part's right region so the editor + * tab bar spans the full width across the editor content and the detail panel. + */ + layout(): void { + const auxiliaryBarContainer = this.auxiliaryBarPart.getContainer(); + if (!auxiliaryBarContainer) { + return; + } + + // Reparent the auxiliary bar into the editor part once, as an absolutely + // positioned overlay on the right that moves with the editor part. + if (!this._docked) { + this.editorPartContainer.appendChild(auxiliaryBarContainer); + auxiliaryBarContainer.classList.add('docked-auxiliarybar'); + this._docked = true; + } + + if (!this.host.isEditorAreaVisible() || !this.host.isAuxiliaryBarVisible()) { + auxiliaryBarContainer.style.display = 'none'; + this.host.setEditorContentRightInset(0); + if (this._sash) { + this._sash.state = SashState.Disabled; + } + return; + } + + const editorRect = this.editorPartContainer.getBoundingClientRect(); + const editorContentHidden = !this.host.isEditorVisible(); + const auxWidth = editorContentHidden ? editorRect.width : this._auxiliaryBarWidth(this.host.getWidth(), editorRect.width); + const top = DockedAuxiliaryBarController.TOP + DockedAuxiliaryBarController.DIVIDER + this.host.getHeaderHeight(); + const height = Math.max(0, editorRect.height - top); + + auxiliaryBarContainer.style.display = ''; + auxiliaryBarContainer.style.position = 'absolute'; + auxiliaryBarContainer.style.right = '0'; + auxiliaryBarContainer.style.top = `${top}px`; + auxiliaryBarContainer.style.width = `${auxWidth}px`; + auxiliaryBarContainer.style.height = `${height}px`; + + this.host.setEditorContentRightInset(auxWidth); + this.auxiliaryBarPart.layout(auxWidth, height, top, editorRect.width - auxWidth); + + if (editorContentHidden) { + if (this._sash) { + this._sash.state = SashState.Disabled; + } + } else { + this._ensureSash(); + this._sash!.state = SashState.Enabled; + this._sash!.layout(); + } + } + + private _auxiliaryBarWidth(hostWidth: number, editorWidth: number): number { + const maxWidth = editorWidth - DockedAuxiliaryBarController.EDITOR_MIN_WIDTH; + // When the editor is too narrow, the detail panel yields instead of enforcing its minimum. + if (maxWidth < DockedAuxiliaryBarController.MIN_WIDTH) { + return Math.max(0, maxWidth); + } + + return Math.max(DockedAuxiliaryBarController.MIN_WIDTH, Math.min(hostWidth, maxWidth)); + } + + private _ensureSash(): void { + if (this._sash) { + return; + } + + const editorPartContainer = this.editorPartContainer; + const layoutProvider: IVerticalSashLayoutProvider = { + getVerticalSashLeft: () => { + const width = editorPartContainer.clientWidth; + const auxWidth = this._auxiliaryBarWidth(this.host.getWidth(), width); + return Math.max(0, width - auxWidth); + }, + getVerticalSashTop: () => DockedAuxiliaryBarController.TOP + DockedAuxiliaryBarController.DIVIDER + this.host.getHeaderHeight(), + getVerticalSashHeight: () => Math.max(0, editorPartContainer.clientHeight - DockedAuxiliaryBarController.TOP - DockedAuxiliaryBarController.DIVIDER - this.host.getHeaderHeight()), + }; + + const sash = this._register(new Sash(editorPartContainer, layoutProvider, { orientation: SashOrientation.VERTICAL })); + this._sash = sash; + + this._register(sash.onDidStart(() => { + this._sashStartWidth = this.host.getWidth(); + })); + this._register(sash.onDidChange((e: ISashEvent) => { + // Dragging left (currentX < startX) widens the detail panel. + const delta = e.startX - e.currentX; + const width = editorPartContainer.clientWidth; + const requestedWidth = this._sashStartWidth + delta; + if (requestedWidth <= DockedAuxiliaryBarController.COLLAPSE_WIDTH) { + this.host.hideAuxiliaryBar(); + return; + } + this.host.setWidth(this._auxiliaryBarWidth(requestedWidth, width)); + this.layout(); + })); + this._register(sash.onDidReset(() => { + this.host.setWidth(DockedAuxiliaryBarController.DEFAULT_WIDTH); + this.layout(); + })); + } +} diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index 6458fdeffa4504..63d8db16eb4224 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -17,6 +17,7 @@ import { registerIcon } from '../../platform/theme/common/iconRegistry.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, IsWindowAlwaysOnTopContext, SideBarVisibleContext } from '../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/browser/layoutService.js'; import { SessionsWelcomeVisibleContext } from '../common/contextkeys.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../common/sessionConfig.js'; // Register Icons const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); @@ -72,14 +73,16 @@ class ToggleSidebarVisibilityAction extends Action2 { registerAction2(ToggleSidebarVisibilityAction); -// The editor-title secondary side bar toggle reuses the core `workbench.action.toggleAuxiliaryBar` -// command (registered by the workbench auxiliary bar part, which is also loaded in the agents -// window). Two mutually-exclusive menu items give the state-dependent icon without the -// checked/highlighted background that a single `toggled` menu item would render. +// The original (non-single-pane) editor-title secondary side bar toggle reuses the core +// `workbench.action.toggleAuxiliaryBar` command (registered by the workbench auxiliary bar +// part, which is also loaded in the agents window), using two mutually-exclusive items to +// avoid the toggled background. The single-pane "Toggle Details" item is a dedicated command +// registered by `SinglePaneLayoutController`. const editorTitleAuxiliaryBarWhen = ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), IsTopRightEditorGroupContext); +const isSinglePaneDetailPanelDisabled = ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true).negate(); MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { command: { @@ -89,7 +92,7 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { }, group: 'navigation', order: 99.5, - when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext) + when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext, isSinglePaneDetailPanelDisabled) }); MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { @@ -100,9 +103,13 @@ MenuRegistry.appendMenuItem(MenuId.EditorTitleLayout, { }, group: 'navigation', order: 99.5, - when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext.toNegated()) + when: ContextKeyExpr.and(editorTitleAuxiliaryBarWhen, AuxiliaryBarVisibleContext.toNegated(), isSinglePaneDetailPanelDisabled) }); +// The single-pane "Toggle Details" editor-title item is registered by +// `SinglePaneLayoutController` (a dedicated command that toggles +// the detail panel and auto-hides / restores the sessions list in one gesture). + MenuRegistry.appendMenuItem(Menus.PanelTitle, { command: { id: 'workbench.action.closePanel', diff --git a/src/vs/sessions/browser/layoutPolicy.ts b/src/vs/sessions/browser/layoutPolicy.ts index 8a234ab8b6c184..f6ad130ad7ccca 100644 --- a/src/vs/sessions/browser/layoutPolicy.ts +++ b/src/vs/sessions/browser/layoutPolicy.ts @@ -83,6 +83,23 @@ export class SessionsLayoutPolicy extends Disposable { /** Current viewport class derived from the most recent `update()` call. */ readonly viewportClass: IObservable<ViewportClass> = this._viewportClass; + /** + * Whether the agents window uses the single-pane layout (editor spans the + * detail panel as a docked auxiliary bar). Resolved once at startup from the + * setting; toggling requires a window reload. Never active on phone (which + * has its own mobile layout). + */ + private _singlePane = false; + + get isSinglePane(): boolean { + return this._singlePane && this._viewportClass.get() !== 'phone'; + } + + /** Set once at startup (from the redesign setting) before the first layout. */ + setSinglePane(value: boolean): void { + this._singlePane = value; + } + /** `true` when the viewport class is `phone`. */ readonly isPhoneLayout: IObservable<boolean> = derived(this, reader => { return this._viewportClass.read(reader) === 'phone'; diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 75463434c459c7..30c24452fa981a 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -252,6 +252,10 @@ line-height: 22px; } +.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions-container { + gap: 4px; +} + .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus, .agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-visible { outline: none; @@ -262,18 +266,6 @@ box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); } -.agent-sessions-workbench .part.editor .multiDiffEntry .header-content .actions { - opacity: 0; - pointer-events: none; - padding: 0 0 0 8px; -} - -.agent-sessions-workbench .part.editor .multiDiffEntry .header:hover .header-content .actions, -.agent-sessions-workbench .part.editor .multiDiffEntry .header:focus-within .header-content .actions { - opacity: 1; - pointer-events: auto; -} - .agent-sessions-workbench .part.editor .multiDiffEntry .collapse-button a { border-radius: var(--vscode-cornerRadius-small); } @@ -368,6 +360,38 @@ overflow: visible; } +.agent-sessions-workbench .part.editor .tabs-container > .tabs-bar-add-tab { + position: sticky; + right: 0; + z-index: 9; + display: flex; + align-items: center; + background: transparent; +} + +/* Match the "New Chat" (+) button in the session-header chat tab bar: a compact, + * centered, rounded icon button rather than a full-height plain glyph. */ +.agent-sessions-workbench .part.editor .tabs-container > .tabs-bar-add-tab .action-label { + width: 22px; + height: 22px; + margin: 0 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--vscode-cornerRadius-small); + color: var(--chat-tab-inactive-foreground, currentColor); +} + +.agent-sessions-workbench .part.editor .tabs-container > .tabs-bar-add-tab .action-label:not(.disabled):hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--chat-tab-active-foreground, currentColor); +} + +.agent-sessions-workbench .part.editor .tabs-container > .tabs-bar-add-tab .action-label:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + .agent-sessions-workbench .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; align-items: center; @@ -387,22 +411,71 @@ margin-left: 0; } -.agent-sessions-workbench .part.auxiliarybar { - /* - * The right gutter is provided by the workbench grid (see Workbench.layout). - * The bottom margin is 5px when the panel is visible (paired with the panel's - * 5px top margin to center the sash) and 0 when the panel is hidden - * (see `.nopanel` override below) so the card fills its cell. - * - * Default (editor visible): card flushes against the editor with the - * grid sash at the seam. `padding-left: 5px` keeps the inner content - * aligned with the editor-hidden state. - * - * Editor hidden (override below): 5px left gutter against the sash. - * - * The TS `layout()` mirrors this and re-runs on editor visibility - * changes so the inner area is sized correctly. - */ +/* Docked detail panel: the auxiliary bar is absolutely positioned + * inside the editor part (right, below the tab strip). The workbench sizes and + * positions the container directly; paint the card background + a left separator. + * The editor part, Changes editor and aux bar all use the editor background so the + * single pane reads as one uniform surface. */ +.agent-sessions-workbench.dock-detail-panel .monaco-grid-view .part.editor:not(.modal-editor-part) { + /* Become the containing block for the docked aux bar so `overflow: hidden` + * clips it inside the rounded card and the borders below/right stay visible. */ + position: relative; + overflow: hidden; + background: var(--vscode-editor-background); + /* Complete the card: right + bottom border and rounded right corners so the + * editor + docked aux bar read as one bordered card. */ + border-right-width: var(--vscode-strokeThickness); + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar { + --part-background: var(--vscode-editor-background); + --vscode-sideBar-background: var(--vscode-editor-background); + background: var(--vscode-editor-background); + border-left: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); + box-sizing: border-box; + z-index: 2; +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar::before { + content: ''; + position: absolute; + top: calc(-1 * var(--vscode-strokeThickness)); + left: calc(-1 * var(--vscode-strokeThickness)); + width: var(--vscode-strokeThickness); + height: var(--vscode-strokeThickness); + background: var(--vscode-agentsPanel-border); +} + +/* Divider under the editor header (spanning the editor content and docked detail) + * instead of a border on the aux-bar top, so the line runs the full header width. + * The docked aux bar starts one divider below its bottom, so its background does + * not overlay the line. */ +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-header-toolbars { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); +} + +/* When the active editor has no header toolbars, put the divider under the tab + * bar instead so the separator is always present below the header row. */ +.agent-sessions-workbench.dock-detail-panel .part.editor .editor-group-container:not(:has(.editor-group-header-toolbars)) > .title { + border-bottom: var(--vscode-strokeThickness) solid var(--vscode-agentsPanel-border); + box-sizing: border-box; +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar > .content .pane-body, +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar .monaco-list, +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar .monaco-list-rows { + background-color: var(--vscode-editor-background); +} + +.agent-sessions-workbench.dock-detail-panel .part.auxiliarybar.docked-auxiliarybar { + overflow: hidden; +} + +/* Previous (non-docked, original) layout: the auxiliary bar is a trailing grid + * column rendered as its own card. Reproduces the pre-redesign styling. */ +.agent-sessions-workbench:not(.dock-detail-panel) .part.auxiliarybar { margin: 0 0 5px 0; padding-left: 5px; background: var(--part-background); @@ -414,14 +487,14 @@ box-sizing: border-box; } -.agent-sessions-workbench.nomaineditorarea .part.auxiliarybar { +.agent-sessions-workbench:not(.dock-detail-panel).nomaineditorarea .part.auxiliarybar { margin-left: 5px; padding-left: 0; border-top-left-radius: 8px; border-bottom-left-radius: 8px; } -.agent-sessions-workbench.nopanel .part.auxiliarybar, +.agent-sessions-workbench:not(.dock-detail-panel).nopanel .part.auxiliarybar, .agent-sessions-workbench.nopanel .monaco-grid-view .part.editor:not(.modal-editor-part) { margin-bottom: 0; } @@ -504,6 +577,15 @@ background: transparent !important; } +/* Chat list rows inherit the list's `cursor: pointer` (from `.monaco-list.mouse-support`), + * but chat requests/responses are not clickable as a whole row. Because the item container + * is centered with a max-width, the row extends into gutters on either side where the pointer + * cursor would otherwise show even though the item container itself uses `cursor: default`. + * Force the default cursor on the rows so the gutters match the item container. */ +.agent-sessions-workbench .interactive-list > .monaco-list.mouse-support > .monaco-scrollable-element > .monaco-list-rows > .monaco-list-row { + cursor: default; +} + /* Constrain content items to the same max-width, centered. * Scoped to the chat panel (`.part.sessionspart`) and the chat editor host * (`.chat-editor-relative`) so the "card style" doesn't leak into other chat @@ -520,6 +602,16 @@ box-sizing: border-box; } +.agent-sessions-workbench .part.sessionspart .interactive-session .interactive-item-container, +.agent-sessions-workbench .chat-editor-relative .interactive-session .interactive-item-container { + position: relative; +} + +.agent-sessions-workbench .part.sessionspart .interactive-list .monaco-list-row.request > .monaco-tl-row > .monaco-tl-contents, +.agent-sessions-workbench .chat-editor-relative .interactive-list .monaco-list-row.request > .monaco-tl-row > .monaco-tl-contents { + overflow: visible; +} + /* Let request bubbles that contain a code block grow to fit the code editor. */ .agent-sessions-workbench .part.sessionspart .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown:has(.interactive-result-code-block), .agent-sessions-workbench .chat-editor-relative .interactive-session .interactive-item-container.interactive-request .value .rendered-markdown:has(.interactive-result-code-block) { diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index cdf13f32672c96..66f9f6e0987bdb 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -18,6 +18,7 @@ export const Menus = { TitleBarCenterRight: new MenuId('SessionsTitleBarCenterRight'), TitleBarSessionTitle: new MenuId('SessionsTitleBarSessionTitle'), TitleBarSessionMenu: new MenuId('SessionsTitleBarSessionMenu'), + BlockedSessionsHeader: new MenuId('SessionsBlockedSessionsHeader'), TitleBarRightLayout: new MenuId('SessionsTitleBarRightLayout'), MobileTitleBarCenter: new MenuId('SessionsMobileTitleBarCenter'), PanelTitle: new MenuId('SessionsPanelTitle'), @@ -37,7 +38,12 @@ export const Menus = { SessionWorkspaceManage: new MenuId('Sessions.SessionWorkspaceManage'), SessionBarToolbar: new MenuId('SessionsSessionBarToolbar'), SessionConversations: new MenuId('SessionsSessionConversations'), + SessionChatTab: new MenuId('SessionsSessionChatTab'), SessionChatTabBar: new MenuId('SessionsSessionChatTabBar'), + SessionsEditorHeaderPrimary: new MenuId('SessionsEditorHeaderPrimary'), + SessionsEditorHeaderSecondary: new MenuId('SessionsEditorHeaderSecondary'), + SessionsEditorTitle: new MenuId('SessionsEditorTitle'), + SessionsEditorTabsBarContext: new MenuId('SessionsEditorTabsBarContext'), SessionHeaderMeta: new MenuId('SessionsSessionHeaderMeta'), SessionHeaderContext: MenuId.SessionHeaderContext, } as const; diff --git a/src/vs/sessions/browser/openInVSCodeUtils.ts b/src/vs/sessions/browser/openInVSCodeUtils.ts index 6944305ef150e4..02e47dff63d289 100644 --- a/src/vs/sessions/browser/openInVSCodeUtils.ts +++ b/src/vs/sessions/browser/openInVSCodeUtils.ts @@ -7,9 +7,6 @@ import { IRemoteAgentHostService, IRemoteAgentHostSSHConnection, RemoteAgentHost import { ISessionsProvidersService } from '../services/sessions/browser/sessionsProvidersService.js'; import { isAgentHostProvider } from '../common/agentHostSessionsProvider.js'; import { encodeHex, VSBuffer } from '../../base/common/buffer.js'; -import { Schemas } from '../../base/common/network.js'; -import { IProductService } from '../../platform/product/common/productService.js'; -import { URI } from '../../base/common/uri.js'; /** * Resolves the VS Code remote authority for the given session provider, @@ -69,45 +66,3 @@ export function sshAuthorityString(connection: IRemoteAgentHostSSHConnection): s const json = JSON.stringify(obj); return encodeHex(VSBuffer.fromString(json)); } - -export function getVSCodeProtocolScheme(productService: Pick<IProductService, 'quality' | 'urlProtocol'>): string { - switch (productService.quality) { - case 'stable': - return 'vscode'; - case 'exploration': - return 'vscode-exploration'; - case 'insider': - return 'vscode-insiders'; - default: - return productService.urlProtocol; - } -} - -export function getOpenInVSCodeUri(scheme: string, folderUri: URI | undefined, remoteAuthority: string | undefined, sessionResource: URI | undefined): URI { - const params = new URLSearchParams(); - params.set('windowId', '_blank'); - - if (sessionResource) { - params.set('session', sessionResource.toString()); - } - - if (!folderUri) { - return URI.from({ scheme, query: params.toString() }); - } - - if (remoteAuthority) { - return URI.from({ - scheme, - authority: Schemas.vscodeRemote, - path: `/${remoteAuthority}${folderUri.path}`, - query: params.toString(), - }); - } - - return URI.from({ - scheme, - authority: Schemas.file, - path: folderUri.path, - query: params.toString(), - }); -} diff --git a/src/vs/sessions/browser/paneCompositePartService.ts b/src/vs/sessions/browser/paneCompositePartService.ts index 1725d8f31b7d28..9417da04256018 100644 --- a/src/vs/sessions/browser/paneCompositePartService.ts +++ b/src/vs/sessions/browser/paneCompositePartService.ts @@ -23,6 +23,9 @@ import { MobileAuxiliaryBarPart } from './parts/mobile/mobileAuxiliaryBarPart.js import { getClientArea } from '../../base/browser/dom.js'; import { mainWindow } from '../../base/browser/window.js'; import { InstantiationType, registerSingleton } from '../../platform/instantiation/common/extensions.js'; +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { IEditorGroupsService } from '../../workbench/services/editor/common/editorGroupsService.js'; +import { shouldUseSinglePaneLayout, SinglePaneMainEditorPart } from './parts/singlePaneEditorPart.js'; export class AgenticPaneCompositePartService extends Disposable implements IPaneCompositePartService { @@ -37,7 +40,9 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane private readonly paneCompositeParts = new Map<ViewContainerLocation, IPaneCompositePart>(); constructor( - @IInstantiationService instantiationService: IInstantiationService + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @IEditorGroupsService editorGroupsService: IEditorGroupsService, ) { super(); @@ -46,7 +51,13 @@ export class AgenticPaneCompositePartService extends Disposable implements IPane this.registerPart(ViewContainerLocation.Panel, instantiationService.createInstance(isPhoneLayout ? MobilePanelPart : PanelPart)); this.registerPart(ViewContainerLocation.Sidebar, instantiationService.createInstance(isPhoneLayout ? MobileSidebarPart : SidebarPart)); - this.registerPart(ViewContainerLocation.AuxiliaryBar, instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart)); + + // In the single-pane layout the auxiliary bar is owned by (docked inside) + // the editor part; share that instance instead of creating a separate one. + const auxiliaryBarPart = shouldUseSinglePaneLayout(configurationService) + ? (editorGroupsService.mainPart as SinglePaneMainEditorPart).auxiliaryBar + : instantiationService.createInstance(isPhoneLayout ? MobileAuxiliaryBarPart : AuxiliaryBarPart); + this.registerPart(ViewContainerLocation.AuxiliaryBar, auxiliaryBarPart); } private registerPart(location: ViewContainerLocation, part: IPaneCompositePart): void { diff --git a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts index 4d3f6f71bafd5b..18059fcddab229 100644 --- a/src/vs/sessions/browser/parts/auxiliaryBarPart.ts +++ b/src/vs/sessions/browser/parts/auxiliaryBarPart.ts @@ -49,7 +49,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { static readonly placeholderViewContainersKey = 'workbench.agentsession.auxiliarybar.placeholderPanels'; static readonly viewContainersWorkspaceStateKey = 'workbench.agentsession.auxiliarybar.viewContainersWorkspaceState'; - /** Visual margin values for the card-like appearance */ + /** Visual margin values for the card-like appearance (non-docked layout). */ static readonly MARGIN_TOP = 0; static readonly MARGIN_BOTTOM = 5; static readonly MARGIN_LEFT = 5; @@ -169,11 +169,13 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { const container = assertReturnsDefined(this.getContainer()); + const backgroundColor = this.getPartBackgroundColor(); + // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(agentsPanelBackground) || ''); + container.style.setProperty('--part-background', backgroundColor); container.style.setProperty('--part-border-color', this.getColor(agentsPanelBorder) || 'transparent'); container.style.setProperty('--part-foreground', this.getColor(agentsPanelForeground) || ''); - container.style.backgroundColor = this.getColor(agentsPanelBackground) || ''; + container.style.backgroundColor = backgroundColor; // Clear borders - the card appearance uses border-radius instead container.style.borderLeftColor = ''; @@ -184,6 +186,11 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { container.style.borderRightWidth = ''; } + /** The part background color. Overridden by the single-pane variant to match the editor. */ + protected getPartBackgroundColor(): string { + return this.getColor(agentsPanelBackground) || ''; + } + protected getCompositeBarOptions(): IPaneCompositeBarOptions { const $this = this; return { diff --git a/src/vs/sessions/browser/parts/chatCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts index cbcf7ea0fb1a7e..3e1175a196a47d 100644 --- a/src/vs/sessions/browser/parts/chatCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -26,7 +26,7 @@ import { IKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../base/common/keyCodes.js'; import { onUnexpectedError } from '../../../base/common/errors.js'; import { localize } from '../../../nls.js'; -import { ChatOriginKind, IChat, SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, getChatCapabilities, IChat, SessionStatus } from '../../services/sessions/common/session.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../services/sessions/browser/sessionsPartService.js'; @@ -63,6 +63,7 @@ export class ChatCompositeBar extends Disposable { private _editingTab: IChatTab | undefined; private _session: IActiveSession | undefined; private readonly _newChatAction: Action; + private readonly _newChatContainer: HTMLElement; private readonly _actionMenuToolbar: MenuWorkbenchToolBar; private readonly _onDidChangeVisibility = this._register(new Emitter<boolean>()); @@ -133,7 +134,8 @@ export class ChatCompositeBar extends Disposable { )); const newChatActionBar = this._register(new ActionBar(this._tabsRow, { actionViewItemProvider: undefined })); newChatActionBar.push(newChatAction, { icon: true, label: false }); - newChatActionBar.getContainer().classList.add('chat-composite-bar-new-chat'); + this._newChatContainer = newChatActionBar.getContainer(); + this._newChatContainer.classList.add('chat-composite-bar-new-chat'); // Chat tab bar action menu (e.g. the Conversations dropdown) right-aligned // at the end of the strip; items are contributed into Menus.SessionChatTabBar. @@ -197,34 +199,26 @@ export class ChatCompositeBar extends Disposable { return; } - // Tab-strip visibility tracks the number of open chats (including in-composer - // draft chats): it is shown as soon as the session has more than one open - // chat, and hidden again when chats are removed back down to just the main - // chat. The strip's own trailing "New Chat" action follows this visibility. + // Visibility (and the trailing "New Chat") follow session.shouldShowChatTabs, once created. this._setVisible(false); store.add(autorun(reader => { - const openChats = session.openChats.read(reader); const mainChat = session.mainChat.read(reader); const activeChatUri = session.activeChat.read(reader)?.resource.toString() ?? ''; const mainChatUri = mainChat.resource.toString(); - const visibleOpenChats = openChats.filter(chat => chat.origin?.kind !== ChatOriginKind.Tool); - // Keep the provider's order, but move untitled (in-composer) chats - // to the end so a just-completed background chat never jumps last. - // Partition so each chat's status is read exactly once (tracked) and - // relative order is preserved by construction. - const committedOpen: IChat[] = []; - const untitledOpen: IChat[] = []; - for (const chat of visibleOpenChats) { - (chat.status.read(reader) === SessionStatus.Untitled ? untitledOpen : committedOpen).push(chat); - } - const orderedChats = untitledOpen.length === 0 ? visibleOpenChats : [...committedOpen, ...untitledOpen]; - this._rebuildTabs(orderedChats, activeChatUri, mainChatUri); - + const tabs = session.visibleChatTabs.read(reader); + this._rebuildTabs(tabs, activeChatUri, mainChatUri); + + // The trailing "New Chat" action only applies to sessions that support + // user-created peer chats. Subagent (read-only) tabs can surface in + // sessions without that capability, so gate the action on the + // capability rather than on tab-strip visibility. + const supportsMultipleChats = session.capabilities.read(reader).supportsMultipleChats; + this._newChatContainer.classList.toggle('hidden', !supportsMultipleChats); // Archived sessions are read-only, so disable the trailing New Chat // action (mirrors the header action's SessionIsArchivedContext gating). - this._newChatAction.enabled = !session.isArchived.read(reader); + this._newChatAction.enabled = supportsMultipleChats && !session.isArchived.read(reader); - this._setVisible(session.isCreated.read(reader) && visibleOpenChats.length > 1); + this._setVisible(session.isCreated.read(reader) && session.shouldShowChatTabs.read(reader)); })); } @@ -256,12 +250,26 @@ export class ChatCompositeBar extends Disposable { const tab = $('.chat-composite-bar-tab'); tab.tabIndex = 0; tab.setAttribute('role', 'tab'); + // Expose the bound chat resource for diagnostics / test automation. + tab.dataset.chatResource = chat.resource.toString(); + tab.dataset.isMainChat = String(isMainChat); const labelEl = $('.chat-composite-bar-tab-label'); this._tabDisposables.add(autorun(reader => { const title = chat.title.read(reader); labelEl.textContent = title; })); + + // Lock icon shown for read-only (non-interactive) chats. + const lockIcon = $('.chat-composite-bar-tab-lock'); + lockIcon.classList.add(...ThemeIcon.asClassNameArray(Codicon.lock)); + tab.appendChild(lockIcon); + this._tabDisposables.add(autorun(reader => { + const isReadOnly = chat.interactivity.read(reader) === ChatInteractivity.ReadOnly; + tab.classList.toggle('read-only', isReadOnly); + tab.dataset.interactivity = chat.interactivity.read(reader); + })); + tab.appendChild(labelEl); // Empty rename host; an InputBox is created inside it only while editing. @@ -314,33 +322,19 @@ export class ChatCompositeBar extends Disposable { tab.appendChild(indicator); - // Close action — only for non-main chats, always visible. For a committed - // chat, closing hides it from the tab strip (reopenable from the chats - // dropdown in the session header); use Delete to remove it permanently. For - // an untitled (in-composer) draft chat there is nothing to reopen, so the - // action deletes the draft outright (no confirmation) and is labelled - // accordingly so keyboard/screen-reader users know it is destructive. - if (!isMainChat) { - const isDraft = chat.status.get() === SessionStatus.Untitled; - const closeAction = this._tabDisposables.add(new Action( - 'chatCompositeBar.closeChat', - isDraft ? localize('deleteDraftChat', "Delete Chat") : localize('closeChat', "Close"), - ThemeIcon.asClassName(Codicon.close), - true, - async () => { - if (!this._session) { - return; - } - if (chat.status.get() === SessionStatus.Untitled) { - await this._sessionsManagementService.deleteChat(this._session, chat.resource, { skipConfirmation: true }); - } else { - await this._sessionsService.closeChat(this._session, chat); - } - }, - )); - const actionBar = this._tabDisposables.add(new ActionBar(tab, { actionViewItemProvider: undefined })); - actionBar.push(closeAction, { icon: true, label: false }); - actionBar.getContainer().classList.add('chat-composite-bar-tab-actions'); + // Close button — contributed via Menus.SessionChatTab (the chat tab menu). + // Only non-main chats can be closed; the main chat lives and dies with its + // session, so its tab renders no actions toolbar. The tab's chat (and its + // session) is forwarded as the action argument. + if (!isMainChat && session) { + const actionsContainer = $('.chat-composite-bar-tab-actions'); + tab.appendChild(actionsContainer); + const tabToolbar = this._tabDisposables.add(this._instantiationService.createInstance(MenuWorkbenchToolBar, actionsContainer, Menus.SessionChatTab, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + toolbarOptions: { primaryGroup: () => true }, + })); + tabToolbar.context = { session, chat }; } this._tabsContainer.appendChild(tab); @@ -374,7 +368,7 @@ export class ChatCompositeBar extends Disposable { // Double-click the tab to start an inline rename, mirroring the session title. this._tabDisposables.add(addDisposableListener(tab, EventType.DBLCLICK, (e: MouseEvent) => { - if (chat.status.get() === SessionStatus.Untitled) { + if (chat.status.get() === SessionStatus.Untitled || !getChatCapabilities(chat, session, undefined).canRename) { return; } e.preventDefault(); @@ -393,9 +387,17 @@ export class ChatCompositeBar extends Disposable { const event = new StandardMouseEvent(getWindow(tab), e); this._contextMenuService.showContextMenu({ getAnchor: () => event, - getActions: () => isMainChat - ? [renameAction] - : [renameAction, deleteAction] + getActions: () => { + const capabilities = getChatCapabilities(chat, session, undefined); + const actions = []; + if (capabilities.canRename) { + actions.push(renameAction); + } + if (capabilities.canDelete) { + actions.push(deleteAction); + } + return actions; + } }); })); diff --git a/src/vs/sessions/browser/parts/dialogs/mobileAwareDialogHandler.ts b/src/vs/sessions/browser/parts/dialogs/mobileAwareDialogHandler.ts new file mode 100644 index 00000000000000..16260c754d2a86 --- /dev/null +++ b/src/vs/sessions/browser/parts/dialogs/mobileAwareDialogHandler.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import Severity from '../../../../base/common/severity.js'; +import { mnemonicButtonLabel } from '../../../../base/common/labels.js'; +import { localize } from '../../../../nls.js'; +import { AbstractDialogHandler, DialogType, IConfirmation, IConfirmationResult, IInput, IInputResult, IPrompt, IAsyncPromptResult } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { BrowserDialogHandler } from '../../../../workbench/browser/parts/dialogs/dialogHandler.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { isPhoneLayout } from '../mobile/mobileLayout.js'; +import { IMobileDialogSheetButton, showMobileDialogSheet } from './mobileDialogSheet.js'; + +/** + * Dialog handler for the Agents window that renders confirmations and prompts + * as native bottom sheets on phone layout, and otherwise delegates to the + * standard {@link BrowserDialogHandler}. + * + * Because every `IDialogService.confirm` / `prompt` call (sessions-layer or + * inherited from chat / workbench) flows through a single handler, routing it + * here gives every modal a phone-appropriate presentation without converting + * call sites one by one. Text input and the about dialog keep the desktop + * rendering for now. + */ +export class MobileAwareDialogHandler extends AbstractDialogHandler { + + private readonly _desktop: BrowserDialogHandler; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, + ) { + super(); + this._desktop = instantiationService.createInstance(BrowserDialogHandler); + } + + async confirm(confirmation: IConfirmation): Promise<IConfirmationResult> { + if (!isPhoneLayout(this._layoutService)) { + return this._desktop.confirm(confirmation); + } + + const labels = this.getConfirmationButtons(confirmation); // [primary, cancel] + const cancelIndex = labels.length - 1; + const { button, checkboxChecked } = await showMobileDialogSheet(this._layoutService, { + title: this._titleFor(confirmation.type), + message: confirmation.message, + detail: confirmation.detail, + buttons: this._toSheetButtons(labels, cancelIndex), + defaultButtonIndex: cancelIndex, + checkbox: confirmation.checkbox, + }); + + return { confirmed: button === 0, checkboxChecked }; + } + + async prompt<T>(prompt: IPrompt<T>): Promise<IAsyncPromptResult<T>> { + if (!isPhoneLayout(this._layoutService)) { + return this._desktop.prompt(prompt); + } + + const labels = this.getPromptButtons(prompt); + const cancelIndex = prompt.cancelButton ? labels.length - 1 : -1; + const { button, checkboxChecked } = await showMobileDialogSheet(this._layoutService, { + title: this._titleFor(prompt.type), + message: prompt.message, + detail: prompt.detail, + buttons: this._toSheetButtons(labels, cancelIndex), + defaultButtonIndex: cancelIndex >= 0 ? cancelIndex : 0, + checkbox: prompt.checkbox, + }); + + return this.getPromptResult(prompt, button, checkboxChecked); + } + + // Text input and the about dialog keep the desktop rendering for now. + input(input: IInput): Promise<IInputResult> { + return this._desktop.input(input); + } + + about(title: string, details: string, detailsToCopy: string): Promise<void> { + return this._desktop.about(title, details, detailsToCopy); + } + + private _toSheetButtons(labels: string[], cancelIndex: number): IMobileDialogSheetButton[] { + return labels.map((label, index) => ({ + label: mnemonicButtonLabel(label, true), + isCancel: index === cancelIndex, + })); + } + + private _titleFor(type: Severity | DialogType | undefined): string { + switch (this.getDialogType(type)) { + case 'error': return localize('mobileDialog.error', "Error"); + case 'warning': return localize('mobileDialog.warning', "Warning"); + default: return localize('mobileDialog.confirm', "Confirm"); + } + } +} diff --git a/src/vs/sessions/browser/parts/dialogs/mobileDialog.web.contribution.ts b/src/vs/sessions/browser/parts/dialogs/mobileDialog.web.contribution.ts new file mode 100644 index 00000000000000..a244b75f69a9ab --- /dev/null +++ b/src/vs/sessions/browser/parts/dialogs/mobileDialog.web.contribution.ts @@ -0,0 +1,86 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Lazy } from '../../../../base/common/lazy.js'; +import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../../workbench/common/contributions.js'; +import { IDialogsModel, IDialogViewItem } from '../../../../workbench/common/dialogs.js'; +import { createBrowserAboutDialogDetails } from '../../../../workbench/browser/parts/dialogs/dialog.js'; +import { DialogService } from '../../../../workbench/services/dialogs/common/dialogService.js'; +import { MobileAwareDialogHandler } from './mobileAwareDialogHandler.js'; + +/** + * Agents-window variant of the workbench `DialogHandlerContribution` that + * drains the shared {@link IDialogsModel} through a {@link MobileAwareDialogHandler}, + * so every confirmation / prompt renders as a bottom sheet on phone layout. + * + * Registered in place of the standard web dialog-handler contribution (which + * the Agents window does not import), so only one handler drains the model. + */ +export class MobileDialogHandlerContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.mobileDialogHandler'; + + private readonly model: IDialogsModel; + private readonly impl: Lazy<IDialogHandler>; + + private currentDialog: IDialogViewItem | undefined; + + constructor( + @IDialogService private dialogService: IDialogService, + @IInstantiationService instantiationService: IInstantiationService, + @IProductService private productService: IProductService, + ) { + super(); + + this.impl = new Lazy(() => instantiationService.createInstance(MobileAwareDialogHandler)); + this.model = (this.dialogService as DialogService).model; + + this._register(this.model.onWillShowDialog(() => { + if (!this.currentDialog) { + this.processDialogs(); + } + })); + + this.processDialogs(); + } + + private async processDialogs(): Promise<void> { + while (this.model.dialogs.length) { + this.currentDialog = this.model.dialogs[0]; + + let result: IDialogResult | Error | undefined = undefined; + try { + if (this.currentDialog.args.confirmArgs) { + const args = this.currentDialog.args.confirmArgs; + result = await this.impl.value.confirm(args.confirmation); + } else if (this.currentDialog.args.inputArgs) { + const args = this.currentDialog.args.inputArgs; + result = await this.impl.value.input(args.input); + } else if (this.currentDialog.args.promptArgs) { + const args = this.currentDialog.args.promptArgs; + result = await this.impl.value.prompt(args.prompt); + } else { + const aboutDialogDetails = createBrowserAboutDialogDetails(this.productService); + await this.impl.value.about(aboutDialogDetails.title, aboutDialogDetails.details, aboutDialogDetails.detailsToCopy); + } + } catch (error) { + result = error; + } + + this.currentDialog.close(result); + this.currentDialog = undefined; + } + } +} + +registerWorkbenchContribution2( + MobileDialogHandlerContribution.ID, + MobileDialogHandlerContribution, + WorkbenchPhase.BlockStartup // Block to allow for dialogs to show before restore finished +); diff --git a/src/vs/sessions/browser/parts/dialogs/mobileDialogSheet.ts b/src/vs/sessions/browser/parts/dialogs/mobileDialogSheet.ts new file mode 100644 index 00000000000000..b8702dd591bfd4 --- /dev/null +++ b/src/vs/sessions/browser/parts/dialogs/mobileDialogSheet.ts @@ -0,0 +1,133 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, getActiveElement, isHTMLElement } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { showMobileContentSheet } from '../mobile/mobilePickerSheet.js'; + +/** Monotonic seed for unique aria id wiring across concurrent sheets. */ +let idSeed = 0; + +/** A single action button rendered in the dialog sheet. */ +export interface IMobileDialogSheetButton { + /** Display label (already stripped of mnemonics). */ + readonly label: string; + /** When true the button is styled as the secondary / safe action. */ + readonly isCancel: boolean; +} + +export interface IMobileDialogSheetOptions { + /** Sheet header title (single line). Usually a short category label. */ + readonly title: string; + /** Primary message describing the action (wraps). */ + readonly message: string; + /** Optional muted detail line beneath the message (wraps). */ + readonly detail?: string; + /** Action buttons, in the dialog's canonical order (index is returned). */ + readonly buttons: readonly IMobileDialogSheetButton[]; + /** Index of the button to focus on open (typically the safe action). */ + readonly defaultButtonIndex: number; + /** Optional checkbox row (e.g. "Do not ask me again"). */ + readonly checkbox?: { readonly label: string; readonly checked?: boolean }; +} + +export interface IMobileDialogSheetResult { + /** Index of the chosen button, or the cancel index when dismissed. */ + readonly button: number; + readonly checkboxChecked?: boolean; +} + +/** + * Render a generic confirmation / prompt as a native mobile bottom sheet. + * + * Maps the workbench dialog model (message + detail + ordered buttons + + * optional checkbox) onto {@link showMobileContentSheet}: full-width stacked + * buttons with 44px tap targets, the safe action styled secondary. Tapping a + * button resolves with its index; dismissing the sheet (backdrop / Escape) + * resolves with the cancel index (or -1 when the dialog has no cancel). + * + * Focus is moved into the sheet on open (defaulting to the safe action) and + * restored to the previously focused element on close, mirroring the desktop + * dialog's behavior. + */ +export async function showMobileDialogSheet(layoutService: IWorkbenchLayoutService, options: IMobileDialogSheetOptions): Promise<IMobileDialogSheetResult> { + const cancelIndex = options.buttons.findIndex(button => button.isCancel); + + // Tracked across the whole interaction so both the dismiss path + // (backdrop / Escape) and a button tap return the live values, not a + // stale snapshot taken at click time. + let chosenButton = cancelIndex; + let checkboxChecked = options.checkbox?.checked; + + const previouslyFocused = getActiveElement(); + + await showMobileContentSheet( + layoutService.mainContainer, + options.title, + (body, api) => { + const store = new DisposableStore(); + + const id = `mobile-dialog-${++idSeed}`; + const describedBy: string[] = []; + + const message = append(body, $('.mobile-content-sheet-message')); + message.id = `${id}-message`; + message.textContent = options.message; + describedBy.push(message.id); + + if (options.detail) { + const detail = append(body, $('.mobile-content-sheet-detail')); + detail.id = `${id}-detail`; + detail.textContent = options.detail; + describedBy.push(detail.id); + } + + // Associate the message / detail with the sheet's dialog element so + // screen readers announce them when the sheet opens (the shell only + // sets an aria-label from the title). + body.closest('[role="dialog"]')?.setAttribute('aria-describedby', describedBy.join(' ')); + + if (options.checkbox) { + const row = append(body, $('label.mobile-content-sheet-checkbox')); + const checkbox = append(row, $('input')) as HTMLInputElement; + checkbox.type = 'checkbox'; + checkbox.checked = !!options.checkbox.checked; + append(row, $('span')).textContent = options.checkbox.label; + store.add(addDisposableListener(checkbox, 'change', () => { checkboxChecked = checkbox.checked; })); + } + + const actions = append(body, $('.mobile-content-sheet-actions')); + + let buttonToFocus: Button | undefined; + options.buttons.forEach((descriptor, index) => { + const button = store.add(new Button(actions, { ...defaultButtonStyles, secondary: descriptor.isCancel })); + button.label = descriptor.label; + store.add(button.onDidClick(() => { + chosenButton = index; + api.close(); + })); + if (index === options.defaultButtonIndex) { + buttonToFocus = button; + } + }); + + // Move focus into the modal sheet for keyboard / screen reader users. + buttonToFocus?.focus(); + + return store; + }, + { hideDoneButton: true }, + ); + + if (isHTMLElement(previouslyFocused) && previouslyFocused.isConnected) { + previouslyFocused.focus(); + } + + return { button: chosenButton, checkboxChecked }; +} + diff --git a/src/vs/sessions/browser/parts/editorPart.ts b/src/vs/sessions/browser/parts/editorPart.ts index ec003bdd3b6ef9..e330d92a9d647b 100644 --- a/src/vs/sessions/browser/parts/editorPart.ts +++ b/src/vs/sessions/browser/parts/editorPart.ts @@ -7,6 +7,7 @@ import { LayoutPriority } from '../../../base/browser/ui/splitview/splitview.js' import { mainWindow } from '../../../base/browser/window.js'; import { MainEditorPart as MainEditorPartBase } from '../../../workbench/browser/parts/editor/editorPart.js'; import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import type { IAgentWorkbenchLayoutService } from '../workbench.js'; export class MainEditorPart extends MainEditorPartBase { static readonly MARGIN_TOP = 0; @@ -25,7 +26,10 @@ export class MainEditorPart extends MainEditorPartBase { override priority = LayoutPriority.Normal; override layout(width: number, height: number, top: number, left: number): void { - if (!this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + const agentLayoutService = this.layoutService as IAgentWorkbenchLayoutService; + const keepForDockedTabBar = agentLayoutService.isSinglePaneLayoutEnabled + && this.layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (!this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && !keepForDockedTabBar) { return; } @@ -44,5 +48,9 @@ export class MainEditorPart extends MainEditorPartBase { const adjustedHeight = height - MainEditorPart.MARGIN_TOP - marginBottom - 2 /* border width */; super.layout(adjustedWidth, adjustedHeight, top, left); + + if (agentLayoutService.isSinglePaneLayoutEnabled && !this.layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + agentLayoutService.handleDockedEditorPartLayout(width); + } } } diff --git a/src/vs/sessions/browser/parts/editorParts.ts b/src/vs/sessions/browser/parts/editorParts.ts index 709e4d19be42b4..406309f2cc5adb 100644 --- a/src/vs/sessions/browser/parts/editorParts.ts +++ b/src/vs/sessions/browser/parts/editorParts.ts @@ -4,13 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import './media/editorPart.css'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; import { InstantiationType, registerSingleton } from '../../../platform/instantiation/common/extensions.js'; import { EditorParts as EditorPartsBase } from '../../../workbench/browser/parts/editor/editorParts.js'; import { IEditorGroupsService } from '../../../workbench/services/editor/common/editorGroupsService.js'; import { MainEditorPart } from './editorPart.js'; +import { shouldUseSinglePaneLayout, SinglePaneMainEditorPart } from './singlePaneEditorPart.js'; export class EditorParts extends EditorPartsBase { protected override createMainEditorPart(): MainEditorPart { + const configurationService = this.instantiationService.invokeFunction(accessor => accessor.get(IConfigurationService)); + if (shouldUseSinglePaneLayout(configurationService)) { + return this.instantiationService.createInstance(SinglePaneMainEditorPart, this); + } return this.instantiationService.createInstance(MainEditorPart, this); } } diff --git a/src/vs/sessions/browser/parts/media/chatCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css index 4ec39d6a6b8afc..4ed9e7a4aa972e 100644 --- a/src/vs/sessions/browser/parts/media/chatCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -269,6 +269,10 @@ margin-left: 4px; } +.chat-composite-bar-tabs-row > .chat-composite-bar-new-chat.hidden { + display: none; +} + .chat-composite-bar-new-chat .action-item .action-label { width: 22px; height: 22px; @@ -374,6 +378,23 @@ min-width: 0; } +/* Lock icon for read-only (non-interactive) chats, shown before the label. + The element carries the `codicon` class, whose base rule + `.codicon[class*='codicon-'] { display: inline-block }` has specificity (0,2,0), + so these hide/show rules are scoped to the tab to win on specificity — + otherwise the lock would show on every tab regardless of read-only state. */ +.chat-composite-bar-tab .chat-composite-bar-tab-lock.codicon { + display: none; + flex-shrink: 0; + align-items: center; + margin-right: 4px; + font-size: var(--vscode-codiconFontSize-compact, 12px); +} + +.chat-composite-bar-tab.read-only .chat-composite-bar-tab-lock.codicon { + display: inline-flex; +} + /* While editing, the tab expands to its max width. */ .chat-composite-bar-tab.editing { cursor: default; @@ -384,6 +405,7 @@ /* Hide the label and adornments so the input fills the tab while editing. */ .session-chat-tabs-bar .chat-composite-bar-tab.editing .chat-composite-bar-tab-label, +.session-chat-tabs-bar .chat-composite-bar-tab.editing .chat-composite-bar-tab-lock, .session-chat-tabs-bar .chat-composite-bar-tab.editing .chat-composite-bar-tab-indicator, .session-chat-tabs-bar .chat-composite-bar-tab.editing .chat-composite-bar-tab-actions { display: none; diff --git a/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css new file mode 100644 index 00000000000000..cdaea7b4b448a4 --- /dev/null +++ b/src/vs/sessions/browser/parts/media/sessionReadOnlyBanner.css @@ -0,0 +1,50 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* A small status banner shown flush below the tab bar (within the same centered + * band) when the session's active chat is read-only. Stands in for the composer, + * which is hidden for read-only chats. Kept within the centered band (aligned + * with the header/tab bar and the centered transcript) with a subtle distinct + * background so it reads as a banner. */ +.session-readonly-banner { + display: flex; + align-items: center; + justify-content: center; + gap: var(--vscode-spacing-size40, 4px); + box-sizing: border-box; + /* Match the VS Code editor read-only banner: 26px tall, 12px text. */ + height: 26px; + /* Inset horizontally to align with the tab bar's content (its `padding: 0 + 10px`), and pull up slightly so only a small gap remains under the tab strip. */ + margin: calc(-1 * var(--vscode-spacing-size20, 2px)) var(--vscode-spacing-size100, 10px) 0; + padding: 0 var(--vscode-spacing-size100, 10px); + background-color: color-mix(in srgb, var(--vscode-focusBorder) 4%, var(--vscode-editorWidget-background)); + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label1, 12px); + font-family: var(--vscode-chat-font-family, inherit); +} + +.session-readonly-banner.hidden { + display: none; +} + +/* Match the editor read-only lock: standard codicon size, muted icon color. */ +.session-readonly-banner .session-readonly-banner-icon { + flex-shrink: 0; + display: flex; + align-items: center; + color: var(--vscode-icon-foreground); +} + +.session-readonly-banner .session-readonly-banner-icon .codicon { + font-size: var(--vscode-codiconFontSize, 16px); +} + +.session-readonly-banner .session-readonly-banner-text { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/src/vs/sessions/browser/parts/menubar.contribution.ts b/src/vs/sessions/browser/parts/menubar.contribution.ts index 5f08335198fd08..487891753728e1 100644 --- a/src/vs/sessions/browser/parts/menubar.contribution.ts +++ b/src/vs/sessions/browser/parts/menubar.contribution.ts @@ -28,6 +28,16 @@ MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { order: 2 }); +MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { + submenu: MenuId.MenubarSelectionMenu, + title: { + value: 'Selection', + original: 'Selection', + mnemonicTitle: localize({ key: 'mSelection', comment: ['&& denotes a mnemonic'] }, "&&Selection") + }, + order: 3 +}); + MenuRegistry.appendMenuItem(MenuId.MenubarMainMenu, { submenu: MenuId.MenubarViewMenu, title: { diff --git a/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css b/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css index 7c66f51eb94628..36e6e373c1eb19 100644 --- a/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css +++ b/src/vs/sessions/browser/parts/mobile/media/mobilePickerSheet.css @@ -236,6 +236,97 @@ padding: 4px 8px 12px; } +/* Static items (e.g. recents) wrapper. `display: contents` so the + * wrapper itself adds no box; the search flow toggles it to + * `display: none` while a query is active to hide recents. */ +.mobile-picker-sheet-static { + display: contents; +} + +/* Pinned primary action region between the search input and the list. + * Holds a single prominent confirm button that stays put as the list + * scrolls. Hidden (via inline `display: none`) when no action is set. */ +.mobile-picker-sheet-pinned { + flex-shrink: 0; + padding: 0 8px 4px; +} + +/* Filled primary action button — the single confirm affordance (e.g. + * "Open this folder"). Uses the workbench button tokens so it reads as + * the primary call to action and themes correctly. */ +.mobile-picker-sheet-primary-action { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-height: 56px; + padding: 10px 12px; + margin: 2px 0; + border: none; + border-radius: var(--vscode-cornerRadius-xLarge); + /* Emphasized but lightweight: a low-opacity `textLink-foreground` tint + * rather than a saturated filled button, so the action reads as the + * primary choice while staying consistent with the muted rows below. */ + background: color-mix(in srgb, var(--vscode-textLink-foreground) 14%, transparent); + color: var(--vscode-textLink-foreground); + font-family: inherit; + font-size: 16px; + text-align: left; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.mobile-picker-sheet-primary-action:focus { + outline: none; +} + +.mobile-picker-sheet-primary-action:focus-visible { + outline: 2px solid var(--vscode-focusBorder); + outline-offset: -2px; +} + +.mobile-picker-sheet-primary-action:active { + background: color-mix(in srgb, var(--vscode-textLink-foreground) 22%, transparent); +} + +.mobile-picker-sheet-primary-action-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--vscode-cornerRadius-large); + /* Accent-tinted tile with an accent glyph: emphasized without the + * glare of a solid white tile, and it fills the 36px slot so the icon + * column lines up with the folder rows below. */ + background: color-mix(in srgb, var(--vscode-textLink-foreground) 22%, transparent); + color: var(--vscode-textLink-foreground); + flex-shrink: 0; +} + +.mobile-picker-sheet-primary-action-icon-glyph { + font-size: 16px; + line-height: 1; +} + +.mobile-picker-sheet-primary-action-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.mobile-picker-sheet-primary-action-label { + font-size: 16px; + font-weight: 600; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* Body container for the shell-only `showMobileContentSheet` variant. * Where the picker sheet builds its own scrollable list of rows * (`.mobile-picker-sheet-list`) inside the sheet flex column, the @@ -251,6 +342,58 @@ touch-action: pan-y; } +/* Generic building blocks for `showMobileContentSheet` bodies that present a + * short confirmation: a bold message, an optional muted detail line, and a + * vertical stack of full-width action buttons with comfortable (44pt) tap + * targets. Kept caller-agnostic so any confirm-style sheet can share them. */ +.mobile-content-sheet-message { + padding: 4px 16px 0; + font-size: 15px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.mobile-content-sheet-detail { + padding: 6px 16px 0; + font-size: 13px; + color: var(--vscode-descriptionForeground); + line-height: 1.35; + overflow-wrap: anywhere; +} + +.mobile-content-sheet-actions { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; +} + +.mobile-content-sheet-actions .monaco-button { + min-height: 44px; + font-size: 16px; + padding: 0 12px; +} + +/* Optional checkbox row (e.g. "Do not ask me again") for confirm-style + * content sheets. Comfortable tap target with the control and label inline. */ +.mobile-content-sheet-checkbox { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 16px 0; + min-height: 44px; + font-size: 13px; + cursor: pointer; +} + +.mobile-content-sheet-checkbox input { + width: 20px; + height: 20px; + margin: 0; + flex-shrink: 0; +} + /* iOS grouped-list section header. The HIG uses a regular-case * footnote-sized label in secondary color — not the uppercased small * caps that older Settings rows used. */ @@ -383,6 +526,24 @@ line-height: 1; } +/* Trailing chevron for navigational rows (tap drills deeper). Muted so + * it reads as a navigation affordance, not a selection state. */ +.mobile-picker-sheet-chevron { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: var(--vscode-descriptionForeground); + flex-shrink: 0; + opacity: 0.8; +} + +.mobile-picker-sheet-chevron-glyph { + font-size: 16px; + line-height: 1; +} + @keyframes mobile-picker-sheet-slide-up { from { transform: translateY(100%); diff --git a/src/vs/sessions/browser/parts/mobile/mobilePickerSheet.ts b/src/vs/sessions/browser/parts/mobile/mobilePickerSheet.ts index 502795cd6c92ca..58bd7dc367f0a4 100644 --- a/src/vs/sessions/browser/parts/mobile/mobilePickerSheet.ts +++ b/src/vs/sessions/browser/parts/mobile/mobilePickerSheet.ts @@ -28,6 +28,13 @@ export interface IMobilePickerSheetItem { readonly icon?: ThemeIcon; readonly checked?: boolean; readonly disabled?: boolean; + /** + * When true, the row is rendered with a trailing chevron to signal + * that tapping it navigates deeper (drill-down) rather than selecting + * a final value. Navigational rows never show the radio checkmark and + * are excluded from the in-section radio toggle. + */ + readonly navigates?: boolean; /** * Optional section title shown above this row. When set, a divider is * inserted above the row (for sections after the first) and the title @@ -82,17 +89,39 @@ export interface IMobilePickerSheetOptions { /** * Called when a row is tapped and {@link stayOpenOnSelect} is true. * Callers should write-through the selection (e.g. - * `provider.setSessionConfigValue`) and optionally update the - * sheet's visual state via {@link IMobilePickerSheetController}. + * `provider.setSessionConfigValue`). * * If the callback returns a string, that string is injected into * the search input and a new query is triggered — use this for * drill-down navigation where tapping a folder replaces the query * with `folderName/` to list its children. * + * If the callback returns {@link MOBILE_PICKER_SHEET_CONFIRM}, the + * tapped row is treated as confirmed: the sheet resolves with that + * row's id and closes immediately. + * * Ignored when `stayOpenOnSelect` is false. */ - readonly onDidSelect?: (id: string) => string | void; + readonly onDidSelect?: (id: string) => string | typeof MOBILE_PICKER_SHEET_CONFIRM | void; + + /** + * Optional override for the dismiss button label (defaults to "Done"). + * Use "Cancel" for sheets where rows self-confirm on tap and the + * header button is purely a dismiss affordance. + */ + readonly doneLabel?: string; +} + +/** + * A pinned, single primary action rendered above the scrollable list. + * Tapping it runs {@link run} and then closes the sheet. Produced by + * {@link IMobilePickerSheetSearchSource.getPrimaryAction} for the current + * query. + */ +export interface IMobilePickerSheetPrimaryAction { + readonly label: string; + readonly icon?: ThemeIcon; + readonly run: () => void; } export interface IMobilePickerSheetHeaderAction { @@ -119,6 +148,13 @@ export interface IMobilePickerSheetSearchSource { readonly emptyMessage?: string; /** Loads the result rows for the given query. */ loadItems(query: string, token: CancellationToken): Promise<readonly IMobilePickerSheetItem[]>; + /** + * Optional pinned primary action for the given query, rendered above + * the result list. Return `undefined` to hide it. Called on every + * query change, so the action can track the browse state (e.g. an + * "Open this folder" button that follows the folder being browsed). + */ + getPrimaryAction?(query: string): IMobilePickerSheetPrimaryAction | undefined; } /** @@ -175,6 +211,11 @@ export interface IMobileContentSheetOptions { */ export const MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX = 'headerAction:'; +/** + * Return from `onDidSelect` to confirm the tapped row and close the sheet while `stayOpenOnSelect` is enabled. + */ +export const MOBILE_PICKER_SHEET_CONFIRM = Symbol('mobilePickerSheetConfirm'); + /** * Show a phone-friendly bottom sheet for picker-style choices. * @@ -209,6 +250,7 @@ export function showMobilePickerSheet( const shell: IMobileSheetShell = buildMobileSheetShell(workbenchContainer, title, { caption: options?.caption, headerActions: options?.headerActions, + doneLabel: options?.doneLabel, onDismiss: () => finish(undefined), onHeaderAction: actionId => finish(`${MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX}${actionId}`), }); @@ -229,6 +271,40 @@ export function showMobilePickerSheet( searchInput.setAttribute('aria-label', options.search.ariaLabel ?? options.search.placeholder); } + // -- Pinned primary action ------------------------------------- + // Optional single, prominent confirm action that sits above the + // scrollable list and stays put as the list scrolls. Refreshed + // from `search.getPrimaryAction(query)` on every query change; + // tapping it runs the action and closes the sheet. + const pinnedContainer = DOM.append(sheet, $('div.mobile-picker-sheet-pinned')); + pinnedContainer.style.display = 'none'; + const pinnedStore = disposables.add(new DisposableStore()); + const setPrimaryAction = (action: IMobilePickerSheetPrimaryAction | undefined) => { + pinnedStore.clear(); + DOM.clearNode(pinnedContainer); + if (!action) { + pinnedContainer.style.display = 'none'; + return; + } + pinnedContainer.style.display = ''; + const btn = DOM.append(pinnedContainer, $('button.mobile-picker-sheet-primary-action', { type: 'button' })) as HTMLButtonElement; + btn.setAttribute('aria-label', action.label); + if (action.icon) { + const iconSlot = DOM.append(btn, $('span.mobile-picker-sheet-primary-action-icon')); + const iconGlyph = DOM.append(iconSlot, $('span.mobile-picker-sheet-primary-action-icon-glyph')); + iconGlyph.classList.add(...ThemeIcon.asClassNameArray(action.icon)); + } + const textCol = DOM.append(btn, $('span.mobile-picker-sheet-primary-action-text')); + DOM.append(textCol, $('span.mobile-picker-sheet-primary-action-label')).textContent = action.label; + // Plain `click` only (no Gesture/Tap) so the action runs + // exactly once on touch-tap; `run` is not idempotent. + pinnedStore.add(DOM.addDisposableListener(btn, DOM.EventType.CLICK, (e: MouseEvent) => { + e.preventDefault(); + action.run(); + finish(undefined); + })); + }; + // -- Items list ------------------------------------------------ const list = DOM.append(sheet, $('div.mobile-picker-sheet-list')); list.setAttribute('role', 'list'); @@ -242,7 +318,7 @@ export function showMobilePickerSheet( // Registry of rendered rows keyed by section index, used to // toggle checkmarks within a section on tap. - const rowsBySection = new Map<number, { row: HTMLButtonElement; checkSlot: HTMLElement; id: string }[]>(); + const rowsBySection = new Map<number, IMobilePickerSheetRowRef[]>(); // Mutable reference so handleRowTap can trigger a search-query // update when onDidSelect returns a drill-down string. Populated @@ -252,10 +328,15 @@ export function showMobilePickerSheet( const handleRowTap = options?.stayOpenOnSelect && options.onDidSelect ? (id: string, _row: HTMLElement, sectionIndex: number) => { // Update visual: uncheck all rows in the same section, - // then check the tapped row. + // then check the tapped row. Skipped for navigational + // rows (drill-down) — they don't carry a radio checkmark. const sectionRows = rowsBySection.get(sectionIndex); - if (sectionRows) { + const targetEntry = sectionRows?.find(entry => entry.id === id); + if (sectionRows && !targetEntry?.navigates) { for (const entry of sectionRows) { + if (!entry.checkSlot) { + continue; + } const isTarget = entry.id === id; entry.row.classList.toggle('checked', isTarget); entry.row.setAttribute('aria-current', isTarget ? 'true' : 'false'); @@ -266,17 +347,23 @@ export function showMobilePickerSheet( } } } - const drillDown = options.onDidSelect!(id); - if (typeof drillDown === 'string' && searchInput && setSearchQuery) { - searchInput.value = drillDown; - setSearchQuery(drillDown); + const selectResult = options.onDidSelect!(id); + if (selectResult === MOBILE_PICKER_SHEET_CONFIRM) { + finish(id); + } else if (typeof selectResult === 'string' && searchInput && setSearchQuery) { + searchInput.value = selectResult; + setSearchQuery(selectResult); } } : (id: string, _row: HTMLElement, _sectionIndex: number) => { finish(id); }; + // Static items live in their own container so the search flow can + // hide them while the user is browsing/searching (a non-empty + // query), keeping recents from cluttering search results. + const staticContainer = DOM.append(list, $('div.mobile-picker-sheet-static')); const renderState: IRenderState = { firstRow: undefined, firstCheckedRow: undefined, sectionCount: 0 }; for (const item of items) { - renderRow(list, item, renderState, handleRowTap, disposables, rowsBySection); + renderRow(staticContainer, item, renderState, handleRowTap, disposables, rowsBySection); } // -- Dynamic search results ----------------------------------- @@ -289,6 +376,18 @@ export function showMobilePickerSheet( const resultsContainer = DOM.append(list, $('div.mobile-picker-sheet-search-results')); let currentQueryTokens: CancellationTokenSource | undefined; let debounceTimer: ReturnType<typeof setTimeout> | undefined; + const searchSectionBase = renderState.sectionCount + 1; + const pruneSearchRows = () => { + for (const key of [...rowsBySection.keys()]) { + if (key >= searchSectionBase) { + rowsBySection.delete(key); + } + } + }; + // Row listeners for search results are scoped here (not to the + // sheet-lifetime store) and cleared on each re-render so we + // don't accumulate handlers bound to detached rows. + const searchRowsStore = disposables.add(new DisposableStore()); const cancelInflight = () => { currentQueryTokens?.cancel(); @@ -303,9 +402,15 @@ export function showMobilePickerSheet( const renderResults = async (query: string): Promise<void> => { cancelInflight(); + // Recents (static items) are only relevant at the root. + // Once the user types or drills into a path, hide them so + // the list shows just the search results. + staticContainer.style.display = query ? 'none' : ''; const tokens = new CancellationTokenSource(); currentQueryTokens = tokens; DOM.clearNode(resultsContainer); + pruneSearchRows(); + searchRowsStore.clear(); const status = DOM.append(resultsContainer, $('div.mobile-picker-sheet-search-status')); status.textContent = localize('mobilePickerSheet.searching', "Searching…"); @@ -318,9 +423,13 @@ export function showMobilePickerSheet( if (tokens.token.isCancellationRequested || resolved) { return; } + // Refresh the pinned primary action for the live query + // (only after the cancellation check so stale queries + // don't leave a mismatched action behind). + setPrimaryAction(search.getPrimaryAction?.(query)); DOM.clearNode(resultsContainer); - const localState: IRenderState = { firstRow: undefined, firstCheckedRow: undefined, sectionCount: 0 }; + const localState: IRenderState = { firstRow: undefined, firstCheckedRow: undefined, sectionCount: searchSectionBase }; if (search.resultsSectionTitle) { const sectionTitle = DOM.append(resultsContainer, $('div.mobile-picker-sheet-section-title')); sectionTitle.textContent = search.resultsSectionTitle; @@ -331,7 +440,7 @@ export function showMobilePickerSheet( return; } for (const item of results) { - renderRow(resultsContainer, item, localState, handleRowTap, disposables, rowsBySection); + renderRow(resultsContainer, item, localState, handleRowTap, searchRowsStore, rowsBySection); } }; @@ -650,6 +759,18 @@ interface IRenderState { sectionCount: number; } +/** + * A rendered row registered per section so `stayOpenOnSelect` mode can + * toggle the radio checkmark within a section on tap. Navigational rows + * have no {@link checkSlot} and are skipped by the toggle. + */ +interface IMobilePickerSheetRowRef { + readonly row: HTMLButtonElement; + readonly checkSlot?: HTMLElement; + readonly id: string; + readonly navigates?: boolean; +} + /** * Append a single picker row (and any preceding section divider) to the * given list element. Wires up touch/click handlers so taps invoke @@ -662,7 +783,7 @@ function renderRow( state: IRenderState, onTap: (id: string, row: HTMLButtonElement, sectionIndex: number) => void, disposables: DisposableStore, - rowsBySection?: Map<number, { row: HTMLButtonElement; checkSlot: HTMLElement; id: string }[]>, + rowsBySection?: Map<number, IMobilePickerSheetRowRef[]>, ): void { if (item.sectionTitle !== undefined) { if (state.sectionCount > 0) { @@ -708,23 +829,32 @@ function renderRow( DOM.append(textCol, $('span.mobile-picker-sheet-description')).textContent = item.description; } - // Trailing checkmark for the currently-selected row. Same child-span - // pattern as the icon slot so flex centering wins over codicon's - // `display: inline-block`. - const checkSlot = DOM.append(row, $('span.mobile-picker-sheet-check')); - if (item.checked) { - const checkGlyph = DOM.append(checkSlot, $('span.mobile-picker-sheet-check-glyph')); - checkGlyph.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + // Trailing affordance. Navigational rows show a chevron (tap drills + // deeper); selectable rows show a checkmark when active. Same + // child-span pattern as the icon slot so flex centering wins over + // codicon's `display: inline-block`. + let checkSlot: HTMLElement | undefined; + if (item.navigates && !item.checked) { + const chevronSlot = DOM.append(row, $('span.mobile-picker-sheet-chevron')); + const chevronGlyph = DOM.append(chevronSlot, $('span.mobile-picker-sheet-chevron-glyph')); + chevronGlyph.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRight)); + } else { + checkSlot = DOM.append(row, $('span.mobile-picker-sheet-check')); + if (item.checked) { + const checkGlyph = DOM.append(checkSlot, $('span.mobile-picker-sheet-check-glyph')); + checkGlyph.classList.add(...ThemeIcon.asClassNameArray(Codicon.check)); + } } // Register this row so `stayOpenOnSelect` mode can toggle // checkmarks within the same section on tap. if (rowsBySection) { + const entry: IMobilePickerSheetRowRef = { row, checkSlot, id: item.id, navigates: item.navigates }; const sectionRows = rowsBySection.get(state.sectionCount); if (sectionRows) { - sectionRows.push({ row, checkSlot, id: item.id }); + sectionRows.push(entry); } else { - rowsBySection.set(state.sectionCount, [{ row, checkSlot, id: item.id }]); + rowsBySection.set(state.sectionCount, [entry]); } } diff --git a/src/vs/sessions/browser/parts/sessionHeader.ts b/src/vs/sessions/browser/parts/sessionHeader.ts index b7f54c5334cfa5..d09f6410b37c8e 100644 --- a/src/vs/sessions/browser/parts/sessionHeader.ts +++ b/src/vs/sessions/browser/parts/sessionHeader.ts @@ -16,6 +16,7 @@ import { localize } from '../../../nls.js'; import { IActiveSession, ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; import { ISessionsListModelService } from '../../services/sessions/browser/sessionsListModelService.js'; import { ISessionsService } from '../../services/sessions/browser/sessionsService.js'; +import { getUntitledSessionTitle } from '../../services/sessions/common/session.js'; import { ActionRunner, IAction } from '../../../base/common/actions.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../platform/actions/browser/toolbar.js'; @@ -327,8 +328,9 @@ export class SessionHeader extends Disposable { const isArchived = session.isArchived.read(reader); this._statusIcon.setStatus(status, isRead, isArchived); - // Session title - this._titleTextEl.textContent = session.title.read(reader) || localize('agentSessions.newSession', "New Session"); + // Session title — quick chats use "New Chat" as the untitled fallback. + const isQuickChat = session.isQuickChat?.read(reader) ?? false; + this._titleTextEl.textContent = session.title.read(reader) || getUntitledSessionTitle(isQuickChat); this._titleEl.classList.toggle('editable', this._isTitleEditable()); // Meta row: contributed action pills (workspace folder · diff stats · pull request). @@ -359,7 +361,7 @@ export class SessionHeader extends Disposable { * signal that gates the `Rename...` context menu action in the sessions list. */ private _isTitleEditable(): boolean { - return !!this._session && (this._session.capabilities.supportsRename ?? false); + return !!this._session && (this._session.capabilities.get().supportsRename ?? false); } startTitleEditing(): void { @@ -381,11 +383,10 @@ export class SessionHeader extends Disposable { } const initialTitle = session.title.get(); - // When the stored title is empty the header shows a localized fallback - // ("New Session"). Reflect that as a placeholder rather than seeding the - // input with it, so the user neither sees a blank field nor accidentally - // commits the fallback string. - const fallbackTitle = localize('agentSessions.newSession', "New Session"); + // When the stored title is empty the header shows a localized fallback. + // Reflect that as a placeholder rather than seeding the input with it, so + // the user neither sees a blank field nor accidentally commits the fallback. + const fallbackTitle = getUntitledSessionTitle(session.isQuickChat?.get() ?? false); const input = document.createElement('input'); input.type = 'text'; diff --git a/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts new file mode 100644 index 00000000000000..7e7dce15ad4640 --- /dev/null +++ b/src/vs/sessions/browser/parts/sessionReadOnlyBanner.ts @@ -0,0 +1,57 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionReadOnlyBanner.css'; +import * as dom from '../../../base/browser/dom.js'; +import { renderIcon } from '../../../base/browser/ui/iconLabel/iconLabels.js'; +import { Codicon } from '../../../base/common/codicons.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { localize } from '../../../nls.js'; + +/** + * A small, self-contained status banner that indicates the current chat is + * read-only (non-interactive). Mirrors the read-only editor banner in VS Code: + * a subtle full-width bar with a leading icon and a single line of text. Shown + * in place of the composer for read-only chats (e.g. a subagent's transcript), + * where it explains why there is no input. + * + * Purely presentational: visibility is driven by the owning chat view via + * {@link setVisible}. + */ +export class SessionReadOnlyBanner extends Disposable { + + readonly domNode: HTMLElement; + + private _visible = false; + + constructor() { + super(); + + this.domNode = dom.$('.session-readonly-banner'); + // A `role="status"` live region is announced from its text content, so no + // `aria-label` is needed (setting one to the same string would just + // override the accessible name without changing the announcement). + this.domNode.setAttribute('role', 'status'); + + const message = localize('sessionReadOnlyBanner.message', "This chat is read-only"); + + const icon = dom.append(this.domNode, dom.$('.session-readonly-banner-icon')); + icon.appendChild(renderIcon(Codicon.lock)); + + const text = dom.append(this.domNode, dom.$('span.session-readonly-banner-text')); + text.textContent = message; + + this.setVisible(false); + } + + get visible(): boolean { + return this._visible; + } + + setVisible(visible: boolean): void { + this._visible = visible; + this.domNode.classList.toggle('hidden', !visible); + } +} diff --git a/src/vs/sessions/browser/parts/sessionView.ts b/src/vs/sessions/browser/parts/sessionView.ts index 2f29e92fca45e7..12f167656f2e7f 100644 --- a/src/vs/sessions/browser/parts/sessionView.ts +++ b/src/vs/sessions/browser/parts/sessionView.ts @@ -17,13 +17,15 @@ import { IActiveSession } from '../../services/sessions/common/sessionsManagemen import { IChatViewFactory } from '../../services/chatView/browser/chatViewFactory.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions } from './chatView.js'; import { ChatCompositeBar } from './chatCompositeBar.js'; +import { SessionReadOnlyBanner } from './sessionReadOnlyBanner.js'; import { SessionHeader, SessionViewFloatingToolbar } from './sessionHeader.js'; import { ISessionContext, SessionContext } from '../../services/sessions/browser/sessionContext.js'; -import { autorun, observableValue } from '../../../base/common/observable.js'; -import { SessionIsMaximizedContext } from '../../common/contextkeys.js'; +import { autorun, observableValue, observableSignalFromEvent } from '../../../base/common/observable.js'; +import { SessionIsMaximizedContext, SessionHasTerminalsContext } from '../../common/contextkeys.js'; import { setActiveSessionContextKeys } from '../../services/sessions/common/sessionContextKeys.js'; +import { ISessionTerminalsService } from '../../services/sessions/browser/sessionTerminalsService.js'; import { activeSessionViewBackground, activeSessionViewForeground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../common/theme.js'; -import { SessionStatus } from '../../services/sessions/common/session.js'; +import { ChatInteractivity, SessionStatus } from '../../services/sessions/common/session.js'; /** * Options passed to {@link SessionView.openSession}. Extends the chat view @@ -61,6 +63,7 @@ export class SessionView extends Disposable implements ISerializableView { private readonly _header: SessionHeader; private readonly _compositeBar: ChatCompositeBar; + private readonly _readOnlyBanner: SessionReadOnlyBanner; private readonly _floatingToolbar: SessionViewFloatingToolbar; private readonly _centeredContentContainer: HTMLElement; private readonly _contentContainer: HTMLElement; @@ -84,6 +87,7 @@ export class SessionView extends Disposable implements ISerializableView { @IChatViewFactory private readonly chatViewFactory: IChatViewFactory, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, ) { super(); @@ -92,6 +96,20 @@ export class SessionView extends Disposable implements ISerializableView { const scopedContextKeyService = this._scopedContextKeyService = this._register(contextKeyService.createScoped(this.element)); this._sessionIsMaximizedKey = SessionIsMaximizedContext.bindTo(scopedContextKeyService); + // The terminal counts are owned by the terminal contribution rather than + // the session model, so they are tracked here with a dedicated autorun + // (the session-model context keys are applied separately per opened + // session). The pill is shown once the session has at least one terminal + // that has had a command sent in it. + const hasTerminalsKey = SessionHasTerminalsContext.bindTo(scopedContextKeyService); + const terminalsSignal = observableSignalFromEvent(this, sessionTerminalsService.onDidChangeTerminals); + this._register(autorun(reader => { + terminalsSignal.read(reader); + const session = this._sessionObs.read(reader); + const total = session ? sessionTerminalsService.getTerminalCounts(session.sessionId).total : 0; + hasTerminalsKey.set(total > 0); + })); + // Scoped service exposing this view's session so toolbars and contributed // action view items (e.g. the changes diff stats in the header) can read it. const scopedInstantiationService = this._register(instantiationService.createChild(new ServiceCollection( @@ -119,6 +137,27 @@ export class SessionView extends Disposable implements ISerializableView { this._compositeBar = this._register(scopedInstantiationService.createInstance(ChatCompositeBar)); this._centeredContentContainer.appendChild(this._compositeBar.element); + // Read-only status banner, shown flush below the tab bar (within the same + // centered band) when the session's active chat is non-interactive, in + // place of the composer which is hidden for read-only chats. + this._readOnlyBanner = this._register(new SessionReadOnlyBanner()); + this._centeredContentContainer.appendChild(this._readOnlyBanner.domNode); + this._register(autorun(reader => { + const session = this._sessionObs.read(reader); + const activeChat = session?.activeChat.read(reader); + const readOnly = !!activeChat && activeChat.interactivity.read(reader) !== ChatInteractivity.Full; + // Only re-layout when the banner's visibility (and thus its + // contribution to `barHeight`) actually changes; toggling within the + // same read-only state leaves the bar height unchanged. Re-layouts + // needed for other reasons (e.g. the child chat view being swapped + // when the active chat changes) are owned by the `openSession` + // autorun, which calls `_layoutChildren` unconditionally. + if (this._readOnlyBanner.visible !== readOnly) { + this._readOnlyBanner.setVisible(readOnly); + this._layoutChildren(); + } + })); + this._contentContainer = $('.session-view-content'); this.element.appendChild(this._contentContainer); @@ -149,7 +188,7 @@ export class SessionView extends Disposable implements ISerializableView { let desiredKind: ChatViewKind; if (session === undefined || session.isCreated.read(reader) === false) { desiredKind = 'newSession'; - } else if (session.activeChat.read(reader).status.read(reader) === SessionStatus.Untitled) { + } else if (session.activeChat.read(reader).status.read(reader) === SessionStatus.Untitled && session.activeChat.read(reader).interactivity.read(reader) === ChatInteractivity.Full) { desiredKind = 'newChatInSession'; } else { desiredKind = 'chat'; @@ -207,7 +246,8 @@ export class SessionView extends Disposable implements ISerializableView { const headerHeight = this._header.visible ? this._header.height : 0; const tabsHeight = this._compositeBar.visible ? this._compositeBar.height : 0; - const barHeight = headerHeight + tabsHeight; + const bannerHeight = this._readOnlyBanner.visible ? this._readOnlyBanner.domNode.offsetHeight : 0; + const barHeight = headerHeight + tabsHeight + bannerHeight; // Cap the band's height to the header + tabs (it is horizontally centered // via CSS `margin: 0 auto`) so the full-width chat content sits below it. diff --git a/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts b/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts new file mode 100644 index 00000000000000..c141016bf8f6d8 --- /dev/null +++ b/src/vs/sessions/browser/parts/singlePaneAuxiliaryBarPart.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { editorBackground } from '../../../platform/theme/common/colorRegistry.js'; +import { AbstractPaneCompositePart } from '../../../workbench/browser/parts/paneCompositePart.js'; +import { Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { AuxiliaryBarPart } from './auxiliaryBarPart.js'; + +/** + * Single-pane variant of the auxiliary bar. In the single-pane layout the + * auxiliary bar is docked inside the editor part as a contextual detail panel: + * it has no title/composite bar, shares the editor background so the pane reads + * as one card, and fills the exact rectangle the workbench positions it in. + */ +export class SinglePaneAuxiliaryBarPart extends AuxiliaryBarPart { + + protected override createTitleArea(): HTMLElement | undefined { + // No title strip; the single tab bar lives on the editor part. + return undefined; + } + + protected override shouldShowCompositeBar(): boolean { + return false; + } + + protected override getPartBackgroundColor(): string { + return this.getColor(editorBackground) || ''; + } + + override layout(width: number, height: number, top: number, left: number): void { + if (!this.layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + return; + } + + // The workbench docks and sizes the aux bar to an exact rectangle (below the + // editor tab strip); fill it directly without the card margins/border math. + AbstractPaneCompositePart.prototype.layout.call(this, width, height, top, left); + } +} diff --git a/src/vs/sessions/browser/parts/singlePaneEditorPart.ts b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts new file mode 100644 index 00000000000000..2d6cda86cba0ae --- /dev/null +++ b/src/vs/sessions/browser/parts/singlePaneEditorPart.ts @@ -0,0 +1,152 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getClientArea } from '../../../base/browser/dom.js'; +import { DisposableMap } from '../../../base/common/lifecycle.js'; +import { mainWindow } from '../../../base/browser/window.js'; +import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; +import { IStorageService } from '../../../platform/storage/common/storage.js'; +import { IThemeService } from '../../../platform/theme/common/themeService.js'; +import { IEditorGroupViewOptions, IEditorPartCreationOptions, IEditorPartsView } from '../../../workbench/browser/parts/editor/editor.js'; +import { EditorGroupView } from '../../../workbench/browser/parts/editor/editorGroupView.js'; +import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { IHostService } from '../../../workbench/services/host/browser/host.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../common/sessionConfig.js'; +import { DockedAuxiliaryBarController } from '../dockedAuxiliaryBarController.js'; +import { Menus } from '../menus.js'; +import { IAgentWorkbenchLayoutService } from '../workbench.js'; +import { MainEditorPart } from './editorPart.js'; +import { SinglePaneAuxiliaryBarPart } from './singlePaneAuxiliaryBarPart.js'; + +/** + * Whether the Agents window should use the single-pane detail-panel layout, where + * the auxiliary bar is owned by (docked inside) the editor part. True only when the + * setting is enabled on a non-phone viewport — the classic and mobile layouts keep + * the auxiliary bar as a standalone part. This is the single source of truth for + * selecting the single-pane workbench, editor part, and auxiliary bar together. + */ +export function shouldUseSinglePaneLayout(configurationService: IConfigurationService): boolean { + const { width } = getClientArea(mainWindow.document.body); + const isPhoneLayout = width < 640; + return !isPhoneLayout && configurationService.getValue<boolean>(DOCK_DETAIL_PANEL_SETTING) === true; +} + +/** + * Single-pane editor part: owns the docked auxiliary bar so "tab bar + editor + * header + editor + auxiliary bar" is a single unit. It creates the + * {@link SinglePaneAuxiliaryBarPart} (lazily, so the pane composite service and + * the editor part share one instance) and the {@link DockedAuxiliaryBarController} + * that docks and sizes the auxiliary bar inside the editor part. The full-width + * header itself is rendered by the editor group from the group's configured header + * menus ({@link Menus.SessionsEditorHeaderPrimary} / {@link Menus.SessionsEditorHeaderSecondary}, + * supplied via {@link getGroupViewOptions}) whenever the active editor opts in via + * {@link IEditorPane.getHeaderActions}; the part only reacts to its height to + * reposition the docked auxiliary bar. + */ +export class SinglePaneMainEditorPart extends MainEditorPart { + + private _auxiliaryBar: SinglePaneAuxiliaryBarPart | undefined; + private _dockedAuxBar: DockedAuxiliaryBarController | undefined; + private readonly _groupHeaderListeners = this._register(new DisposableMap<EditorGroupView>()); + + protected override getGroupViewOptions(): IEditorGroupViewOptions { + return { + menuIds: { + headerPrimary: Menus.SessionsEditorHeaderPrimary, + headerSecondary: Menus.SessionsEditorHeaderSecondary, + editorActions: Menus.SessionsEditorTitle, + tabsBarContext: Menus.SessionsEditorTabsBarContext + } + }; + } + + constructor( + editorPartsView: IEditorPartsView, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IThemeService themeService: IThemeService, + @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, + @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, + @IHostService hostService: IHostService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(editorPartsView, _instantiationService, themeService, configurationService, storageService, layoutService, hostService, contextKeyService); + } + + /** + * The auxiliary bar owned by this editor part, created on first access. The + * pane composite service reads this so both share the same instance. + */ + get auxiliaryBar(): SinglePaneAuxiliaryBarPart { + if (!this._auxiliaryBar) { + this._auxiliaryBar = this._register(this._instantiationService.createInstance(SinglePaneAuxiliaryBarPart)); + } + return this._auxiliaryBar; + } + + /** + * Creates the editor part's DOM. Besides the base content (the editor grid), the + * single-pane part docks the auxiliary bar here — in the same place the base part + * creates its content — and enables the header separator border on every group. + */ + protected override createContentArea(parent: HTMLElement, options?: IEditorPartCreationOptions): HTMLElement { + const container = super.createContentArea(parent, options); + + this._registerGroupHeaders(); + + const layoutService = this.layoutService as IAgentWorkbenchLayoutService; + this._dockedAuxBar = this._register(new DockedAuxiliaryBarController( + this.element, + this.auxiliaryBar, + { + getWidth: () => layoutService.getDockedAuxiliaryBarWidth(), + setWidth: (width: number) => layoutService.setDockedAuxiliaryBarWidth(width), + isEditorAreaVisible: () => layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || layoutService.isVisible(Parts.AUXILIARYBAR_PART), + isEditorVisible: () => layoutService.isVisible(Parts.EDITOR_PART, mainWindow), + isAuxiliaryBarVisible: () => layoutService.isVisible(Parts.AUXILIARYBAR_PART), + hideAuxiliaryBar: () => layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART), + setEditorContentRightInset: (px: number) => this.setContentRightInset(px), + getHeaderHeight: () => (this.activeGroup as EditorGroupView).headerHeight, + }, + )); + + return container; + } + + /** + * Repositions the docked auxiliary bar when a group's header height changes, + * so the aux bar and sash stay aligned with the editor content below the header. + */ + private _registerGroupHeaders(): void { + for (const group of this.groups) { + this._registerGroupHeader(group as EditorGroupView); + } + this._register(this.onDidAddGroup(group => this._registerGroupHeader(group as EditorGroupView))); + this._register(this.onDidRemoveGroup(group => this._groupHeaderListeners.deleteAndDispose(group as EditorGroupView))); + } + + private _registerGroupHeader(group: EditorGroupView): void { + this._groupHeaderListeners.set(group, group.onDidChangeHeaderHeight(() => this._dockedAuxBar?.layout())); + } + + override layout(width: number, height: number, top: number, left: number): void { + super.layout(width, height, top, left); + (this.layoutService as IAgentWorkbenchLayoutService).handleDockedEditorPartLayout(width); + + // The editor part owns the docked auxiliary bar (and its resize sash), so it + // must re-position it whenever it is itself laid out (window/grid resize, + // sidebar toggle). Otherwise the aux bar keeps sticking to the right edge + // while the sash's absolute position goes stale and drifts off the border. + // The header lays out with its group (flow), so it needs no repositioning here. + this._dockedAuxBar?.layout(); + } + + /** Re-layouts the docked auxiliary bar. Called by the workbench on layout changes. */ + layoutDockedAuxiliaryBar(): void { + this._dockedAuxBar?.layout(); + } +} diff --git a/src/vs/sessions/browser/singlePaneWorkbench.ts b/src/vs/sessions/browser/singlePaneWorkbench.ts new file mode 100644 index 00000000000000..7e450be949bc7e --- /dev/null +++ b/src/vs/sessions/browser/singlePaneWorkbench.ts @@ -0,0 +1,375 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ISerializedNode, IViewSize } from '../../base/browser/ui/grid/grid.js'; +import { Parts } from '../../workbench/services/layout/browser/layoutService.js'; +import { DockedAuxiliaryBarController } from './dockedAuxiliaryBarController.js'; +import { SinglePaneMainEditorPart } from './parts/singlePaneEditorPart.js'; +import { ISideBarResizeContext, Workbench } from './workbench.js'; + +interface IDockedSideBarResizeContext extends ISideBarResizeContext { + readonly freedSideBarWidth: number; + readonly editorSizeBeforeSideBarHide: IViewSize | undefined; + readonly detailWidthBeforeSideBarHide: number | undefined; +} + +/** + * Remembers editor/detail widths captured around visibility and sidebar-collapse + * transitions so the docked side pane restores the user's chosen sizes. + */ +export class DockedEditorSizeMemento { + /** Editor node size captured when "Hide Editor" is used with the detail still visible. */ + dockedEditorSizeBeforeHide: IViewSize | undefined; + /** Editor node size grown while the sidebar is collapsed (editor content visible). */ + editorSizeGrownForSidebarHide: IViewSize | undefined; + /** Detail-panel width grown while the sidebar is collapsed (editor content hidden). */ + detailWidthGrownForSidebarHide: number | undefined; + + /** Drop the sidebar-collapse snapshots, e.g. once the node returns to the detail width. */ + clearSidebarGrowSnapshots(): void { + this.editorSizeGrownForSidebarHide = undefined; + this.detailWidthGrownForSidebarHide = undefined; + } +} + +/** + * Single-pane workbench: the auxiliary bar is docked inside the editor part (below + * a shared tab bar) rather than being its own grid column. The editor part + * ({@link SinglePaneMainEditorPart}) owns the auxiliary bar and its docked + * controller; this workbench owns the docked width, the reveal-sync, and the + * docked size bookkeeping. + */ +export class SinglePaneWorkbench extends Workbench { + + /** Node width past the detail width at which editor content counts as visible. */ + private static readonly _EDITOR_CONTENT_VISIBLE_THRESHOLD = 4; + + /** Extra node width beyond the detail width at which a widen reveals the editor. */ + private static readonly _EDITOR_REVEAL_MARGIN = 200; + + private _dockedAuxiliaryBarWidth = DockedAuxiliaryBarController.DEFAULT_WIDTH; + private _syncingEditorVisibility = false; + private readonly _memento = new DockedEditorSizeMemento(); + + override get isSinglePaneLayoutEnabled(): boolean { + return true; + } + + override getDockedAuxiliaryBarWidth(): number { + return this._dockedAuxiliaryBarWidth; + } + + override setDockedAuxiliaryBarWidth(width: number): void { + this._dockedAuxiliaryBarWidth = width; + } + + /** Re-layouts the docked auxiliary bar, which the editor part owns. */ + private _layoutDockedAuxBar(): void { + (this.editorGroupService.mainPart as SinglePaneMainEditorPart).layoutDockedAuxiliaryBar(); + } + + protected override _applyLayoutContainerClass(): void { + this.mainContainer.classList.toggle('dock-detail-panel', true); + } + + protected override _auxiliaryBarLayoutWidth(): number { + return this._dockedAuxiliaryBarWidth; + } + + protected override _auxiliaryBarViewSize(): IViewSize { + return { width: this._dockedAuxiliaryBarWidth, height: this._editorPartContainer?.clientHeight ?? 0 }; + } + + protected override _setAuxiliaryBarViewSize(size: IViewSize): void { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, size.width); + this._layoutDockedAuxBar(); + } + + protected override _resizeAuxiliaryBarBy(deltaWidth: number, _deltaHeight: number): void { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, this._dockedAuxiliaryBarWidth + deltaWidth); + this._layoutDockedAuxBar(); + } + + protected override _restoreAuxiliaryBarWidth(width: number): void { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, width); + } + + protected override _persistedEditorWidth(editorGridWidth: number | undefined): number | undefined { + // The docked panel lives inside the editor grid node; exclude it to avoid reload drift. + return typeof editorGridWidth === 'number' + ? Math.max(0, editorGridWidth - this._dockedAuxiliaryBarWidth) + : editorGridWidth; + } + + protected override _persistedAuxiliaryBarWidth(_gridWidth: number | undefined): number | undefined { + return this._memento.detailWidthGrownForSidebarHide ?? this._dockedAuxiliaryBarWidth; + } + + protected override _defaultSideBarSize(policySideBarSize: number): number { + return Math.min(policySideBarSize, 280); + } + + protected override _editorNodeSize(effectiveEditorWidth: number, effectiveAuxBarWidth: number): number { + // The editor part spans the editor + auxiliary bar width (the aux bar is + // docked inside it, not a grid column) so the editor tab bar spans the full width. + return effectiveEditorWidth + effectiveAuxBarWidth; + } + + protected override _editorNodeVisible(editorVisible: boolean, auxBarVisible: boolean): boolean { + return editorVisible || auxBarVisible; + } + + protected override _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, _auxiliaryBarNode: ISerializedNode): ISerializedNode[] { + // The auxiliary bar is inside the editor part and omitted from the grid. + return [sessionsNode, editorNode]; + } + + protected override _layoutSidePane(): void { + this._layoutDockedAuxBar(); + } + + protected override _onGridDidChange(): void { + this._syncEditorVisibility(this.workbenchGrid.getViewSize(this.editorPartView).width); + } + + protected override _onEditorNodeResized(nodeWidth: number): void { + this._syncEditorVisibility(nodeWidth); + } + + private _syncEditorVisibility(nodeWidth: number): void { + if (this._syncingEditorVisibility) { + return; + } + // A session-switch / reload layout restore holds `suppressEditorPartAutoVisibility` + // while it applies the working set, which can widen the docked node before the + // controller has set the target editor-part visibility. The width-based sync must + // not race that: revealing (or hiding) the editor here from the restored geometry + // flickers the editor open for a Detail-only session (and can persist it on reload). + // Only the user dragging the sash (unsuppressed) should drive width-based visibility. + if (this._isEditorPartAutoVisibilitySuppressed) { + return; + } + + this._syncingEditorVisibility = true; + try { + const editorContentVisible = nodeWidth > this._dockedAuxiliaryBarWidth + SinglePaneWorkbench._EDITOR_CONTENT_VISIBLE_THRESHOLD; + + // Hide: editor content is visible and the node is squeezed down to the detail + // width. Only hide when the detail is visible, so we don't hide when both parts + // are closed. + if (this.partVisibility.editor && !editorContentVisible && this.partVisibility.auxiliaryBar) { + this.partVisibility.editor = false; + this._setMainEditorAreaHidden(true); + this._editorRevealedExplicitly = false; + this._memento.clearSidebarGrowSnapshots(); + this._layoutDockedAuxBar(); + this._fireDidChangePartVisibility(Parts.EDITOR_PART, false); + this._savePartVisibility(); + return; + } + + // Reveal (symmetric): the detail is visible while the editor is hidden and the + // user drags the node wide enough to fit the editor beside the detail. Mirrors + // the hide branch above; the wide gap between this threshold and the hide + // threshold provides hysteresis so a small drag can't oscillate. The detail + // keeps its width and the editor takes the remainder (the docked layout + // recomputes the split), so there is no even-split jump. + const revealThreshold = this._dockedAuxiliaryBarWidth + SinglePaneWorkbench._EDITOR_REVEAL_MARGIN; + if (!this.partVisibility.editor && this.partVisibility.auxiliaryBar && nodeWidth >= revealThreshold) { + this.partVisibility.editor = true; + this._setMainEditorAreaHidden(false); + this._editorRevealedExplicitly = false; + this._layoutDockedAuxBar(); + this._fireDidChangePartVisibility(Parts.EDITOR_PART, true); + this._savePartVisibility(); + } + } finally { + this._syncingEditorVisibility = false; + } + } + + protected override _runWithEditorResizeSyncSuspended(fn: () => void): void { + this._syncingEditorVisibility = true; + try { + fn(); + } finally { + this._syncingEditorVisibility = false; + } + } + + protected override _applyEditorVisibility(hidden: boolean): void { + // Give the editor a comfortable even split when revealed without a user-chosen + // width to restore. Hiding collapses the node to the detail width and the grid + // caches it, so a later cross-session reveal would otherwise come back narrow. + // A captured size in the memento always wins. + const dockedEditorSizeBeforeHide = this._memento.dockedEditorSizeBeforeHide; + const shouldRestoreDockedEditorSize = !hidden && !!dockedEditorSizeBeforeHide; + const shouldApplyEvenSplit = !hidden && !shouldRestoreDockedEditorSize; + + const mainAreaWidthBeforeReveal = shouldApplyEvenSplit + ? this.workbenchGrid.getViewSize(this.sessionsPartView).width + : 0; + + this.workbenchGrid.setViewVisible(this.editorPartView, this.partVisibility.editor || this.partVisibility.auxiliaryBar); + + if (hidden) { + // Only "Hide Editor" (detail still visible) keeps the editor grid node + // visible, so its width is a real user-chosen width to restore later. + // Closing the whole side pane collapses the node to 0px, so reset instead. + if (this.partVisibility.auxiliaryBar) { + this._memento.dockedEditorSizeBeforeHide = this.workbenchGrid.getViewSize(this.editorPartView); + this.workbenchGrid.resizeView(this.editorPartView, { + width: this._dockedAuxiliaryBarWidth, + height: this._memento.dockedEditorSizeBeforeHide.height + }); + this._memento.clearSidebarGrowSnapshots(); + } else { + this._memento.dockedEditorSizeBeforeHide = undefined; + this._memento.clearSidebarGrowSnapshots(); + } + } else if (dockedEditorSizeBeforeHide) { + this.workbenchGrid.resizeView(this.editorPartView, dockedEditorSizeBeforeHide); + this._memento.dockedEditorSizeBeforeHide = undefined; + } + + if (shouldApplyEvenSplit) { + this._hasAppliedInitialEditorSplit = true; + this._applyEditorSplitSize(mainAreaWidthBeforeReveal); + } + + this._layoutDockedAuxBar(); + this._fireDidChangePartVisibility(Parts.EDITOR_PART, !hidden); + this._notifyContainerDidLayout(); + } + + protected override _onWillHideAuxiliaryBar(hidden: boolean): void { + if (hidden && !this.partVisibility.editor && !this._isEditorPartAutoVisibilitySuppressed) { + this.setEditorHidden(false, /* explicit */ true); + } + } + + /** + * No-op: the editor-part grid view hosts the docked auxiliary bar, so its + * visibility flips whenever the *detail* opens/closes (not the editor content). + * Editor-content visibility and its part-visibility events are driven directly + * by `setEditorHidden` / `_applyEditorVisibility` / `_applyAuxiliaryBarVisibility` + * / `_syncEditorVisibility`, so mapping the shared node's grid visibility to + * `setEditorHidden` here would wrongly reveal the editor when only the detail is + * shown. + */ + protected override _onEditorPartGridVisibilityChange(_visible: boolean): void { } + + protected override _applyAuxiliaryBarVisibility(hidden: boolean): void { + // The auxiliary bar is docked inside the editor part (not a grid view), so + // drive its visibility through the docked layout and fire the visibility + // event the grid path would otherwise raise (the layout controller listens + // for it to capture per-session state). + if (this.workbenchGrid) { + this.workbenchGrid.setViewVisible( + this.editorPartView, + this.partVisibility.editor || this.partVisibility.auxiliaryBar + ); + if (!hidden && !this.partVisibility.editor) { + this._syncingEditorVisibility = true; + try { + this.workbenchGrid.resizeView(this.editorPartView, { + width: this._dockedAuxiliaryBarWidth, + height: this.workbenchGrid.getViewSize(this.editorPartView).height + }); + } finally { + this._syncingEditorVisibility = false; + } + } + } + this._layoutDockedAuxBar(); + this._fireDidChangePartVisibility(Parts.AUXILIARYBAR_PART, !hidden); + this._notifyContainerDidLayout(); + } + + protected override _shouldOpenAuxiliaryPaneComposite(containerId: string): boolean { + // Never force-open a container that has no active views: doing so would leave + // the detail panel rendered but blank while the toggle/context key reads "on". + return this._isAuxViewContainerActive(containerId); + } + + protected override _handleAllEditorsClosed(): void { + if (!this.partVisibility.editor && !this.partVisibility.auxiliaryBar) { + return; + } + if (this.partVisibility.editor) { + this.rememberAttachedEditorMaximizedState(); + } + const suppress = this.suppressEditorPartAutoVisibility(); + try { + if (this.partVisibility.editor) { + this.setEditorHidden(true); + } + if (this.partVisibility.auxiliaryBar) { + this.setAuxiliaryBarHidden(true); + } + } finally { + suppress.dispose(); + } + } + + protected override _prepareSideBarResize(hidden: boolean): ISideBarResizeContext { + const shouldResize = this.partVisibility.editor || this.partVisibility.auxiliaryBar; + // Grow the editor node when the editor is visible, else the detail (keeps node == detail width so reveal-sync can't misfire). + const growEditorNode = shouldResize && this.partVisibility.editor; + const growDetailPanel = shouldResize && !this.partVisibility.editor; + return { + freedSideBarWidth: hidden && shouldResize ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0, + editorSizeBeforeSideBarHide: hidden && growEditorNode ? this.workbenchGrid.getViewSize(this.editorPartView) : undefined, + detailWidthBeforeSideBarHide: hidden && growDetailPanel ? this._dockedAuxiliaryBarWidth : undefined, + } satisfies IDockedSideBarResizeContext; + } + + protected override _applySideBarResize(hidden: boolean, context: ISideBarResizeContext): void { + const { freedSideBarWidth, editorSizeBeforeSideBarHide, detailWidthBeforeSideBarHide } = context as IDockedSideBarResizeContext; + + if (editorSizeBeforeSideBarHide) { + this._memento.editorSizeGrownForSidebarHide = editorSizeBeforeSideBarHide; + this._resizeEditorAfterSidebarChange({ + width: editorSizeBeforeSideBarHide.width + freedSideBarWidth, + height: editorSizeBeforeSideBarHide.height + }); + } else if (detailWidthBeforeSideBarHide !== undefined) { + this._memento.detailWidthGrownForSidebarHide = detailWidthBeforeSideBarHide; + this._growDetailAfterSidebarChange(detailWidthBeforeSideBarHide + freedSideBarWidth); + } else if (!hidden && this._memento.editorSizeGrownForSidebarHide) { + this._resizeEditorAfterSidebarChange(this._memento.editorSizeGrownForSidebarHide); + this._memento.editorSizeGrownForSidebarHide = undefined; + } else if (!hidden && this._memento.detailWidthGrownForSidebarHide !== undefined) { + this._growDetailAfterSidebarChange(this._memento.detailWidthGrownForSidebarHide); + this._memento.detailWidthGrownForSidebarHide = undefined; + } else if (!hidden) { + this._memento.clearSidebarGrowSnapshots(); + } + } + + private _resizeEditorAfterSidebarChange(size: IViewSize): void { + this._syncingEditorVisibility = true; + try { + this.workbenchGrid.resizeView(this.editorPartView, size); + } finally { + this._syncingEditorVisibility = false; + } + this._layoutDockedAuxBar(); + } + + private _growDetailAfterSidebarChange(width: number): void { + this._dockedAuxiliaryBarWidth = Math.max(DockedAuxiliaryBarController.MIN_WIDTH, width); + this._syncingEditorVisibility = true; + try { + this.workbenchGrid.resizeView(this.editorPartView, { + width: this._dockedAuxiliaryBarWidth, + height: this.workbenchGrid.getViewSize(this.editorPartView).height + }); + } finally { + this._syncingEditorVisibility = false; + } + this._layoutDockedAuxBar(); + } +} diff --git a/src/vs/sessions/browser/web.main.ts b/src/vs/sessions/browser/web.main.ts index a5a4b4a637e0f8..c6e97e797586e0 100644 --- a/src/vs/sessions/browser/web.main.ts +++ b/src/vs/sessions/browser/web.main.ts @@ -24,12 +24,14 @@ import { BrowserMain, IBrowserMainWorkbench } from '../../workbench/browser/web. import { getWorkspaceIdentifier } from '../../platform/workspaces/common/workspaceIdentifier.js'; import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; -import { Workbench as SessionsWorkbench } from './workbench.js'; +import { ConfigurationCache } from '../../workbench/services/configuration/common/configurationCache.js'; +import { Schemas } from '../../base/common/network.js'; +import { createSessionsWorkbench } from './workbenchFactory.js'; export class SessionsBrowserMain extends BrowserMain { protected override createWorkbench(domElement: HTMLElement, serviceCollection: ServiceCollection, logService: ILogService): IBrowserMainWorkbench { - return new SessionsWorkbench(domElement, undefined, serviceCollection, logService); + return createSessionsWorkbench(domElement, undefined, serviceCollection, logService); } protected override async createWorkspaceConfigAndStorageServices( @@ -59,13 +61,16 @@ export class SessionsBrowserMain extends BrowserMain { // Configuration — the sessions ConfigurationService works against the // in-memory workspace model rather than a real .code-workspace file on disk. + const configurationCache = new ConfigurationCache([Schemas.file, Schemas.vscodeUserData], environmentService, fileService); const configurationService = new ConfigurationService( userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, - logService + logService, + configurationCache, + environmentService, ); try { diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index fd45e5ada870cb..c025f1c53b33eb 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -7,7 +7,7 @@ import '../../workbench/browser/style.js'; import './media/style.css'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { Emitter, Event, setGlobalLeakWarningThreshold } from '../../base/common/event.js'; -import { getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; +import { addDisposableListener, getActiveDocument, getActiveElement, getClientArea, getWindowId, getWindows, IDimension, isAncestorUsingFlowTo, isHTMLElement, size, Dimension, runWhenWindowIdle } from '../../base/browser/dom.js'; import { DeferredPromise, RunOnceScheduler } from '../../base/common/async.js'; import { isFullscreen, onDidChangeFullscreen, isChrome, isFirefox, isSafari } from '../../base/browser/browser.js'; import { mark } from '../../base/common/performance.js'; @@ -41,7 +41,8 @@ import { setHoverDelegateFactory } from '../../base/browser/ui/hover/hoverDelega import { setBaseLayerHoverDelegate } from '../../base/browser/ui/hover/hoverDelegate2.js'; import { Registry } from '../../platform/registry/common/platform.js'; import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from '../../workbench/common/contributions.js'; -import { IEditorFactoryRegistry, EditorExtensions } from '../../workbench/common/editor.js'; +import { IEditorFactoryRegistry, EditorExtensions, IEditorWillOpenEvent } from '../../workbench/common/editor.js'; +import { EditorInput } from '../../workbench/common/editor/editorInput.js'; import { setARIAContainer } from '../../base/browser/ui/aria/aria.js'; import { FontMeasurements } from '../../editor/browser/config/fontMeasurements.js'; import { createBareFontInfoFromRawSettings } from '../../editor/common/config/fontInfoFromSettings.js'; @@ -108,7 +109,8 @@ enum LayoutClasses { //#region Part Visibility State -interface IPartVisibilityState { +/** Visibility of each workbench part in the Agents window layout. */ +export interface IPartVisibilityState { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; @@ -124,26 +126,77 @@ interface IPartSizesState { panel?: number; } +/** Opaque per-transition capture returned by `Workbench._prepareSideBarResize`. */ +export interface ISideBarResizeContext { } + //#endregion -export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService { +export interface IAgentWorkbenchLayoutService extends IWorkbenchLayoutService, IDockedEditorLayout { isEditorMaximized(): boolean; setEditorMaximized(maximized: boolean): void; readonly onDidChangeEditorMaximized: Event<void>; + /** + * Whether the Agents window is using the single-pane (docked detail panel) + * layout. Fixed at construction by the workbench subclass — `false` for the + * classic/mobile workbench, `true` for {@link SinglePaneWorkbench}. Features + * gate single-pane behaviour on this instead of reading the setting directly. + */ + readonly isSinglePaneLayoutEnabled: boolean; + /** * Suppresses the automatic editor part show/hide that normally fires from * `editorService.onWillOpenEditor` / `onDidCloseEditor`. Use this around * programmatic editor operations (e.g. applying a working set) so that the * editor part visibility is not changed as a side-effect. Dispose the * returned handle to release the suppression. Calls nest via a counter. - * - * Comment: We should consider movin mximization logic into layoutController */ suppressEditorPartAutoVisibility(): IDisposable; } +/** + * Docked-editor (single-pane detail panel) concerns of the layout service, kept + * separate from the general contract so features that do not care about the + * docked layout are not coupled to it. + */ +export interface IDockedEditorLayout { + handleDockedEditorPartLayout(nodeWidth: number): void; + + /** + * Whether the editor's current visible state was produced by an explicit user + * reveal (opening an editor, or toggling the detail panel off) rather than an + * automatic layout/working-set reveal. The single-pane new-session rule (R1) + * uses this to avoid re-hiding an editor the user explicitly asked to show. + */ + isEditorRevealedExplicitly(): boolean; + + /** + * Reveals the (possibly hidden) editor part as an *explicit* user reveal, so + * the automatic single-pane hide rules (R1 / working-set apply) do not undo it. + * Use for deliberate opens like the session-header Changes pill or opening a + * file diff — not for automatic/layout-driven reveals. + */ + revealEditorPartExplicitly(): void; + + /** + * The docked auxiliary bar (detail panel) width, owned by the workbench's + * single-pane layout state and read/written by the docked controller that the + * editor part owns. Trivial in the classic layout. + */ + getDockedAuxiliaryBarWidth(): number; + setDockedAuxiliaryBarWidth(width: number): void; + + /** + * Sets a predicate deciding which editors, when opened while the editor area is + * hidden, should NOT reveal it (their content lives in the detail panel, e.g. the + * managed Changes and Files tabs). Lets contrib own the policy — including the + * editor types involved — instead of the core workbench hardcoding type ids. + * Returns a disposable that clears the predicate. + */ + setEditorRevealOnOpenExclusion(predicate: (editor: EditorInput) => boolean): IDisposable; +} + export const IAgentWorkbenchLayoutService = refineServiceDecorator<IWorkbenchLayoutService, IAgentWorkbenchLayoutService>(IWorkbenchLayoutService); export const CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID = 'sessions.closeMobileSidebarDrawer'; @@ -277,17 +330,26 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region State private readonly parts = new Map<string, Part>(); - private workbenchGrid!: SerializableGrid<ISerializableView>; + protected workbenchGrid!: SerializableGrid<ISerializableView>; private titleBarPartView!: ISerializableView; - private sideBarPartView!: ISerializableView; + protected sideBarPartView!: ISerializableView; private panelPartView!: ISerializableView; - private auxiliaryBarPartView!: ISerializableView; - private editorPartView!: ISerializableView; + protected auxiliaryBarPartView!: ISerializableView; + protected editorPartView!: ISerializableView; - private sessionsPartView!: ISerializableView; + protected sessionsPartView!: ISerializableView; - private readonly partVisibility: IPartVisibilityState = { + /** The editor part container; the auxiliary bar is docked inside it. */ + protected _editorPartContainer: HTMLElement | undefined; + /** `false` for the classic/mobile layout; {@link SinglePaneWorkbench} overrides to `true`. */ + get isSinglePaneLayoutEnabled(): boolean { + return false; + } + /** `true` while the editor's current visible state was produced by an explicit user reveal (opening an editor, or toggling the detail panel off) rather than an automatic layout/working-set reveal. Read by the single-pane new-session rule (R1) so it does not undo an explicit reveal. */ + protected _editorRevealedExplicitly = false; + + protected readonly partVisibility: IPartVisibilityState = { sidebar: true, auxiliaryBar: true, editor: false, @@ -297,7 +359,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private mainWindowFullscreen = false; private readonly maximized = new Set<number>(); - private readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); + protected readonly layoutPolicy = this._register(new SessionsLayoutPolicy()); private readonly mobileNavStack = this._register(new MobileNavigationStack()); private mobileTopBarElement: HTMLElement | undefined; private readonly mobileTopBarDisposables = this._register(new DisposableStore()); @@ -306,8 +368,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic private _editorLastNonMaximizedVisibility: IPartVisibilityState | undefined; private _editorLastNonMaximizedSize: IViewSize | undefined; private _restoreAttachedEditorMaximizedOnShow = false; - private _editorPartAutoVisibilitySuppressionCount = 0; - private _hasAppliedInitialEditorSplit = false; + protected _editorPartAutoVisibilitySuppressionCount = 0; + protected _hasAppliedInitialEditorSplit = false; + private _editorRevealOnOpenExclusion: ((editor: EditorInput) => boolean) | undefined; private readonly restoredPromise = new DeferredPromise<void>(); readonly whenRestored = this.restoredPromise.p; @@ -324,7 +387,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Services - private editorGroupService!: IEditorGroupsService; + protected editorGroupService!: IEditorGroupsService; private editorService!: IEditorService; private paneCompositeService!: IPaneCompositePartService; private viewDescriptorService!: IViewDescriptorService; @@ -669,7 +732,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.partVisibility.sidebar = savedPartVisibility.sidebar ?? this.partVisibility.sidebar; } - private _savePartVisibility(): void { + protected _savePartVisibility(): void { if (this.layoutPolicy.viewportClass.get() === 'phone') { return; } @@ -710,11 +773,22 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return this.workbenchGrid.getViewCachedVisibleSize(view); }; + // The editor-part grid node hosts the docked auxiliary bar in single-pane, so + // it is "visible" whenever the editor OR the detail is shown. Use the node's + // real visibility (not just `partVisibility.editor`) so a Detail-only session + // records its *current* collapsed node width — reading the stale cached visible + // size (wide) here would restore a wide node on reload and flicker the editor + // open via the width-based reveal-sync. Classic layout is unaffected + // (`_editorNodeVisible` returns `partVisibility.editor` there). + const editorNodeVisible = this._editorNodeVisible(this.partVisibility.editor, this.partVisibility.auxiliaryBar); + const editorGridWidth = getSize(this.editorPartView, 'width', editorNodeVisible); + const editorWidth = this._persistedEditorWidth(editorGridWidth); + const sizes: IPartSizesState = { sidebar: getSize(this.sideBarPartView, 'width', this.partVisibility.sidebar), - auxiliaryBar: getSize(this.auxiliaryBarPartView, 'width', this.partVisibility.auxiliaryBar), + auxiliaryBar: this._persistedAuxiliaryBarWidth(getSize(this.auxiliaryBarPartView, 'width', this.partVisibility.auxiliaryBar)), sessions: getSize(this.sessionsPartView, 'width', this.partVisibility.sessions), - editor: getSize(this.editorPartView, 'width', this.partVisibility.editor), + editor: editorWidth, panel: getSize(this.panelPartView, 'height', this.partVisibility.panel), }; @@ -745,6 +819,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // grid descriptor so editor/sidebar/auxbar/panel restore to their previous // dimensions across reloads. this._savedPartSizes = this._loadPartSizes(storageService); + if (this._savedPartSizes.auxiliaryBar !== undefined) { + this._restoreAuxiliaryBarWidth(this._savedPartSizes.auxiliaryBar); + } // State specific classes const platformClass = isWindows ? 'windows' : isLinux ? 'linux' : 'mac'; @@ -941,6 +1018,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic editorPartContainer.classList.add('part', 'editor'); editorPartContainer.id = Parts.EDITOR_PART; editorPartContainer.setAttribute('role', 'main'); + this._editorPartContainer = editorPartContainer; mark('code/willCreatePart/workbench.parts.editor'); this.getPart(Parts.EDITOR_PART).create(editorPartContainer, { restorePreviousState: false }); @@ -1025,6 +1103,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.storageService = accessor.get(IStorageService); accessor.get(ITitleService); + // Resolve the single-pane layout mode once (reload to toggle). + this.layoutPolicy.setSinglePane(this.isSinglePaneLayoutEnabled); + // Register layout listeners this.registerLayoutListeners(); @@ -1032,32 +1113,13 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // they actually target one of the main editor groups. Modal // opens stay neutral. Programmatic opens that suppress auto // visibility (e.g. working set application) are ignored. - this._register(this.editorService.onWillOpenEditor(e => { - if (this._editorPartAutoVisibilitySuppressionCount > 0) { - return; - } - - const targetsMainEditorPart = this.editorGroupService.mainPart.groups.some(group => group.id === e.groupId); - if (!targetsMainEditorPart) { - return; - } - - if (!this.partVisibility.editor) { - this.setEditorHidden(false); - this.restoreAttachedEditorMaximizedState(); - } - })); + // The managed empty Files tab is a placeholder that activates as a side + // effect of closing another tab (e.g. the Changes tab); it must never + // reveal a hidden editor. Real content (files, diffs, browser) still does. + this._register(this.editorService.onWillOpenEditor(e => this._handleWillOpenEditor(e))); // Hide editor part when last editor closes - this._register(this.editorService.onDidCloseEditor(() => { - if (this._editorPartAutoVisibilitySuppressionCount > 0) { - return; - } - if (this.partVisibility.editor && this.areAllGroupsInMainPartEmpty()) { - this.rememberAttachedEditorMaximizedState(); - this.setEditorHidden(true); - } - })); + this._register(this.editorService.onDidCloseEditor(() => this.handleDidCloseEditor())); // Initialize layout state (must be done before createWorkbenchLayout) this._mainContainerDimension = getClientArea(this.parent, new Dimension(800, 600)); @@ -1087,6 +1149,37 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return true; } + private _handleWillOpenEditor(e: IEditorWillOpenEvent): void { + if (this._editorPartAutoVisibilitySuppressionCount > 0) { + return; + } + + const group = this.editorGroupService.mainPart.groups.find(g => g.id === e.groupId); + if (!group) { + return; + } + + // A contrib-provided policy decides which editors surface their content in + // the detail panel (e.g. the managed Changes and Files tabs) and so must not + // reveal the hidden editor area when opened. + if (this._editorRevealOnOpenExclusion?.(e.editor)) { + return; + } + + if (!this.partVisibility.editor) { + this.setEditorHidden(false, /* explicit */ true); + this.restoreAttachedEditorMaximizedState(); + } + } + + private handleDidCloseEditor(): void { + if (this._editorPartAutoVisibilitySuppressionCount > 0 || !this.areAllGroupsInMainPartEmpty()) { + return; + } + + this._handleAllEditorsClosed(); + } + suppressEditorPartAutoVisibility(): IDisposable { this._editorPartAutoVisibilitySuppressionCount++; let disposed = false; @@ -1099,7 +1192,16 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic }); } - private rememberAttachedEditorMaximizedState(): void { + setEditorRevealOnOpenExclusion(predicate: (editor: EditorInput) => boolean): IDisposable { + this._editorRevealOnOpenExclusion = predicate; + return toDisposable(() => { + if (this._editorRevealOnOpenExclusion === predicate) { + this._editorRevealOnOpenExclusion = undefined; + } + }); + } + + protected rememberAttachedEditorMaximizedState(): void { this._restoreAttachedEditorMaximizedOnShow = this._editorMaximized && this.partVisibility.auxiliaryBar; } @@ -1112,6 +1214,155 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } } + //#region Side-pane layout hooks (classic grid defaults; overridden by SinglePaneWorkbench) + + protected _fireDidChangePartVisibility(partId: Parts, visible: boolean): void { + this._onDidChangePartVisibility.fire({ partId, visible }); + } + + protected _notifyContainerDidLayout(): void { + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + } + + protected _setMainEditorAreaHidden(hidden: boolean): void { + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + } + + /** + * Handles a change in the editor-part grid view's visibility. In the classic + * layout the editor part is a standalone grid view, so its view visibility *is* + * the editor visibility — map it to `setEditorHidden` and raise the part event. + * Single-pane overrides this: its editor-part grid view also hosts the docked + * auxiliary bar, so the view can become visible purely to show the detail while + * the editor content stays hidden; it fires its own editor-part events instead. + */ + protected _onEditorPartGridVisibilityChange(visible: boolean): void { + this.setEditorHidden(!visible); + this._onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible }); + } + + protected get _isEditorPartAutoVisibilitySuppressed(): boolean { + return this._editorPartAutoVisibilitySuppressionCount > 0; + } + + /** Toggles the container marker class for the side-pane layout. */ + protected _applyLayoutContainerClass(): void { + this.mainContainer.classList.toggle('dock-detail-panel', false); + } + + /** Width the auxiliary bar occupies when visible (for max-editor-dimension math). */ + protected _auxiliaryBarLayoutWidth(): number { + return this.workbenchGrid ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; + } + + protected _auxiliaryBarViewSize(): IViewSize { + if (!this.workbenchGrid || !this.auxiliaryBarPartView) { + return { width: 0, height: 0 }; + } + return this.workbenchGrid.getViewSize(this.auxiliaryBarPartView); + } + + protected _setAuxiliaryBarViewSize(size: IViewSize): void { + if (this.auxiliaryBarPartView) { + this.workbenchGrid.resizeView(this.auxiliaryBarPartView, size); + } + } + + protected _resizeAuxiliaryBarBy(deltaWidth: number, deltaHeight: number): void { + if (!this.auxiliaryBarPartView) { + return; + } + const currentSize = this.workbenchGrid.getViewSize(this.auxiliaryBarPartView); + this.workbenchGrid.resizeView(this.auxiliaryBarPartView, { + width: currentSize.width + deltaWidth, + height: currentSize.height + deltaHeight + }); + } + + protected _restoreAuxiliaryBarWidth(_width: number): void { } + + protected _persistedEditorWidth(editorGridWidth: number | undefined): number | undefined { + return editorGridWidth; + } + + protected _persistedAuxiliaryBarWidth(gridWidth: number | undefined): number | undefined { + return gridWidth; + } + + protected _defaultSideBarSize(policySideBarSize: number): number { + return policySideBarSize; + } + + protected _editorNodeSize(effectiveEditorWidth: number, _effectiveAuxBarWidth: number): number { + return effectiveEditorWidth; + } + + protected _editorNodeVisible(editorVisible: boolean, _auxBarVisible: boolean): boolean { + return editorVisible; + } + + protected _topRightSectionChildren(sessionsNode: ISerializedNode, editorNode: ISerializedNode, auxiliaryBarNode: ISerializedNode): ISerializedNode[] { + return [sessionsNode, editorNode, auxiliaryBarNode]; + } + + /** Attach any per-layout controllers once the editor part container exists. */ + protected _attachSidePane(): void { } + /** Lay out any docked overlay. */ + protected _layoutSidePane(): void { } + /** React to a whole-grid change (e.g. a sash drag) after the grid rebuilds. */ + protected _onGridDidChange(): void { } + /** React to the editor grid node being resized to `nodeWidth`. */ + protected _onEditorNodeResized(_nodeWidth: number): void { } + + /** Run editor-node work with the reveal-sync suspended (no-op for the grid layout). */ + protected _runWithEditorResizeSyncSuspended(fn: () => void): void { + fn(); + } + + protected _applyEditorVisibility(hidden: boolean): void { + const shouldApplyEvenSplit = !hidden && !this._hasAppliedInitialEditorSplit; + const mainAreaWidthBeforeReveal = shouldApplyEvenSplit + ? this.workbenchGrid.getViewSize(this.sessionsPartView).width + : 0; + + this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); + + if (shouldApplyEvenSplit) { + this._hasAppliedInitialEditorSplit = true; + this._applyEditorSplitSize(mainAreaWidthBeforeReveal); + } + } + + protected _onWillHideAuxiliaryBar(_hidden: boolean): void { } + + protected _applyAuxiliaryBarVisibility(hidden: boolean): void { + // Skipped before the grid exists: during startup the layout controller (a + // BlockRestore contribution) runs before createWorkbenchLayout(), so the + // visibility is recorded in partVisibility and applied when the grid is built. + if (this.workbenchGrid) { + this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); + } + } + + protected _shouldOpenAuxiliaryPaneComposite(_containerId: string): boolean { + return true; + } + + protected _handleAllEditorsClosed(): void { + if (this.partVisibility.editor) { + this.rememberAttachedEditorMaximizedState(); + this.setEditorHidden(true); + } + } + + protected _prepareSideBarResize(_hidden: boolean): ISideBarResizeContext { + return {}; + } + + protected _applySideBarResize(_hidden: boolean, _context: ISideBarResizeContext): void { } + + //#endregion + private registerLayoutListeners(): void { // Fullscreen changes this._register(onDidChangeFullscreen(windowId => { @@ -1124,8 +1375,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Window resize — needed for device emulation and mobile viewport changes const onWindowResize = () => this.layout(); - mainWindow.addEventListener('resize', onWindowResize); - this._register({ dispose: () => mainWindow.removeEventListener('resize', onWindowResize) }); + this._register(addDisposableListener(mainWindow, 'resize', onWindowResize)); } private updateFullscreenClass(): void { @@ -1141,6 +1391,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Workbench Layout Creation createWorkbenchLayout(): void { + this._applyLayoutContainerClass(); + const titleBar = this.getPart(Parts.TITLEBAR_PART); const editorPart = this.getPart(Parts.EDITOR_PART); const panelPart = this.getPart(Parts.PANEL_PART); @@ -1176,6 +1428,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.mainContainer.setAttribute('role', 'application'); this.workbenchGrid = workbenchGrid; this.workbenchGrid.edgeSnapping = this.mainWindowFullscreen; + this._register(this.workbenchGrid.onDidChange(() => { + this._onGridDidChange(); + })); // If the editor is restored visible, it already has an established // width, so a later reveal must not force an even split over it. @@ -1184,6 +1439,18 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Listen for part visibility changes (for parts in grid) for (const part of [titleBar, panelPart, sideBar, auxiliaryBarPart, sessionsPart, editorPart]) { this._register(part.onDidVisibilityChange(visible => { + // The editor part's grid-view visibility is fully owned by + // `_onEditorPartGridVisibilityChange`: in the classic layout it maps to + // the editor visibility and raises the part-visibility event; single-pane + // (whose editor-part view also hosts the docked auxiliary bar) overrides it + // so the shared node becoming visible for the detail neither reveals the + // editor content nor fires a bogus editor-part-visible event. + if (part === editorPart) { + this._onEditorPartGridVisibilityChange(visible); + this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); + return; + } + if (part === sideBar) { this.setSideBarHidden(!visible); } else if (part === panelPart) { @@ -1192,8 +1459,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this.setAuxiliaryBarHidden(!visible); } else if (part === sessionsPart) { this.setSessionsHidden(!visible); - } else if (part === editorPart) { - this.setEditorHidden(!visible); } this._onDidChangePartVisibility.fire({ partId: part.getId(), visible }); @@ -1254,10 +1519,14 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const sizes = this.layoutPolicy.getPartSizes(width, height); // For hidden parts, still provide a reasonable cached size for when they're shown later. // Saved sizes from a previous session take precedence over policy defaults. + const defaultSideBarSize = this._defaultSideBarSize(sizes.sideBarSize); const sideBarSize = this._savedPartSizes.sidebar - ?? (this.partVisibility.sidebar ? sizes.sideBarSize : Math.max(sizes.sideBarSize, 250)); + ?? (this.partVisibility.sidebar ? defaultSideBarSize : Math.max(defaultSideBarSize, 250)); + const defaultAuxiliaryBarSize = this.isSinglePaneLayoutEnabled + ? this.getDockedAuxiliaryBarWidth() + : sizes.auxiliaryBarSize; const auxiliaryBarSize = this._savedPartSizes.auxiliaryBar - ?? (this.partVisibility.auxiliaryBar ? sizes.auxiliaryBarSize : Math.max(sizes.auxiliaryBarSize, 300)); + ?? (this.partVisibility.auxiliaryBar ? defaultAuxiliaryBarSize : Math.max(defaultAuxiliaryBarSize, 300)); const panelSize = this._savedPartSizes.panel ?? (this.partVisibility.panel ? sizes.panelSize : Math.max(sizes.panelSize, 250)); const editorSize = this._savedPartSizes.editor ?? 600; @@ -1294,13 +1563,6 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic visible: this.partVisibility.sidebar }; - const auxiliaryBarNode: ISerializedLeafNode = { - type: 'leaf', - data: { type: Parts.AUXILIARYBAR_PART }, - size: auxiliaryBarSize, - visible: this.partVisibility.auxiliaryBar - }; - const sessionsNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.SESSIONS_PART }, @@ -1311,8 +1573,15 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic const editorNode: ISerializedLeafNode = { type: 'leaf', data: { type: Parts.EDITOR_PART }, - size: editorSize, - visible: this.partVisibility.editor + size: this._editorNodeSize(effectiveEditorWidth, effectiveAuxBarWidth), + visible: this._editorNodeVisible(this.partVisibility.editor, this.partVisibility.auxiliaryBar) + }; + + const auxiliaryBarNode: ISerializedLeafNode = { + type: 'leaf', + data: { type: Parts.AUXILIARYBAR_PART }, + size: auxiliaryBarSize, + visible: this.partVisibility.auxiliaryBar }; const panelNode: ISerializedLeafNode = { @@ -1322,10 +1591,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic visible: this.partVisibility.panel }; - // Top right section: Chat Bar | Editor | Auxiliary Bar (horizontal) + // Top right section: Chat Bar | Editor [| Auxiliary Bar] (horizontal). + // When docked, the auxiliary bar is inside the editor part and + // omitted from the grid; otherwise it is its own trailing grid column. const topRightSection: ISerializedNode = { type: 'branch', - data: [sessionsNode, editorNode, auxiliaryBarNode], + data: this._topRightSectionChildren(sessionsNode, editorNode, auxiliaryBarNode), size: topRightHeight }; @@ -1447,12 +1718,40 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // Layout the grid widget this.workbenchGrid.layout(gridWidth, gridHeight); + + // Dock + layout the auxiliary bar inside the editor part so the + // editor tab bar spans the full width above both. + this._attachSidePane(); + this._layoutSidePane(); + this.layoutMobileSidebar(); // Emit as event this.handleContainerDidLayout(this.mainContainer, this._mainContainerDimension); } + handleDockedEditorPartLayout(nodeWidth: number): void { + this._onEditorNodeResized(nodeWidth); + } + + isEditorRevealedExplicitly(): boolean { + return this._editorRevealedExplicitly; + } + + revealEditorPartExplicitly(): void { + // Mark the reveal explicit so R1 / the working-set apply do not re-hide it. + // Re-assert the flag even when already visible (the early-return in + // setEditorHidden would otherwise skip it). + this._editorRevealedExplicitly = true; + this.setEditorHidden(false, /* explicit */ true); + } + + getDockedAuxiliaryBarWidth(): number { + return 0; + } + + setDockedAuxiliaryBarWidth(_width: number): void { } + private layoutMobileSidebar(): void { const sidebarContainer = this.getContainer(mainWindow, Parts.SIDEBAR_PART); const sidebarPart = this.getPart(Parts.SIDEBAR_PART); @@ -1663,6 +1962,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic return; } + const resizeContext = this._prepareSideBarResize(hidden); + this.partVisibility.sidebar = !hidden; this.mainContainer.classList.toggle(LayoutClasses.SIDEBAR_HIDDEN, hidden); @@ -1672,6 +1973,8 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic !hidden, ); + this._applySideBarResize(hidden, resizeContext); + // If sidebar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); @@ -1690,7 +1993,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._savePartVisibility(); } - private setAuxiliaryBarHidden(hidden: boolean): void { + setAuxiliaryBarHidden(hidden: boolean): void { if (this.partVisibility.auxiliaryBar === !hidden) { return; } @@ -1699,14 +2002,12 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._restoreAttachedEditorMaximizedOnShow = false; } + this._onWillHideAuxiliaryBar(hidden); + this.partVisibility.auxiliaryBar = !hidden; this.mainContainer.classList.toggle(LayoutClasses.AUXILIARYBAR_HIDDEN, hidden); - // Propagate to grid - this.workbenchGrid.setViewVisible( - this.auxiliaryBarPartView, - !hidden, - ); + this._applyAuxiliaryBarVisibility(hidden); // If auxiliary bar becomes hidden, also hide the current active pane composite if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { @@ -1717,7 +2018,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { const paneCompositeToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar) ?? this.viewDescriptorService.getDefaultViewContainer(ViewContainerLocation.AuxiliaryBar)?.id; - if (paneCompositeToOpen) { + if (paneCompositeToOpen && this._shouldOpenAuxiliaryPaneComposite(paneCompositeToOpen)) { this.paneCompositeService.openPaneComposite(paneCompositeToOpen, ViewContainerLocation.AuxiliaryBar); } } @@ -1725,47 +2026,47 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic this._savePartVisibility(); } - private setEditorHidden(hidden: boolean): void { + /** + * Whether the given auxiliary-bar view container currently has content to show + * (mirrors `IViewsService.isViewContainerActive`: a `hideIfEmpty` container is + * only active once it has at least one active view descriptor). Used to avoid + * presenting an empty docked detail panel. + */ + protected _isAuxViewContainerActive(containerId: string): boolean { + const viewContainer = this.viewDescriptorService.getViewContainerById(containerId); + if (!viewContainer) { + return false; + } + if (!viewContainer.hideIfEmpty) { + return true; + } + return this.viewDescriptorService.getViewContainerModel(viewContainer).activeViewDescriptors.length > 0; + } + + setEditorHidden(hidden: boolean, explicit: boolean = false): void { if (this.partVisibility.editor === !hidden) { return; } - // If hiding the editor while maximized - if (hidden && this._editorMaximized) { - this.setEditorMaximized(false); - } + // Track whether this visible state was an explicit user reveal so R1 does + // not undo it. Any hide clears it; an automatic reveal leaves it false. + this._editorRevealedExplicitly = !hidden && explicit; - this.partVisibility.editor = !hidden; - this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); + this._runWithEditorResizeSyncSuspended(() => { + // If hiding the editor while maximized + if (hidden && this._editorMaximized) { + this.setEditorMaximized(false); + } + + this.partVisibility.editor = !hidden; + this.mainContainer.classList.toggle(LayoutClasses.MAIN_EDITOR_AREA_HIDDEN, hidden); - if (this.editorPartView) { - // Force an even 50/50 split only the *first* time the editor is - // revealed in this workbench instance. On later show/hide cycles the - // grid caches and restores the user-adjusted width, so we must not - // override it. The grid always seeds a cached visible size from the - // serialized descriptor, so it can't be used to detect the first - // reveal — a runtime flag is needed instead. - const shouldApplyEvenSplit = !hidden && !this._hasAppliedInitialEditorSplit; - - // Capture the sessions part width *before* revealing the editor. The - // editor is hidden (0px) right now, so the sessions part spans the - // whole main area; we split that in half below so the editor opens - // as an even split rather than at its minimum/restored width. - // Measuring after the reveal is unreliable because the grid first - // restores the editor to its cached/minimum width. - const mainAreaWidthBeforeReveal = shouldApplyEvenSplit - ? this.workbenchGrid.getViewSize(this.sessionsPartView).width - : 0; - - this.workbenchGrid.setViewVisible(this.editorPartView, !hidden); - - if (shouldApplyEvenSplit) { - this._hasAppliedInitialEditorSplit = true; - this._applyEditorSplitSize(mainAreaWidthBeforeReveal); + if (this.editorPartView) { + this._applyEditorVisibility(hidden); } - } - this._savePartVisibility(); + this._savePartVisibility(); + }); } /** @@ -1777,7 +2078,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic * editor was revealed (i.e. the full main area width, since the editor was * hidden). */ - private _applyEditorSplitSize(mainAreaWidth: number): void { + protected _applyEditorSplitSize(mainAreaWidth: number): void { const targetEditorWidth = Math.max(300, Math.floor(mainAreaWidth / 2)); const currentEditorSize = this.workbenchGrid.getViewSize(this.editorPartView); this.workbenchGrid.resizeView(this.editorPartView, { @@ -1872,6 +2173,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic //#region Size Methods getSize(part: Parts): IViewSize { + if (part === Parts.AUXILIARYBAR_PART) { + return this._auxiliaryBarViewSize(); + } const view = this.getPartView(part); if (!view) { return { width: 0, height: 0 }; @@ -1880,6 +2184,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } setSize(part: Parts, size: IViewSize): void { + if (part === Parts.AUXILIARYBAR_PART) { + this._setAuxiliaryBarViewSize(size); + return; + } const view = this.getPartView(part); if (view) { this.workbenchGrid.resizeView(view, size); @@ -1887,6 +2195,10 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic } resizePart(part: Parts, sizeChangeWidth: number, sizeChangeHeight: number): void { + if (part === Parts.AUXILIARYBAR_PART) { + this._resizeAuxiliaryBarBy(sizeChangeWidth, sizeChangeHeight); + return; + } const view = this.getPartView(part); if (!view) { return; @@ -1921,7 +2233,9 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic getMaximumEditorDimensions(_container: HTMLElement): IDimension { // Return the available space for editor (excluding other parts) const sidebarWidth = this.partVisibility.sidebar ? this.workbenchGrid.getViewSize(this.sideBarPartView).width : 0; - const auxiliaryBarWidth = this.partVisibility.auxiliaryBar ? this.workbenchGrid.getViewSize(this.auxiliaryBarPartView).width : 0; + const auxiliaryBarWidth = this.partVisibility.auxiliaryBar + ? this._auxiliaryBarLayoutWidth() + : 0; const panelHeight = this.partVisibility.panel ? this.workbenchGrid.getViewSize(this.panelPartView).height : 0; const titleBarHeight = this.workbenchGrid.getViewSize(this.titleBarPartView).height; @@ -2025,6 +2339,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic if (this.editorPartView && size) { this.workbenchGrid.resizeView(this.editorPartView, size); } + this._layoutSidePane(); } this._onDidChangeEditorMaximized.fire(); diff --git a/src/vs/sessions/browser/workbenchFactory.ts b/src/vs/sessions/browser/workbenchFactory.ts new file mode 100644 index 00000000000000..37bbe1489b39f0 --- /dev/null +++ b/src/vs/sessions/browser/workbenchFactory.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; +import { SyncDescriptor } from '../../platform/instantiation/common/descriptors.js'; +import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; +import { ILogService } from '../../platform/log/common/log.js'; +import { shouldUseSinglePaneLayout } from './parts/singlePaneEditorPart.js'; +import { SinglePaneWorkbench } from './singlePaneWorkbench.js'; +import { IWorkbenchOptions, Workbench } from './workbench.js'; + +/** + * Creates the Agents window workbench, choosing the single-pane (docked + * detail-panel) variant when the setting is enabled. The layout mode is fixed at + * construction — toggling the setting requires a window reload. + */ +export function createSessionsWorkbench(parent: HTMLElement, options: IWorkbenchOptions | undefined, serviceCollection: ServiceCollection, logService: ILogService): Workbench { + const configurationService = serviceCollection.get(IConfigurationService); + const singlePane = !(configurationService instanceof SyncDescriptor) + && shouldUseSinglePaneLayout(configurationService); + return singlePane + ? new SinglePaneWorkbench(parent, options, serviceCollection, logService) + : new Workbench(parent, options, serviceCollection, logService); +} diff --git a/src/vs/sessions/common/agentHostSessionsProvider.ts b/src/vs/sessions/common/agentHostSessionsProvider.ts index 8c2ba5f840c31b..e685785129015a 100644 --- a/src/vs/sessions/common/agentHostSessionsProvider.ts +++ b/src/vs/sessions/common/agentHostSessionsProvider.ts @@ -7,10 +7,10 @@ import { Event } from '../../base/common/event.js'; import { IObservable } from '../../base/common/observable.js'; import { equals } from '../../base/common/objects.js'; import { URI } from '../../base/common/uri.js'; -import { IAgentConnection } from '../../platform/agentHost/common/agentService.js'; +import { AuthenticateParams, AuthenticateResult, IAgentConnection } from '../../platform/agentHost/common/agentService.js'; import { RemoteAgentHostConnectionStatus } from '../../platform/agentHost/common/remoteAgentHostService.js'; import { ResolveSessionConfigResult, SessionConfigValueItem } from '../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, Customization, McpServerStatus, RootConfigState } from '../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, Customization, McpServerStatus, RootConfigState, type McpServerState } from '../../platform/agentHost/common/state/protocol/state.js'; import { ISessionsProvider } from '../services/sessions/common/sessionsProvider.js'; import { ISessionAgentRef } from '../services/sessions/common/session.js'; @@ -32,7 +32,12 @@ export interface IAgentHostMcpServer { readonly name: string; readonly enabled: boolean; readonly status: McpServerStatus; + readonly state: McpServerState; readonly logOutputChannelId?: string; + /** Starts or restarts the server. Providers that cannot control lifecycle may no-op. */ + start(): Promise<void>; + /** Stops the server. Providers that cannot control lifecycle may no-op. */ + stop(): Promise<void>; setEnabled(enabled: boolean): void; } @@ -130,6 +135,9 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { */ replaceRootConfig(values: Record<string, unknown>): Promise<void>; + /** Authenticate against the backing agent-host connection. */ + authenticate(params: AuthenticateParams): Promise<AuthenticateResult>; + // -- Custom Agents -- /** @@ -161,9 +169,9 @@ export interface IAgentHostSessionsProvider extends ISessionsProvider { /** * Returns the MCP servers exposed by the session as rich objects whose - * {@link IAgentHostMcpServer.setEnabled} dispatches the appropriate - * protocol-level toggle. Returns an empty array when the session is - * unknown or exposes no MCP servers. + * methods dispatch protocol-level toggle and lifecycle actions. + * Returns an empty array when the session is unknown or exposes no MCP + * servers. */ getMcpServers(sessionId: string): readonly IAgentHostMcpServer[]; diff --git a/src/vs/sessions/common/contextkeys.ts b/src/vs/sessions/common/contextkeys.ts index 9a03c5bab9fb52..bfc69d9d8f517b 100644 --- a/src/vs/sessions/common/contextkeys.ts +++ b/src/vs/sessions/common/contextkeys.ts @@ -27,13 +27,20 @@ export const SessionIsCreatedContext = new RawContextKey<boolean>('sessionIsCrea export const SessionIsStickyContext = new RawContextKey<boolean>('sessionIsSticky', false, localize('sessionIsSticky', "Whether the session view's session is sticky in the grid")); export const SessionIsMaximizedContext = new RawContextKey<boolean>('sessionIsMaximized', false, localize('sessionIsMaximized', "Whether the session view is currently maximized in the sessions part's grid")); export const SessionSupportsMultipleChatsContext = new RawContextKey<boolean>('sessionSupportsMultipleChats', false, localize('sessionSupportsMultipleChats', "Whether the session view's session supports multiple chats")); +export const SessionSupportsForkContext = new RawContextKey<boolean>('sessionSupportsFork', false, localize('sessionSupportsFork', "Whether the session view's session supports forking a chat from a turn into a new peer chat")); export const SessionHasMultipleCommittedChatsContext = new RawContextKey<boolean>('sessionHasMultipleCommittedChats', false, localize('sessionHasMultipleCommittedChats', "Whether the session view's session has more than one committed (non-draft) chat, which drives the Conversations menu visibility")); -export const SessionHasMultipleOpenChatsContext = new RawContextKey<boolean>('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat, i.e. the chat tab strip is shown. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionActiveChatHasSubagentsContext = new RawContextKey<boolean>('sessionActiveChatHasSubagents', false, localize('sessionActiveChatHasSubagents', "Whether the session view's currently-active chat has spawned subagent (tool-origin) chats, which are listed as a separate group in the Conversations menu")); +export const SessionShouldShowChatTabsContext = new RawContextKey<boolean>('sessionShouldShowChatTabs', false, localize('sessionShouldShowChatTabs', "Whether the session view's chat tab strip is shown, i.e. the session has more than one chat (counting closed chats) or its single remaining chat's title diverged from the session title. Used to hide the header New Chat button, which the tab strip then offers instead")); +export const SessionHasMultipleOpenChatsContext = new RawContextKey<boolean>('sessionHasMultipleOpenChats', false, localize('sessionHasMultipleOpenChats', "Whether the session view's session has more than one open chat (the tabs shown in the strip, including in-composer drafts). Used to scope chat-to-chat navigation (next/previous chat, the Ctrl+Tab chat switcher)")); +export const SessionActiveChatIsClosableContext = new RawContextKey<boolean>('sessionActiveChatIsClosable', false, localize('sessionActiveChatIsClosable', "Whether the session's active chat can be closed (hidden) from the tab strip, i.e. it is not the main chat. Includes read-only subagent chats. Used to scope the close-chat keybinding so it closes the tab instead of the session")); +export const SessionActiveChatIsDeletableContext = new RawContextKey<boolean>('sessionActiveChatIsDeletable', false, localize('sessionActiveChatIsDeletable', "Whether the session's active chat can be permanently deleted from the tab strip, i.e. it is a real, user-created non-main chat (not the main chat and not a tool-spawned subagent chat, which are transient children). Used to scope the delete-chat keybinding")); export const SessionIsReadContext = new RawContextKey<boolean>('sessionIsRead', true, localize('sessionIsRead', "Whether the session has been marked as read")); export const SessionIsArchivedContext = new RawContextKey<boolean>('sessionIsArchived', false, localize('sessionIsArchived', "Whether the session in scope is archived/marked as done (the active session globally, or a specific session within an isolated component such as the session view or a context menu overlay)")); export const SessionHasChangesContext = new RawContextKey<boolean>('sessionHasChanges', false, localize('sessionHasChanges', "Whether the session view's session has pending changes (insertions or deletions)")); export const SessionHasPullRequestContext = new RawContextKey<boolean>('sessionHasPullRequest', false, localize('sessionHasPullRequest', "Whether the session view's session is associated with a GitHub pull request")); export const SessionHasWorkspaceContext = new RawContextKey<boolean>('sessionHasWorkspace', false, localize('sessionHasWorkspace', "Whether the session view's session has an associated workspace folder")); +export const SessionHasTerminalsContext = new RawContextKey<boolean>('sessionHasTerminals', false, localize('sessionHasTerminals', "Whether the session view's session has one or more terminals that have had at least one command sent in them")); +export const IsQuickChatSessionContext = new RawContextKey<boolean>('isQuickChatSession', false, localize('isQuickChatSession', "Whether the session in scope is a workspace-less quick chat")); //#endregion @@ -75,6 +82,13 @@ export const SessionIsolationPickerVisibleContext = new RawContextKey<boolean>(' //#region < --- Sessions Picker --- > export const SessionsPickerVisibleContext = new RawContextKey<boolean>('sessionsPickerVisible', false, localize('sessionsPickerVisible', "Whether the sessions picker is visible")); +export const SessionChatsPickerVisibleContext = new RawContextKey<boolean>('sessionChatsPickerVisible', false, localize('sessionChatsPickerVisible', "Whether the chats picker (chats within the active session) is visible")); + +//#endregion + +//#region < --- Blocked Sessions --- > + +export const SessionsBlockedSessionsVisibleContext = new RawContextKey<boolean>('sessionsBlockedSessionsVisible', false, localize('sessionsBlockedSessionsVisible', "Whether the blocked-sessions dropdown (surfacing sessions that require input) is open in the sessions titlebar")); //#endregion @@ -94,6 +108,9 @@ export const CanGoForwardContext = new RawContextKey<boolean>('sessionsCanGoForw //#region < --- Editor --- > export const EditorMaximizedContext = new RawContextKey<boolean>('editorMaximized', false, localize('editorMaximized', "Whether the editor area is maximized")); +export const SinglePaneDetailChangesOrFilesActiveContext = new RawContextKey<boolean>('agentSessionsSinglePaneDetailChangesOrFiles', false, localize('agentSessionsSinglePaneDetailChangesOrFiles', "Whether the single-pane detail panel's active editor maps to the Changes or Files detail target")); +export const SinglePaneChangesTabMissingContext = new RawContextKey<boolean>('agentSessionsSinglePaneChangesTabMissing', false, localize('agentSessionsSinglePaneChangesTabMissing', "Whether the single-pane session supports a Changes editor but its tab is not currently open")); +export const SinglePaneFilesTabMissingContext = new RawContextKey<boolean>('agentSessionsSinglePaneFilesTabMissing', false, localize('agentSessionsSinglePaneFilesTabMissing', "Whether the single-pane session supports a Files tab but its tab is not currently open")); //#endregion diff --git a/src/vs/sessions/common/sessionConfig.ts b/src/vs/sessions/common/sessionConfig.ts index cc8590541a1da0..deda8edc5edf24 100644 --- a/src/vs/sessions/common/sessionConfig.ts +++ b/src/vs/sessions/common/sessionConfig.ts @@ -5,6 +5,14 @@ import type { ResolveSessionConfigResult } from '../../platform/agentHost/common/state/protocol/commands.js'; +/** + * When enabled, the Agents window docks the detail panel (auxiliary + * bar) inside the editor part so a single editor tab bar spans the full width + * across the editor content and the detail panel. Read once at startup; toggling + * requires a window reload. + */ +export const DOCK_DETAIL_PANEL_SETTING = 'sessions.layout.singlePaneDetailPanel'; + export function isSessionConfigComplete(config: ResolveSessionConfigResult): boolean { return (config.schema.required ?? []).every(property => config.values[property] !== undefined); } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts new file mode 100644 index 00000000000000..0f1b0180526512 --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentEditorCommentsProvider.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IAgentEditorComment, IAgentEditorCommentsBridge, IAgentEditorCommentsProvider } from '../../../../workbench/services/agentEditorComments/common/agentEditorComments.js'; +import { IAgentFeedbackService } from './agentFeedbackService.js'; +import { getSessionEditorComments, fromSessionEditorCommentId, SessionEditorCommentSource } from './sessionEditorComments.js'; + +/** + * Registers a provider with the workbench {@link IAgentEditorCommentsBridge} + * that surfaces the active session's comments (the same store the code editor + * renders from) to the extension host, so custom editors (e.g. the Markdown + * editor) can render and contribute the same comments. Lives in the sessions + * layer because the feedback service does. + */ +export class AgentEditorCommentsProviderContribution extends Disposable implements IWorkbenchContribution, IAgentEditorCommentsProvider { + + static readonly ID = 'workbench.contrib.agentEditorCommentsProvider'; + + readonly onDidChangeComments: Event<void>; + + constructor( + @IAgentFeedbackService private readonly _agentFeedbackService: IAgentFeedbackService, + @IAgentEditorCommentsBridge bridge: IAgentEditorCommentsBridge, + ) { + super(); + this.onDidChangeComments = Event.signal(this._agentFeedbackService.onDidChangeFeedback); + this._register(bridge.registerProvider(this)); + } + + acceptsComments(resource: URI): boolean { + return !!this._agentFeedbackService.getSessionForFile(resource); + } + + getComments(resource: URI): readonly IAgentEditorComment[] { + const session = this._agentFeedbackService.getSessionForFile(resource); + if (!session) { + return []; + } + const comments: IAgentEditorComment[] = []; + const sessionComments = getSessionEditorComments(session.resource, this._agentFeedbackService.getFeedback(session.resource)); + for (const comment of sessionComments) { + if (isEqual(comment.resourceUri, resource)) { + comments.push({ id: comment.id, range: comment.range, body: comment.text }); + } + } + return comments; + } + + addComment(resource: URI, range: IRange, body: string): void { + const session = this._agentFeedbackService.getSessionForFile(resource); + if (!session) { + return; + } + this._agentFeedbackService.addFeedback(session.resource, resource, range, body); + } + + deleteComment(resource: URI, id: string): void { + const session = this._agentFeedbackService.getSessionForFile(resource); + if (!session) { + return; + } + // Only agent feedback comments are surfaced to (and thus deletable from) + // custom editors; see `getComments`. + const parsed = fromSessionEditorCommentId(id); + if (parsed?.source !== SessionEditorCommentSource.AgentFeedback) { + return; + } + this._agentFeedbackService.removeFeedback(session.resource, parsed.sourceId); + } +} diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts index 5d00f5231247eb..54a3479b7ba127 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedback.contribution.ts @@ -17,6 +17,7 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from './agentFeedbackService.js'; import { AgentFeedbackAttachmentContribution } from './agentFeedbackAttachment.js'; +import { AgentEditorCommentsProviderContribution } from './agentEditorCommentsProvider.js'; import { AgentFeedbackPRThreadResolverContribution } from './agentFeedbackPRThreadResolver.js'; import { AgentFeedbackPRReviewSeederContribution } from './agentFeedbackPRReviewSeeder.js'; import { AgentFeedbackAttachmentWidget } from './agentFeedbackAttachmentWidget.js'; @@ -84,6 +85,7 @@ registerWorkbenchContribution2(AgentFeedbackEditorOverlay.ID, AgentFeedbackEdito registerWorkbenchContribution2(AgentFeedbackAttachmentContribution.ID, AgentFeedbackAttachmentContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackPRThreadResolverContribution.ID, AgentFeedbackPRThreadResolverContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentFeedbackPRReviewSeederContribution.ID, AgentFeedbackPRReviewSeederContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentEditorCommentsProviderContribution.ID, AgentEditorCommentsProviderContribution, WorkbenchPhase.BlockRestore); registerAgentFeedbackEditorActions(); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts index 22e67942c21071..fdaf7d3923f2f5 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorInputContribution.ts @@ -36,7 +36,12 @@ const addFeedbackAtCurrentLineActionId = 'agentFeedbackEditor.action.addAtCurren const agentFeedbackHoverGlyphClassName = 'agent-feedback-glyph'; const hasAgentFeedbackSessionForEditor = new RawContextKey<boolean>('agentFeedbackEditor.hasSession', false); -class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { +/** + * The inline "Add Feedback" input shown in the editor when the user selects a + * range to comment on. Exported so it can be rendered in a component fixture; + * it only depends on {@link ICodeEditor} for its layout geometry. + */ +export class AgentFeedbackInputWidget extends Disposable implements IOverlayWidget { private static readonly _ID = 'agentFeedback.inputWidget'; private static readonly _MIN_WIDTH = 150; @@ -571,6 +576,12 @@ export class AgentFeedbackEditorInputContribution extends Disposable implements // selection and opens the input for the freshly selected line. this._editor.setSelection(new Selection(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber))); this._editor.focus(); + + // Focusing the editor synchronously opens the input via the + // selection-change handler, so move focus into it now that it is + // visible. This lets the user type feedback immediately after clicking + // the gutter glyph without having to click the input first. + this.focusInput(); } private _getSessionForModel(): ISession | undefined { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts index 460ea4250818fa..4596ec52f1b915 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorOverlay.ts @@ -35,7 +35,7 @@ class SubmitFeedbackActionRunner extends ActionRunner { protected override async runAction(action: IAction, context?: unknown): Promise<void> { const editorToClose = action.id === submitFeedbackActionId ? this._editorGroup.activeEditor : undefined; const didSubmit = await action.run(context); - if (didSubmit === true && editorToClose && this._editorGroup.contains(editorToClose)) { + if (didSubmit === true && editorToClose) { await this._editorGroup.closeEditor(editorToClose); } } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts index cdd8a160141fa8..6ff2f8c18a7366 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackEditorWidgetContribution.ts @@ -430,8 +430,16 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid this._editor.layoutOverlayWidget(this); }; + const isPRComment = comment.source === SessionEditorCommentSource.PRReview; + const acceptTooltip = isPRComment + ? nls.localize('acceptPRFeedbackTooltip', "Share PR comment with agent") + : nls.localize('acceptAgentFeedbackTooltip', "Share comment with agent"); + const deleteTooltip = isPRComment + ? nls.localize('deletePRFeedbackTooltip', "Remove and mark as resolved on GitHub") + : nls.localize('deleteAgentFeedbackTooltip', "Remove agent comment"); + const acceptButton = buttonStore.add(new Button(buttonBar, { - title: nls.localize('acceptFeedbackButton', "Accept"), + title: acceptTooltip, buttonBackground: 'var(--vscode-charts-purple)', buttonHoverBackground: 'color-mix(in srgb, var(--vscode-charts-purple) 85%, var(--vscode-foreground))', buttonForeground: 'var(--vscode-button-foreground)', @@ -447,16 +455,16 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid dismiss(); })); - const removeButton = buttonStore.add(new Button(buttonBar, { - title: nls.localize('removeFeedbackButton', "Remove"), + const deleteButton = buttonStore.add(new Button(buttonBar, { + title: deleteTooltip, secondary: true, buttonSecondaryBackground: 'var(--vscode-button-secondaryBackground)', buttonSecondaryHoverBackground: 'var(--vscode-button-secondaryHoverBackground)', buttonSecondaryForeground: 'var(--vscode-button-secondaryForeground)', buttonSecondaryBorder: 'var(--vscode-button-secondaryBorder)', })); - removeButton.label = nls.localize('removeFeedbackButton', "Remove"); - buttonStore.add(removeButton.onDidClick(() => { + deleteButton.label = nls.localize('deleteFeedbackButton', "Delete"); + buttonStore.add(deleteButton.onDidClick(() => { this._removeComment(comment); dismiss(); })); @@ -1023,7 +1031,7 @@ export class AgentFeedbackEditorWidget extends Disposable implements IOverlayWid * Groups feedback items and creates combined widgets for nearby items. * Widgets start collapsed and expand when navigated to. */ -class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution { +export class AgentFeedbackEditorWidgetContribution extends Disposable implements IEditorContribution { static readonly ID = 'agentFeedback.editorWidgetContribution'; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts index b8cc17b07c72fc..31713c4bc9c95f 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackReviewCommands.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { Range, type IRange } from '../../../../editor/common/core/range.js'; import { localize } from '../../../../nls.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { AgentFeedbackReviewCommandId, IChatAgentFeedbackReviewComment } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -57,6 +59,31 @@ export function registerAgentFeedbackReviewCommands(): void { await feedbackService.revealFeedback(URI.revive(sessionResource), commentId); }); + CommandsRegistry.registerCommand(AgentFeedbackReviewCommandId.RevealAt, async (accessor, resourceUri: string, range: IRange): Promise<void> => { + const feedbackService = accessor.get(IAgentFeedbackService); + const resource = URI.parse(resourceUri); + // A rendered `addComment` tool call links here without knowing the + // session URI, so resolve the owning session from the file it commented + // on. Prefer the session the file belongs to (which falls back to the + // active session for in-scope files, so the "open file at range" + // affordance works even before the resource has accumulated feedback); + // fall back to the most recent session that has feedback for it. + const sessionResource = feedbackService.getSessionForFile(resource)?.resource + ?? feedbackService.getMostRecentSessionForResource(resource); + if (!sessionResource) { + return; + } + // Prefer revealing via the matching feedback item so its editor widget + // expands (the navigation anchor is set from the item id); fall back to + // opening the file at the range when no item matches. + const match = feedbackService.getFeedback(sessionResource).find(item => isEqual(item.resourceUri, resource) && Range.equalsRange(item.range, range)); + if (match) { + await feedbackService.revealFeedback(sessionResource, match.id); + } else { + await feedbackService.revealSessionComment(sessionResource, '', resource, range); + } + }); + CommandsRegistry.registerCommand(AgentFeedbackReviewCommandId.Delete, (accessor, sessionResource: UriComponents, commentId: string): void => { const feedbackService = accessor.get(IAgentFeedbackService); const codeReviewService = accessor.get(ICodeReviewService); diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts index 773968080fab0d..7028d35b068548 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackService.ts @@ -27,6 +27,7 @@ import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider import { AnnotationsAgentFeedbackItemsBackend, IAgentFeedbackItemsBackend, InMemoryAgentFeedbackItemsBackend } from './agentFeedbackItemsBackend.js'; import { ATTACHMENT_ID_PREFIX, createAgentFeedbackVariableEntry } from './agentFeedbackAttachmentEntry.js'; import { AgentFeedbackKind, AgentFeedbackState, type IAgentFeedback } from './agentFeedbackModel.js'; +import { SessionEditorCommentSource, toSessionEditorCommentId } from './sessionEditorComments.js'; // --- Types -------------------------------------------------------------------- @@ -549,7 +550,8 @@ export class AgentFeedbackService extends Disposable implements IAgentFeedbackSe if (!feedback) { return; } - await this.revealSessionComment(sessionResource, feedbackId, feedback.resourceUri, feedback.range); + // Anchor using the session-editor-comment id (not the raw feedback id) so the editor widget contribution matches the active item and expands its widget. + await this.revealSessionComment(sessionResource, toSessionEditorCommentId(SessionEditorCommentSource.AgentFeedback, feedbackId), feedback.resourceUri, feedback.range); } async revealSessionComment(sessionResource: URI, commentId: string, resourceUri: URI, range: IRange): Promise<void> { diff --git a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css index 693cc4bed55357..aced779633fd03 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css +++ b/src/vs/sessions/contrib/agentFeedback/browser/media/agentFeedbackEditorInput.css @@ -79,7 +79,8 @@ flex-shrink: 0; } -.agent-feedback-input-widget .agent-feedback-input-actions .action-bar .action-item .action-label { +.agent-feedback-input-widget .agent-feedback-input-actions .monaco-action-bar .action-item .action-label { + box-sizing: content-box; width: 16px; height: 16px; } diff --git a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts index 5624f43a545c11..15a081dd18278a 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/sessionEditorComments.ts @@ -5,7 +5,7 @@ import { IRange, Range } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; -import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackService.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback } from './agentFeedbackModel.js'; import { ICodeReviewSuggestion, IPRReviewComment, IPRReviewState, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; export const enum SessionEditorCommentSource { @@ -187,6 +187,22 @@ export function toSessionEditorCommentId(source: SessionEditorCommentSource, sou return `${source}:${sourceId}`; } +/** + * Inverse of {@link toSessionEditorCommentId}. Returns `undefined` when the id + * does not match the `${source}:${sourceId}` shape produced above. + */ +export function fromSessionEditorCommentId(id: string): { readonly source: SessionEditorCommentSource; readonly sourceId: string } | undefined { + const separatorIndex = id.indexOf(':'); + if (separatorIndex === -1) { + return undefined; + } + const source = id.slice(0, separatorIndex); + if (source !== SessionEditorCommentSource.AgentFeedback && source !== SessionEditorCommentSource.PRReview) { + return undefined; + } + return { source, sourceId: id.slice(separatorIndex + 1) }; +} + export function getAcceptedAgentFeedbackCommentCount(comments: readonly ISessionEditorComment[]): number { let count = 0; for (const comment of comments) { diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts index 34bb397cfd40aa..0f4133c06f0d06 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackEditorWidget.fixture.ts @@ -12,12 +12,14 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; import { IRange } from '../../../../../editor/common/core/range.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; -import { AgentFeedbackKind, IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; -import { AgentFeedbackEditorWidget } from '../../browser/agentFeedbackEditorWidgetContribution.js'; +import { AgentFeedbackKind, AgentFeedbackState, IAgentFeedback, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { AgentFeedbackEditorWidget, AgentFeedbackEditorWidgetContribution } from '../../browser/agentFeedbackEditorWidgetContribution.js'; import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { ICodeReviewService, ICodeReviewSuggestion } from '../../../codeReview/browser/codeReviewService.js'; import { createMockCodeReviewService } from '../../../../../workbench/test/browser/componentFixtures/sessions/mockCodeReviewService.js'; import { ISessionEditorComment, SessionEditorCommentSource } from '../../browser/sessionEditorComments.js'; +import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); const fileResource = URI.parse('inmemory://model/agent-feedback-widget.ts'); @@ -40,6 +42,20 @@ const sampleCode = [ '}', ].join('\n'); +const longSampleCode = Array.from({ length: 100 }, (_, i) => { + const line = i + 1; + if (line % 6 === 1) { + return `function fn${line}() {`; + } + if (line % 6 === 0) { + return '}'; + } + if (line % 6 === 5) { + return `\treturn value${line};`; + } + return `\tconst value${line} = ${line} + compute${line}();`; +}).join('\n'); + interface IFixtureOptions { readonly expanded?: boolean; readonly focusedCommentId?: string; @@ -221,6 +237,92 @@ function renderWidget(context: ComponentFixtureContext, options: IFixtureOptions } } +/** + * Renders the agent feedback widgets the same way production does: by + * instantiating the real {@link AgentFeedbackEditorWidgetContribution} and + * feeding it comments through the services. This exercises the production + * grouping (far-apart comments become separate widgets) and scroll handling + * (widgets follow their anchor line as the editor scrolls), which a directly + * constructed {@link AgentFeedbackEditorWidget} does not. + */ +function renderViaContribution(context: ComponentFixtureContext, code: string, comments: readonly ISessionEditorComment[]): void { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '420px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const feedback: readonly IAgentFeedback[] = comments.map(comment => ({ + id: comment.sourceId, + text: comment.text, + resourceUri: comment.resourceUri, + range: comment.range, + sessionResource: comment.sessionResource, + suggestion: comment.suggestion, + kind: comment.kind, + replies: comment.replies, + state: comment.state ?? AgentFeedbackState.Accepted, + })); + + const agentFeedbackService = new class extends mock<IAgentFeedbackService>() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + + override getSessionForFile(resourceUri: URI): ISession | undefined { + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return resourceUri.toString() === fileResource.toString() ? { resource: sessionResource } as ISession : undefined; + } + + override getFeedback(resource: URI): readonly IAgentFeedback[] { + return resource.toString() === sessionResource.toString() ? feedback : []; + } + + override getNavigationBearing() { + return { activeIdx: -1, totalCount: feedback.length }; + } + }(); + + const sessionsManagementService = new class extends mock<ISessionsManagementService>() { + override getSession(): ISession | undefined { + return undefined; + } + }(); + + const codeReviewService = createMockCodeReviewService(); + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(ISessionsManagementService, sessionsManagementService); + reg.defineInstance(ICodeReviewService, codeReviewService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + }, + }); + const model = scopedDisposables.add(createTextModel(instantiationService, code, fileResource, 'typescript')); + + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: true, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + { contributions: [] } + )); + + editor.setModel(model); + + // The contribution builds, groups, positions and keeps the widgets in sync + // with editor scroll — exactly as in production. + scopedDisposables.add(instantiationService.createInstance(AgentFeedbackEditorWidgetContribution, editor)); +} + const singleFeedback = [ createFeedbackComment('f-1', 'Prefer a clearer variable name on this line.', 2), ]; @@ -279,6 +381,11 @@ const allSourcesMixed = [ createPRReviewComment('pr-2', 'This logic duplicates what we have in utils.ts — consider reusing.', 8, 9), ]; +const longFileFeedback = [ + createFeedbackComment('lf-1', 'Consider validating this input before using it.', 5), + createFeedbackComment('lf-2', 'This computation is duplicated further down — extract a helper.', 20), +]; + export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { CollapsedSingleComment: defineComponentFixture({ labels: { kind: 'screenshot' }, @@ -392,4 +499,9 @@ export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { hidden: true, }), }), + + LongFileTwoComments: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderViaContribution(context, longSampleCode, longFileFeedback), + }), }); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts new file mode 100644 index 00000000000000..7dc65cc165342f --- /dev/null +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackInputWidget.fixture.ts @@ -0,0 +1,237 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Color } from '../../../../../base/common/color.js'; +import { Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ICodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorLayoutInfo } from '../../../../../editor/common/config/editorOptions.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { AgentFeedbackEditorInputContribution, AgentFeedbackInputWidget } from '../../browser/agentFeedbackEditorInputContribution.js'; +import { IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { ISession, ISessionFileChange } from '../../../../services/sessions/common/session.js'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import '../../../../../base/browser/ui/codicons/codiconStyles.js'; +import '../../browser/media/agentFeedbackEditorInput.css'; + +const sessionResource = URI.parse('vscode-agent-session://fixture/session-1'); +const fileResource = URI.parse('inmemory://model/agent-feedback-input.ts'); + +const sampleCode = [ + 'function alpha() {', + '\tconst first = 1;', + '\treturn first;', + '}', + '', + 'function beta() {', + '\tconst second = 2;', + '\tconst third = second + 1;', + '\treturn third;', + '}', +].join('\n'); + +function ensureTokenColorMap(): void { + if (TokenizationRegistry.getColorMap()?.length) { + return; + } + TokenizationRegistry.setColorMap([ + Color.fromHex('#000000'), + Color.fromHex('#d4d4d4'), + Color.fromHex('#9cdcfe'), + Color.fromHex('#ce9178'), + Color.fromHex('#b5cea8'), + Color.fromHex('#569cd6'), + Color.fromHex('#dcdcaa'), + ]); +} + +interface IInputFixtureOptions { + /** Initial text in the input. Empty renders the placeholder state. */ + readonly text?: string; + /** Placeholder to show — "Add Feedback" (has changes) vs "Add Comment". */ + readonly placeholder?: string; +} + +/** + * A minimal {@link ICodeEditor} stand-in for the standalone variants. The input + * widget only reads layout geometry ({@link ICodeEditor.getLayoutInfo}) and asks + * the editor to re-layout itself ({@link ICodeEditor.layoutOverlayWidget}), so a + * mock that provides just those two is enough to render it on its own. + */ +function createFakeEditor(): ICodeEditor { + return new class extends mock<ICodeEditor>() { + override getLayoutInfo(): EditorLayoutInfo { + // Only `width` and `contentLeft` are read (to clamp the input width). + // eslint-disable-next-line local/code-no-dangerous-type-assertions + return { width: 520, contentLeft: 64 } as EditorLayoutInfo; + } + override layoutOverlayWidget(): void { } + }(); +} + +/** Renders the input widget on its own — the widget's own DOM/CSS in isolation. */ +function renderInputWidget(context: ComponentFixtureContext, options: IInputFixtureOptions): void { + // The widget is `position: absolute`, so give it a positioned host with + // room, and let it flow statically so it is fully captured (not clipped). + context.container.style.position = 'relative'; + context.container.style.width = '520px'; + context.container.style.padding = '24px'; + context.container.style.background = 'var(--vscode-editor-background)'; + + const widget = context.disposableStore.add(new AgentFeedbackInputWidget(createFakeEditor())); + const domNode = widget.getDomNode(); + domNode.style.position = 'static'; + // When absolutely positioned (as in the editor) the widget shrinks to its + // content. Flowing it statically would instead stretch the flex container to + // the host width, so pin it to its content width to preserve the real layout. + domNode.style.width = 'fit-content'; + domNode.style.animation = 'none'; + context.container.appendChild(domNode); + + if (options.placeholder) { + widget.setPlaceholder(options.placeholder); + } + if (options.text) { + widget.inputElement.value = options.text; + } + + // Reveal (it starts hidden) and let it size itself + enable/disable actions + // exactly as the contribution does after mounting it. + widget.show(); + widget.updateActionEnabled(); + widget.autoSize(); +} + +/** A session whose feedback scopes {@link fileResource}, for the mock service. */ +function createFixtureSession(): ISession { + const changes = observableValue<readonly ISessionFileChange[]>('agentFeedbackFixtureChanges', []); + return new class extends mock<ISession>() { + override readonly resource = sessionResource; + override readonly changes = changes; + }(); +} + +/** + * Renders the input widget the way production does: by instantiating the real + * {@link AgentFeedbackEditorInputContribution} on a real editor and letting it + * create, show and position the widget. The contribution requires chat to be + * enabled and the file to be owned by a session, so both are stubbed here, then + * its public {@link AgentFeedbackEditorInputContribution.showAtCurrentLine} entry + * point (also used by the "add feedback at current line" command) is invoked to + * summon the box — exercising the real placement instead of re-implementing it. + */ +function renderInEditor(context: ComponentFixtureContext): Promise<void> { + const scopedDisposables = context.disposableStore.add(new DisposableStore()); + context.container.style.width = '760px'; + context.container.style.height = '260px'; + context.container.style.border = '1px solid var(--vscode-editorWidget-border)'; + context.container.style.background = 'var(--vscode-editor-background)'; + + ensureTokenColorMap(); + + const session = createFixtureSession(); + const agentFeedbackService = new class extends mock<IAgentFeedbackService>() { + override readonly onDidChangeFeedback = Event.None; + override readonly onDidChangeNavigation = Event.None; + override getSessionForFile(resourceUri: URI): ISession | undefined { + return isEqual(resourceUri, fileResource) ? session : undefined; + } + override getFeedback() { + return []; + } + }(); + + // The contribution only offers the input when chat is enabled. The fixtures' + // default MockContextKeyService reports every rule as unmatched, so provide a + // variant that reports the chat-enabled gate as satisfied. + const contextKeyService = new class extends MockContextKeyService { + override contextMatchesRules(): boolean { return true; } + }(); + + const instantiationService = createEditorServices(scopedDisposables, { + colorTheme: context.theme, + additionalServices: reg => { + reg.defineInstance(IAgentFeedbackService, agentFeedbackService); + reg.defineInstance(IContextKeyService, contextKeyService); + }, + }); + + const model = scopedDisposables.add(createTextModel(instantiationService, sampleCode, fileResource, 'typescript')); + const editor = scopedDisposables.add(instantiationService.createInstance( + CodeEditorWidget, + context.container, + { + automaticLayout: false, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 13, + lineHeight: 20, + }, + { contributions: [] }, + )); + editor.setModel(model); + // Lay out synchronously so the contribution positions/sizes the widget + // against real geometry (automaticLayout would settle asynchronously, after + // the box has already been placed against a zero-size editor). + editor.layout({ width: 760, height: 260 }); + + const contribution = scopedDisposables.add(instantiationService.createInstance(AgentFeedbackEditorInputContribution, editor)); + + // Put the cursor on the line to comment on and let the contribution create, + // show and position the input exactly as the command does in production. + editor.setPosition(new Position(7, 1)); + contribution.showAtCurrentLine(false); + + // Let the DOM settle, then trigger a layout change so the contribution + // re-measures and re-positions the (now measurable) widget against real + // geometry — this is the same reposition path it runs on editor resize. + return new Promise<void>(resolve => { + // this is fine in fixtures + // eslint-disable-next-line no-restricted-globals + requestAnimationFrame(() => { + editor.layout({ width: 759, height: 260 }); + editor.layout({ width: 760, height: 260 }); + resolve(); + }); + }); +} + +export default defineThemedFixtureGroup({ path: 'sessions/agentFeedback/' }, { + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, {}), + }), + + AddComment: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { placeholder: 'Add Comment' }), + }), + + WithText: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { text: 'Prefer a clearer variable name on this line.' }), + }), + + MultilineText: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInputWidget(context, { + text: 'This branch needs a stronger explanation.\nAlso consider extracting it into a helper so the intent is explicit.', + }), + }), + + InEditor: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: context => renderInEditor(context), + }), +}); diff --git a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts index 1b5353990de7fa..dc806720b6825a 100644 --- a/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts +++ b/src/vs/sessions/contrib/agentFeedback/test/browser/agentFeedbackService.test.ts @@ -12,6 +12,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { AgentFeedbackKind, AgentFeedbackService, AgentFeedbackState, IAgentFeedbackService } from '../../browser/agentFeedbackService.js'; +import { getSessionEditorComments } from '../../browser/sessionEditorComments.js'; import { IChatEditingService } from '../../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IAgentFeedbackVariableEntry } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; @@ -51,6 +52,7 @@ suite('AgentFeedbackService - Ordering', () => { instantiationService.stub(IEditorService, new class extends mock<IEditorService>() { override onDidVisibleEditorsChange = Event.None; override visibleEditorPanes = []; + override openEditor(..._args: unknown[]): Promise<undefined> { return Promise.resolve(undefined); } }); instantiationService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() { override getSession(_resource: URI) { return undefined; } @@ -191,6 +193,25 @@ suite('AgentFeedbackService - Ordering', () => { assert.strictEqual(bearing.activeIdx, 2); }); + test('revealFeedback anchors the matching session editor comment so its widget expands', async () => { + const f1 = service.addFeedback(session, fileA, r(5), 'A:5'); + const f2 = service.addFeedback(session, fileA, r(20), 'A:20'); + + // The editor widget contribution expands the widget whose session + // editor comment matches the navigation anchor. revealFeedback must set + // the anchor using the prefixed session-editor-comment id (not the raw + // feedback id) for that match to succeed. + await service.revealFeedback(session, f2.id); + + const comments = getSessionEditorComments(session, service.getFeedback(session)); + const bearing = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearing.activeIdx]?.sourceId, f2.id); + + await service.revealFeedback(session, f1.id); + const bearingAfter = service.getNavigationBearing(session, comments); + assert.strictEqual(comments[bearingAfter.activeIdx]?.sourceId, f1.id); + }); + test('removing feedback preserves ordering', () => { const f1 = service.addFeedback(session, fileA, r(30), 'A:30'); service.addFeedback(session, fileA, r(10), 'A:10'); diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts index fae6383be267be..c731937095b481 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationOverviewView.ts @@ -24,7 +24,7 @@ import { IPromptsService } from '../../../../workbench/contrib/chat/common/promp import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection, AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; -import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, toolsIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, automationIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, toolsIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; @@ -32,6 +32,8 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j import { IAgentPluginService } from '../../../../workbench/contrib/chat/common/plugins/agentPluginService.js'; import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE, countEnabledCustomizationTools, IAgentHostToolSetEnablementService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; const $ = DOM.$; @@ -80,6 +82,7 @@ export class AICustomizationOverviewView extends ViewPane { @IAgentPluginService private readonly agentPluginService: IAgentPluginService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @IAgentHostToolSetEnablementService private readonly toolEnablementService: IAgentHostToolSetEnablementService, + @IAutomationService private readonly automationService: IAutomationService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -88,11 +91,45 @@ export class AICustomizationOverviewView extends ViewPane { { id: AICustomizationManagementSection.Agents, label: localize('agents', "Agents"), icon: agentIcon, count: 0 }, { id: AICustomizationManagementSection.Skills, label: localize('skills', "Skills"), icon: skillIcon, count: 0 }, { id: AICustomizationManagementSection.Instructions, label: localize('instructions', "Instructions"), icon: instructionsIcon, count: 0 }, + ); + // Only show the tile when the setting is on (mirrors the management editor gate). + if (this._isAutomationsEnabled()) { + this.sections.push({ id: AICustomizationManagementSection.Automations, label: localize('automations', "Automations"), icon: automationIcon, count: 0 }); + } + this.sections.push( { id: AICustomizationManagementSection.McpServers, label: localize('mcpServers', "MCP Servers"), icon: mcpServerIcon, count: 0 }, { id: AICustomizationManagementSection.Plugins, label: localize('plugins', "Plugins"), icon: pluginIcon, count: 0 }, { id: AICustomizationManagementSection.Tools, label: localize('tools', "Tools"), icon: toolsIcon, count: 0 }, ); + // Re-render when the user toggles `chat.automations.enabled`, + // so the tile appears/disappears live without a reload. + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (!e.affectsConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING)) { + return; + } + const present = this.sections.some(s => s.id === AICustomizationManagementSection.Automations); + const desired = this._isAutomationsEnabled(); + if (present === desired) { + return; + } + if (desired) { + // Insert before McpServers to preserve the original order. + const mcpIdx = this.sections.findIndex(s => s.id === AICustomizationManagementSection.McpServers); + const insertAt = mcpIdx === -1 ? this.sections.length : mcpIdx; + this.sections.splice(insertAt, 0, { id: AICustomizationManagementSection.Automations, label: localize('automations', "Automations"), icon: automationIcon, count: 0 }); + } else { + const idx = this.sections.findIndex(s => s.id === AICustomizationManagementSection.Automations); + if (idx !== -1) { + this.sections.splice(idx, 1); + } + } + if (this.sectionsContainer) { + this.renderSections(); + void this.loadCounts(); + } + })); + // Listen to changes this._register(this.promptsService.onDidChangeCustomAgents(() => this.loadCounts())); this._register(this.promptsService.onDidChangeSlashCommands(() => this.loadCounts())); @@ -231,9 +268,23 @@ export class AICustomizationOverviewView extends ViewPane { })); } + // Update automation count reactively (no-ops when tile is hidden). + this._register(autorun(reader => { + const automations = this.automationService.automations.read(reader); + const automationSection = this.sections.find(s => s.id === AICustomizationManagementSection.Automations); + if (automationSection) { + automationSection.count = automations.length; + this.updateCountElements(); + } + })); + this.updateCountElements(); } + private _isAutomationsEnabled(): boolean { + return this.configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + } + private getSectionAriaLabel(section: ISectionSummary): string { return localize('overviewSectionAriaLabelWithCount', "{0}, {1} items", section.label, section.count); } diff --git a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts index d34502a2e6a4c4..b191406b6c994d 100644 --- a/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts +++ b/src/vs/sessions/contrib/aiCustomizationTreeView/browser/aiCustomizationTreeViewViews.ts @@ -29,9 +29,10 @@ import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IPromptsService, PromptsStorage, IAgentSkill, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { ResourceSet } from '../../../../base/common/map.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { agentIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, automationIcon, extensionIcon, instructionsIcon, mcpServerIcon, pluginIcon, promptIcon, skillIcon, userIcon, workspaceIcon, builtinIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { AICustomizationItemMenuId } from './aiCustomizationTreeView.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; import { AICustomizationPromptsStorage, BUILTIN_STORAGE } from '../../chat/common/builtinPromptsStorage.js'; import { AICustomizationManagementEditorInput } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditorInput.js'; import { AICustomizationManagementEditor } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.js'; @@ -321,6 +322,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource<RootElement, private readonly promptsService: IPromptsService, private readonly logService: ILogService, private readonly onItemCountChanged: (count: number) => void, + private readonly isAutomationsEnabled: () => boolean, ) { } /** @@ -363,7 +365,7 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource<RootElement, } private getTypeCategories(): (IAICustomizationTypeItem | IAICustomizationLinkItem)[] { - return [ + const items: (IAICustomizationTypeItem | IAICustomizationLinkItem)[] = [ { type: 'category', id: 'category-agents', @@ -385,6 +387,17 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource<RootElement, promptType: PromptsType.instructions, icon: instructionsIcon, }, + ]; + if (this.isAutomationsEnabled()) { + items.push({ + type: 'link', + id: 'link-automations', + label: localize('automations', "Automations"), + icon: automationIcon, + section: AICustomizationManagementSection.Automations, + }); + } + items.push( { type: 'link', id: 'link-mcp-servers', @@ -392,7 +405,8 @@ class UnifiedAICustomizationDataSource implements IAsyncDataSource<RootElement, icon: mcpServerIcon, section: AICustomizationManagementSection.McpServers, }, - ]; + ); + return items; } /** @@ -675,6 +689,7 @@ export class AICustomizationViewPane extends ViewPane { this.promptsService, this.logService, (count) => this.isEmptyContextKey.set(count === 0), + () => this.configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true, ); this.tree = this.treeDisposables.add(this.instantiationService.createInstance( diff --git a/src/vs/sessions/contrib/automations/browser/automationDialog.ts b/src/vs/sessions/contrib/automations/browser/automationDialog.ts new file mode 100644 index 00000000000000..ab45fa0977f68d --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationDialog.ts @@ -0,0 +1,777 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { InputBox } from '../../../../base/browser/ui/inputbox/inputBox.js'; +import { ISelectOptionItem, SelectBox } from '../../../../base/browser/ui/selectBox/selectBox.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { IAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; +import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultCheckboxStyles, defaultInputBoxStyles, defaultSelectBoxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { hasNativeContextMenu } from '../../../../platform/window/common/window.js'; +import { WorkspacePicker } from '../../chat/browser/sessionWorkspacePicker.js'; +import { ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../services/sessions/common/session.js'; +import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; +import { AutomationInterval } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { IAutomationSessionTypeChoice, IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { DAYS_OF_WEEK } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ILanguageModelsService } from '../../../../workbench/contrib/chat/common/languageModels.js'; +import { ChatAgentLocation, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; +import { AgentSessionProviders, AgentSessionTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatWidget, ISessionTypePickerDelegate } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputPart.js'; +import { isModeConsideredBuiltIn } from '../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; + +const $ = DOM.$; + +const INTERVALS: { readonly value: AutomationInterval; readonly label: string }[] = [ + { value: 'manual', label: localize('automation.interval.manual', "Manual") }, + { value: 'hourly', label: localize('automation.interval.hourly', "Hourly") }, + { value: 'daily', label: localize('automation.interval.daily', "Daily") }, + { value: 'weekly', label: localize('automation.interval.weekly', "Weekly") }, +]; + +// Popup containers (context views, quick picks, menus, hovers) must not trip the dialog's focus-trap. +export function isAutomationDialogPopupTarget(relatedTarget: HTMLElement): boolean { + return !!relatedTarget.closest( + '.context-view, .quick-input-widget, .monaco-menu-container, .monaco-hover, .monaco-hover-content' + ); +} + +export interface IFormState { + name: string; + interval: AutomationInterval; + hour: number; + minute: number; + day: number; + folderUri: URI | undefined; + providerId: string | undefined; + sessionTypeId: string | undefined; + isolationMode: string | undefined; + branch: string | undefined; + enabled: boolean; +} + +export interface IValidationState { + nameError: string | undefined; + promptError: string | undefined; + folderError: string | undefined; +} + +interface IRenderFormHandle { + readonly getPrompt: () => string; + readonly getMode: () => string | undefined; + readonly getPermissionLevel: () => string | undefined; + readonly getModelId: () => string | undefined; +} + +const AUTOMATIONS_HARNESS_CHIP_ACTION_ID = 'workbench.action.chat.renderAutomationsHarnessChip'; +const AUTOMATIONS_ISOLATION_GROUP_ACTION_ID = 'workbench.action.chat.renderAutomationsIsolationGroup'; + +function createAutomationHarnessChip(): HTMLElement { + const harnessChip = $('span.automation-form-harness-chip'); + DOM.append(harnessChip, renderIcon(Codicon.copilot)); + DOM.append(harnessChip, $('span.automation-form-harness-label', undefined, localize('automation.form.harness', "Copilot CLI"))); + return harnessChip; +} + +class AutomationHarnessChipActionViewItem extends BaseActionViewItem { + constructor(action: IAction, options?: IBaseActionViewItemOptions) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + DOM.clearNode(container); + DOM.append(container, createAutomationHarnessChip()); + } +} + +class AutomationIsolationGroupActionViewItem extends BaseActionViewItem { + private readonly renderDisposables = this._register(new DisposableStore()); + private readonly branchRepoDisposable = this._register(new MutableDisposable<IDisposable>()); + private branchRequestId = 0; + private folderChip: HTMLSpanElement | undefined; + private branchSlot: HTMLSpanElement | undefined; + + constructor( + action: IAction, + private readonly state: IFormState, + private readonly workspacePicker: AutomationsWorkspacePicker, + private readonly actionWidgetService: IActionWidgetService, + private readonly gitService: IGitService, + options?: IBaseActionViewItemOptions, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + this.renderDisposables.clear(); + this.branchRepoDisposable.clear(); + DOM.clearNode(container); + container.style.marginLeft = 'auto'; + + const isolationGroup = DOM.append(container, $('span.automation-form-isolation-group')); + this.folderChip = DOM.append(isolationGroup, $('span.automation-form-isolation-chip')) as HTMLSpanElement; + this.folderChip.setAttribute('role', 'button'); + this.folderChip.tabIndex = 0; + this.branchSlot = DOM.append(isolationGroup, $('span.automation-form-branch-slot')) as HTMLSpanElement; + this.branchSlot.setAttribute('aria-live', 'polite'); + + this.renderIsolationChip(); + this.renderBranchLabel(localize('automation.form.branch.unknown', "—"), true); + this.renderDisposables.add(this.workspacePicker.onDidSelectWorkspace(uri => { + this.updateBranchForFolder(uri); + })); + this.renderDisposables.add(DOM.addDisposableListener(this.folderChip, DOM.EventType.CLICK, (e) => { + DOM.EventHelper.stop(e, true); + this.showIsolationPicker(); + })); + this.renderDisposables.add(DOM.addDisposableListener(this.folderChip, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + DOM.EventHelper.stop(e, true); + this.showIsolationPicker(); + } + })); + this.updateBranchForFolder(this.state.folderUri); + } + + private renderIsolationChip(): void { + if (!this.folderChip) { + return; + } + DOM.clearNode(this.folderChip); + const isWorktree = this.state.isolationMode === 'worktree'; + const modeIcon = isWorktree ? Codicon.worktree : Codicon.folder; + const modeLabel = isWorktree + ? localize('automation.form.isolation.worktree', "Worktree") + : localize('automation.form.isolation.folder', "Folder"); + this.folderChip.setAttribute('aria-label', localize('automation.form.isolation.pickerAriaLabel', "Pick Isolation Mode, {0}", modeLabel)); + this.folderChip.title = modeLabel; + DOM.append(this.folderChip, renderIcon(modeIcon)); + DOM.append(this.folderChip, $('span.automation-form-isolation-label', undefined, modeLabel)); + } + + private renderBranchLabel(text: string, isMissing: boolean): void { + if (!this.branchSlot) { + return; + } + DOM.clearNode(this.branchSlot); + this.branchSlot.classList.toggle('automation-form-branch-missing', isMissing); + DOM.append(this.branchSlot, renderIcon(Codicon.gitBranch)); + DOM.append(this.branchSlot, $('span.automation-form-branch-name', undefined, text)); + } + + private showIsolationPicker(): void { + if (!this.folderChip || this.actionWidgetService.isVisible) { + return; + } + const currentMode = this.state.isolationMode ?? 'workspace'; + const items: IActionListItem<{ readonly mode: string; readonly checked?: boolean }>[] = [ + { + kind: ActionListItemKind.Action, + label: localize('automation.form.isolation.worktree', "Worktree"), + group: { title: '', icon: Codicon.worktree }, + item: { mode: 'worktree', checked: currentMode === 'worktree' || undefined }, + }, + { + kind: ActionListItemKind.Action, + label: localize('automation.form.isolation.folder', "Folder"), + group: { title: '', icon: Codicon.folder }, + item: { mode: 'workspace', checked: currentMode === 'workspace' || undefined }, + }, + ]; + const delegate: IActionListDelegate<{ readonly mode: string; readonly checked?: boolean }> = { + onSelect: ({ mode }) => { + this.actionWidgetService.hide(); + this.state.isolationMode = mode; + this.renderIsolationChip(); + }, + onHide: () => { + this.folderChip?.focus(); + }, + }; + this.actionWidgetService.show( + 'automationIsolationPicker', + false, + items, + delegate, + this.folderChip, + undefined, + [], + { + getAriaLabel: item => item.label ?? '', + getWidgetAriaLabel: () => localize('automation.form.isolation.widgetAriaLabel', "Isolation Mode"), + }, + ); + } + + private async updateBranchForFolder(folder: URI | undefined): Promise<void> { + const myRequestId = ++this.branchRequestId; + this.branchRepoDisposable.clear(); + if (!folder) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noFolder', "—"), true); + return; + } + let repo; + try { + repo = await this.gitService.openRepository(folder); + } catch { + if (myRequestId !== this.branchRequestId) { + return; + } + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noRepo', "no git repo"), true); + return; + } + if (myRequestId !== this.branchRequestId) { + return; + } + if (!repo) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noRepo', "no git repo"), true); + return; + } + const watcher = new DisposableStore(); + watcher.add(autorun(reader => { + const head = repo.state.read(reader).HEAD; + const name = head?.name; + if (name) { + this.state.branch = name; + this.renderBranchLabel(name, false); + } else if (head?.commit) { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.detached', "({0})", head.commit.slice(0, 7)), false); + } else { + this.state.branch = undefined; + this.renderBranchLabel(localize('automation.form.branch.noBranch', "—"), true); + } + })); + this.branchRepoDisposable.value = watcher; + } +} + +registerAction2(class OpenAutomationsHarnessChipAction extends Action2 { + constructor() { + super({ + id: AUTOMATIONS_HARNESS_CHIP_ACTION_ID, + title: localize2('automation.form.harnessChip.action', "Automations Harness Chip"), + f1: false, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.ChatInputSecondary, + group: 'navigation', + order: -1, + when: ChatContextKeys.inAutomationsDialog, + }], + }); + } + + override async run(): Promise<void> { /* handled by action view item */ } +}); + +registerAction2(class OpenAutomationsIsolationGroupAction extends Action2 { + constructor() { + super({ + id: AUTOMATIONS_ISOLATION_GROUP_ACTION_ID, + title: localize2('automation.form.isolationGroup.action', "Automations Isolation Group"), + f1: false, + precondition: ChatContextKeys.enabled, + menu: [{ + id: MenuId.ChatInputSecondary, + group: 'navigation', + order: 2, + when: ChatContextKeys.inAutomationsDialog, + }], + }); + } + + override async run(): Promise<void> { /* handled by action view item */ } +}); + +/** + * Two-way binding between the chat input's session-target chip and the form's + * providerId + sessionTypeId fields. + */ +function createSessionTypeBinder( + state: IFormState, + sessionTypeProvider: IAutomationSessionTypeProvider, + disposables: DisposableStore, +): ISessionTypePickerDelegate & { setFolder(folder: URI | undefined): void } { + const onDidChange = disposables.add(new Emitter<AgentSessionTarget>()); + + const pickDefault = (available: readonly IAutomationSessionTypeChoice[]): IAutomationSessionTypeChoice | undefined => { + if (available.length === 0) { + return undefined; + } + return available.find(c => c.sessionTypeId === AgentSessionProviders.Background) + ?? available[0]; + }; + + const validateOrDefault = (folder: URI | undefined): void => { + if (!folder) { + state.providerId = undefined; + state.sessionTypeId = undefined; + return; + } + const available = sessionTypeProvider.getSessionTypesForFolder(folder); + if (state.providerId && state.sessionTypeId) { + const match = available.find(c => c.providerId === state.providerId && c.sessionTypeId === state.sessionTypeId); + if (match) { + return; + } + } + const def = pickDefault(available); + state.providerId = def?.providerId; + state.sessionTypeId = def?.sessionTypeId; + }; + + validateOrDefault(state.folderUri); + + return { + getActiveSessionProvider: () => state.sessionTypeId as AgentSessionTarget | undefined, + setActiveSessionProvider: (target: AgentSessionTarget) => { + // Safe against folder-change races: we read the current folderUri and + // validate target against it. If the folder changed while the picker was + // open, the old target won't match the new folder's available types. + if (!state.folderUri) { + return; + } + const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri); + const match = available.find(c => c.sessionTypeId === target); + if (!match) { + return; + } + state.providerId = match.providerId; + state.sessionTypeId = match.sessionTypeId; + onDidChange.fire(match.sessionTypeId as AgentSessionTarget); + }, + onDidChangeActiveSessionProvider: onDidChange.event, + setFolder: (folder: URI | undefined) => { + validateOrDefault(folder); + if (state.sessionTypeId) { + onDidChange.fire(state.sessionTypeId as AgentSessionTarget); + } + }, + isSessionTypeVisible: (type: AgentSessionTarget) => { + if (type !== AgentSessionProviders.Background) { + return false; + } + if (!state.folderUri) { + return true; + } + const available = sessionTypeProvider.getSessionTypesForFolder(state.folderUri); + return available.some(c => c.sessionTypeId === type); + }, + }; +} + +export function renderForm( + form: HTMLElement, + state: IFormState, + options: IShowAutomationDialogOptions, + disposables: DisposableStore, + validation: IValidationState, + revalidate: () => void, + instantiationService: IInstantiationService, + contextKeyService: IContextKeyService, + contextViewService: IContextViewService, + configurationService: IConfigurationService, + layoutService: ILayoutService, + logService: ILogService, + productService: IProductService, + sessionTypeProvider: IAutomationSessionTypeProvider, + initialPrompt: string, + initialMode: string | undefined, + initialPermissionLevel: string | undefined, + initialModelId: string | undefined, +): IRenderFormHandle { + const nameRow = DOM.append(form, $('.automation-form-row')); + DOM.append(nameRow, $('span.automation-form-label', undefined, localize('automation.form.name', "Name"))); + const nameInputContainer = DOM.append(nameRow, $('.automation-form-input-host')); + const nameInput = disposables.add(new InputBox(nameInputContainer, contextViewService, { + inputBoxStyles: defaultInputBoxStyles, + placeholder: localize('automation.form.namePlaceholder', "e.g. Morning standup notes"), + ariaLabel: localize('automation.form.name', "Name"), + })); + nameInput.value = state.name; + disposables.add(nameInput.onDidChange(value => { + state.name = value; + revalidate(); + })); + + const scheduleRow = DOM.append(form, $('.automation-form-row.automation-form-schedule-row')); + const useCustomDrawn = !hasNativeContextMenu(configurationService); + + const intervalGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group')); + DOM.append(intervalGroup, $('label.automation-form-label', undefined, localize('automation.form.interval', "Schedule"))); + const intervalOptions: ISelectOptionItem[] = INTERVALS.map(item => ({ text: item.label })); + const intervalIndex = Math.max(0, INTERVALS.findIndex(item => item.value === state.interval)); + const intervalSelect = disposables.add(new SelectBox( + intervalOptions, + intervalIndex, + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.interval', "Schedule"), useCustomDrawn }, + )); + const intervalSelectContainer = DOM.append(intervalGroup, $('.automation-form-schedule-select-container')); + intervalSelect.render(intervalSelectContainer); + + const timeGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group.automation-form-time-group')); + DOM.append(timeGroup, $('label.automation-form-label', undefined, localize('automation.form.time', "Time"))); + const timeOptions = buildTimeOptions(); + const initialTimeIndex = nearestTimeOptionIndex(state.hour, state.minute); + state.hour = timeOptions[initialTimeIndex].hour; + state.minute = timeOptions[initialTimeIndex].minute; + const timeSelect = disposables.add(new SelectBox( + timeOptions.map(opt => ({ text: opt.label } satisfies ISelectOptionItem)), + initialTimeIndex, + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.time', "Time"), useCustomDrawn }, + )); + const timeSelectContainer = DOM.append(timeGroup, $('.automation-form-schedule-select-container.automation-form-time-select-container')); + timeSelect.render(timeSelectContainer); + disposables.add(timeSelect.onDidSelect(e => { + const opt = timeOptions[e.index]; + state.hour = opt.hour; + state.minute = opt.minute; + })); + + const dayGroup = DOM.append(scheduleRow, $('.automation-form-schedule-group.automation-form-day-group')); + DOM.append(dayGroup, $('label.automation-form-label', undefined, localize('automation.form.day', "Day of week"))); + const dayOptions: ISelectOptionItem[] = DAYS_OF_WEEK.map(d => ({ text: d })); + const daySelect = disposables.add(new SelectBox( + dayOptions, + Math.min(Math.max(state.day, 0), DAYS_OF_WEEK.length - 1), + contextViewService, + defaultSelectBoxStyles, + { ariaLabel: localize('automation.form.day', "Day of week"), useCustomDrawn }, + )); + const daySelectContainer = DOM.append(dayGroup, $('.automation-form-schedule-select-container')); + daySelect.render(daySelectContainer); + disposables.add(daySelect.onDidSelect(e => { + state.day = e.index; + })); + + const applyIntervalVisibility = () => { + const showTime = state.interval === 'daily' || state.interval === 'weekly'; + const showDay = state.interval === 'weekly'; + timeGroup.style.display = showTime ? '' : 'none'; + dayGroup.style.display = showDay ? '' : 'none'; + }; + applyIntervalVisibility(); + disposables.add(intervalSelect.onDidSelect(e => { + state.interval = INTERVALS[e.index].value; + applyIntervalVisibility(); + })); + + const sessionTypeBinder = createSessionTypeBinder(state, sessionTypeProvider, disposables); + + const workspacePicker = disposables.add(instantiationService.createInstance(AutomationsWorkspacePicker)); + + if (state.folderUri) { + workspacePicker.setSelectedWorkspace(state.folderUri, { fireEvent: false }); + } + + disposables.add(workspacePicker.onDidSelectWorkspace(uri => { + state.folderUri = uri; + sessionTypeBinder.setFolder(uri); + revalidate(); + })); + + if (!state.folderUri && workspacePicker.selectedFolderUri) { + state.folderUri = workspacePicker.selectedFolderUri; + sessionTypeBinder.setFolder(state.folderUri); + } + + const promptRow = DOM.append(form, $('.automation-form-row')); + DOM.append(promptRow, $('label.automation-form-label', undefined, localize('automation.form.prompt', "Prompt"))); + const promptHost = DOM.append(promptRow, $('.automation-form-prompt-host.interactive-session')); + + const chatInputStyles: IChatInputStyles = { + overlayBackground: 'var(--vscode-input-background)', + listForeground: 'var(--vscode-foreground)', + listBackground: 'var(--vscode-input-background)', + }; + + const chatInputOptions: IChatInputPartOptions = { + renderFollowups: false, + renderInputToolbarBelowInput: false, + renderWorkingSet: false, + enableImplicitContext: false, + supportsChangingModes: true, + hideCustomChatModes: true, + suppressModePreferredModel: true, + suppressModelPersistence: true, + menus: { + executeToolbar: MenuId.AutomationsDialogInput, + telemetrySource: 'automations.dialog', + }, + widgetViewKindTag: 'automations-dialog', + inputEditorMinLines: 3, + // The dialog renders the composer flush with its form column (the + // `.interactive-input-part` margin is zeroed in CSS), so there is no + // outer horizontal gutter. Without this, ChatInputPart would still + // reserve the default 24px margin and lay the editor out too narrow, + // leaving its scrollbar floating ~24px in from the right wall. + inputPartHorizontalPadding: 0, + sessionTypePickerDelegate: sessionTypeBinder, + workspacePickerInput: workspacePicker, + secondaryToolbarActionViewItemProvider: (action, itemOptions) => { + if (action.id === AUTOMATIONS_HARNESS_CHIP_ACTION_ID) { + return new AutomationHarnessChipActionViewItem(action, itemOptions); + } + if (action.id === AUTOMATIONS_ISOLATION_GROUP_ACTION_ID) { + const actionWidgetService = instantiationService.invokeFunction(accessor => accessor.get(IActionWidgetService)); + const gitService = instantiationService.invokeFunction(accessor => accessor.get(IGitService)); + return new AutomationIsolationGroupActionViewItem(action, state, workspacePicker, actionWidgetService, gitService, itemOptions); + } + return undefined; + }, + }; + + // Minimal subset of IChatWidget needed by ChatInputPart in dialog context + type IMinimalChatWidget = Pick<IChatWidget, 'onDidChangeViewModel' | 'viewModel' | 'contribs' | 'location' | 'viewContext' | 'lockToCodingAgent' | 'unlockFromCodingAgent'>; + + const stubWidget: IMinimalChatWidget = { + onDidChangeViewModel: Event.None, + viewModel: undefined, + contribs: [], + location: ChatAgentLocation.Chat, + viewContext: {}, + lockToCodingAgent: () => { }, + unlockFromCodingAgent: () => { }, + }; + + // Bind context keys required by chat input toolbar `when` clauses. + const scopedContextKeyService = disposables.add(contextKeyService.createScoped(promptHost)); + ChatContextKeys.location.bindTo(scopedContextKeyService).set(ChatAgentLocation.Chat); + ChatContextKeys.inChatSession.bindTo(scopedContextKeyService).set(true); + ChatContextKeys.inAutomationsDialog.bindTo(scopedContextKeyService).set(true); + const scopedInstantiationService = disposables.add( + instantiationService.createChild(new ServiceCollection([IContextKeyService, scopedContextKeyService])) + ); + + const chatInput = disposables.add( + scopedInstantiationService.createInstance(ChatInputPart, ChatAgentLocation.Chat, chatInputOptions, chatInputStyles, false), + ); + chatInput.render(promptHost, initialPrompt, stubWidget as IChatWidget); + chatInput.inputEditor.updateOptions({ placeholder: localize('automation.form.prompt.placeholder', "Describe what you want to automate") }); + + if (initialMode) { + const getUnfilteredInitialMode = () => { + const modes = chatInput.currentChatModesObs.get(); + return modes.findModeById(initialMode) ?? modes.findModeByName(initialMode); + }; + const isHiddenCustomInitialMode = () => { + const mode = getUnfilteredInitialMode(); + return !!mode && chatInputOptions.hideCustomChatModes && !isModeConsideredBuiltIn(mode, productService); + }; + + if (isHiddenCustomInitialMode()) { + logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}". Falling back to the default mode.`); + } else { + chatInput.setChatMode(initialMode, /* storeSelection */ false); + } + // Retry on cold-start when extension-contributed modes arrive late. + if (chatInput.currentModeObs.get().id !== initialMode && !isHiddenCustomInitialMode()) { + const retry = disposables.add(new MutableDisposable<IDisposable>()); + const tryApply = () => { + if (isHiddenCustomInitialMode()) { + logService.trace(`[AutomationDialog] Skipping hidden custom initial mode "${initialMode}" after modes updated. Falling back to the default mode.`); + retry.clear(); + return; + } + const modes = chatInput.currentChatModesObs.get(); + if (modes.findModeById(initialMode) || modes.findModeByName(initialMode)) { + chatInput.setChatMode(initialMode, /* storeSelection */ false); + if (chatInput.currentModeObs.get().id === initialMode) { + retry.clear(); + } + } + }; + retry.value = autorun(reader => { + const modes = chatInput.currentChatModesObs.read(reader); + reader.store.add(modes.onDidChange(tryApply)); + tryApply(); + }); + } + } + if (initialPermissionLevel && isChatPermissionLevel(initialPermissionLevel)) { + chatInput.setPermissionLevel(initialPermissionLevel); + } + // On edit, apply the saved model with late-arrival retry if needed. + chatInput.resetLanguageModelToDefault(/* storeSelection */ false); + + if (initialModelId && !chatInput.switchModelByIdentifier(initialModelId, /* storeSelection */ false)) { + const languageModelsService = instantiationService.invokeFunction(accessor => accessor.get(ILanguageModelsService)); + const baseline = chatInput.selectedLanguageModel.get()?.identifier; + const retry = disposables.add(new MutableDisposable<IDisposable>()); + retry.value = languageModelsService.onDidChangeLanguageModels(() => { + if (chatInput.selectedLanguageModel.get()?.identifier !== baseline) { + retry.clear(); + return; + } + if (chatInput.switchModelByIdentifier(initialModelId, /* storeSelection */ false)) { + retry.clear(); + } + }); + } + + disposables.add(chatInput.inputEditor.onDidChangeModelContent(() => { + revalidate(); + })); + + chatInput.layout(580); + queueMicrotask(() => { + if (!disposables.isDisposed) { + chatInput.layout(580); + } + }); + + const resizeObserver = disposables.add(new DOM.DisposableResizeObserver('automationDialog.promptHost', entries => { + for (const entry of entries) { + const width = entry.contentRect.width; + if (width > 0) { + chatInput.layout(width); + } + } + }, DOM.getWindow(promptHost))); + disposables.add(resizeObserver.observe(promptHost)); + + const enabledRow = DOM.append(form, $('.automation-form-row.automation-form-checkbox-row')); + const enabledLabelText = localize('automation.form.enabled', "Enabled (the scheduler runs this automation when due)"); + const enabledCheckbox = disposables.add(new Checkbox(enabledLabelText, state.enabled, defaultCheckboxStyles)); + DOM.append(enabledRow, enabledCheckbox.domNode); + const enabledLabel = DOM.append(enabledRow, $('span.automation-form-checkbox-label', undefined, enabledLabelText)); + const setEnabled = (value: boolean) => { + if (enabledCheckbox.checked !== value) { + enabledCheckbox.checked = value; + } + state.enabled = value; + }; + disposables.add(enabledCheckbox.onChange(() => { + state.enabled = enabledCheckbox.checked; + })); + disposables.add(DOM.addStandardDisposableListener(enabledLabel, 'click', () => { + setEnabled(!enabledCheckbox.checked); + })); + + return { + getPrompt: () => chatInput.inputEditor.getValue(), + getMode: () => chatInput.currentModeObs.get().id, + getPermissionLevel: () => chatInput.currentPermissionLevelObs.get(), + getModelId: () => chatInput.selectedLanguageModel.get()?.identifier, + }; +} + +interface ITimeOption { + readonly hour: number; + readonly minute: number; + readonly label: string; +} + +function buildTimeOptions(): readonly ITimeOption[] { + const options: ITimeOption[] = []; + for (let hour = 0; hour < 24; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const period = hour < 12 ? 'AM' : 'PM'; + const hour12 = hour === 0 ? 12 : (hour > 12 ? hour - 12 : hour); + const minuteText = minute.toString().padStart(2, '0'); + options.push({ + hour, + minute, + label: `${hour12}:${minuteText} ${period}`, + }); + } + } + return options; +} + +function nearestTimeOptionIndex(hour: number, minute: number): number { + const safeHour = Math.max(0, Math.min(23, hour | 0)); + const safeMinute = Math.max(0, Math.min(59, minute | 0)); + const slot = Math.round(safeMinute / 15) % 4; + const carriedHour = safeMinute >= 53 && slot === 0 ? (safeHour + 1) % 24 : safeHour; + return carriedHour * 4 + slot; +} + +export function updateSaveButtonState( + saveButton: IButton | undefined, + state: IFormState, + validation: IValidationState, + form: HTMLElement, + getPrompt: () => string, +): void { + validation.nameError = state.name.trim() === '' + ? localize('automation.form.nameRequired', "Name is required.") + : undefined; + validation.promptError = getPrompt().trim() === '' + ? localize('automation.form.promptRequired', "Prompt is required.") + : undefined; + validation.folderError = !state.folderUri + ? localize('automation.form.folderRequired', "Workspace folder is required.") + : undefined; + + const valid = !validation.nameError && !validation.promptError && !validation.folderError; + if (saveButton) { + saveButton.enabled = valid; + } + form.classList.toggle('automation-form-invalid', !valid); +} + +// Local-only workspace picker: hides category tabs and non-local browse actions. +class AutomationsWorkspacePicker extends WorkspacePicker { + protected override _showTabs(): boolean { + return false; + } + + protected override _getAllBrowseActions(): ISessionWorkspaceBrowseAction[] { + return super._getAllBrowseActions().filter(a => a.group === SESSION_WORKSPACE_GROUP_LOCAL); + } +} + +// Make Enter insert a newline in the dialog's editor (overrides ChatSubmitAction). +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: 'workbench.action.chat.automationsDialog.insertNewline', + weight: KeybindingWeight.EditorContrib + 100, + when: ContextKeyExpr.and( + EditorContextKeys.textInputFocus, + ChatContextKeys.inAutomationsDialog, + ), + primary: KeyCode.Enter, + handler: (accessor) => { + const editor = accessor.get(ICodeEditorService).getFocusedCodeEditor(); + editor?.trigger('keyboard', 'type', { text: '\n' }); + }, +}); diff --git a/src/vs/sessions/contrib/automations/browser/automationDialogService.ts b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts new file mode 100644 index 00000000000000..192f2c5b5e5e97 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationDialogService.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as DOM from '../../../../base/browser/dom.js'; +import { IButton } from '../../../../base/browser/ui/button/button.js'; +import { Dialog } from '../../../../base/browser/ui/dialog/dialog.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { createWorkbenchDialogOptions } from '../../../../workbench/browser/parts/dialogs/dialog.js'; +import { IAutomationSchedule } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { ICreateAutomationOptions, IUpdateAutomationOptions } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { SessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IHostService } from '../../../../workbench/services/host/browser/host.js'; +import { IFormState, IValidationState, isAutomationDialogPopupTarget, renderForm, updateSaveButtonState } from './automationDialog.js'; + +const $ = DOM.$; +const COPILOT_PROVIDER_ID = 'default-copilot'; + +/** + * Owns the Automations create/edit dialog in the sessions layer, where the + * session-type provider it needs already lives. The workbench list widget + * depends only on {@link IAutomationDialogService}. + */ +export class AutomationDialogService implements IAutomationDialogService { + + declare readonly _serviceBrand: undefined; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + @ILayoutService private readonly layoutService: ILayoutService, + @ILogService private readonly logService: ILogService, + @IProductService private readonly productService: IProductService, + @IHostService private readonly hostService: IHostService, + @IAutomationSessionTypeProvider private readonly sessionTypeProvider: IAutomationSessionTypeProvider, + ) { } + + async showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined> { + const disposables = new DisposableStore(); + + const initial = options.existing; + const isEdit = !!initial; + + const state: IFormState = { + name: initial?.name ?? '', + interval: initial?.schedule.interval ?? 'daily', + hour: initial?.schedule.scheduleHour ?? 9, + minute: initial?.schedule.scheduleMinute ?? 0, + day: initial?.schedule.scheduleDay ?? 1, + folderUri: initial?.folderUri, + providerId: initial?.providerId, + sessionTypeId: initial?.sessionTypeId, + isolationMode: initial?.isolationMode ?? 'workspace', + branch: initial?.branch, + enabled: initial?.enabled ?? true, + }; + + const validation: IValidationState = { nameError: undefined, promptError: undefined, folderError: undefined }; + + let saveButton: IButton | undefined; + let revalidate: () => void = () => { }; + let getPrompt: () => string = () => initial?.prompt ?? ''; + let getMode: () => string | undefined = () => initial?.mode; + let getPermissionLevel: () => string | undefined = () => initial?.permissionLevel; + let getModelId: () => string | undefined = () => initial?.modelId; + + const title = isEdit + ? localize('automation.dialog.editTitle', "Edit automation") + : localize('automation.dialog.createTitle', "New automation"); + + const buttonLabels = [ + isEdit ? localize('automation.dialog.save', "Save") : localize('automation.dialog.create', "Create"), + localize('automation.dialog.cancel', "Cancel"), + ]; + + const dialog = disposables.add(new Dialog( + this.layoutService.activeContainer, + title, + buttonLabels, + createWorkbenchDialogOptions({ + type: 'none', + extraClasses: ['automation-dialog'], + cancelId: 1, + isExternalFocusAllowed: isAutomationDialogPopupTarget, + // textLinkForeground stamps inline styles onto chat input picker chips. + dialogStyles: { ...defaultDialogStyles, textLinkForeground: undefined }, + buttonOptions: [ + { + styleButton: button => { + saveButton = button; + revalidate(); + }, + }, + ], + renderBody: container => { + container.classList.add('automation-dialog-body'); + + const titlebar = DOM.append(container, $('.automation-titlebar')); + titlebar.setAttribute('aria-hidden', 'true'); + titlebar.textContent = title; + + const description = DOM.append(container, $('.automation-description')); + description.textContent = isEdit + ? localize('automation.dialog.editDescription', "Update the schedule, prompt, or run target for this automation.") + : localize('automation.dialog.createDescription', "Define a prompt that Copilot will run on a schedule against the selected folder."); + + const formPane = DOM.append(container, $('.automation-form-pane')); + const form = DOM.append(formPane, $('.automation-form')); + const handle = renderForm(form, state, options, disposables, validation, () => revalidate(), this.instantiationService, this.contextKeyService, this.contextViewService, this.configurationService, this.layoutService, this.logService, this.productService, this.sessionTypeProvider, initial?.prompt ?? '', initial?.mode, initial?.permissionLevel, initial?.modelId); + getPrompt = handle.getPrompt; + getMode = handle.getMode; + getPermissionLevel = handle.getPermissionLevel; + getModelId = handle.getModelId; + revalidate = () => updateSaveButtonState(saveButton, state, validation, form, getPrompt); + revalidate(); + }, + }, this.keybindingService, this.layoutService, this.hostService), + )); + + try { + const result = await dialog.show(); + if (result.button !== 0) { + return undefined; + } + // Guard against submit-with-Enter bypassing live validation. + revalidate(); + if (validation.nameError || validation.promptError || validation.folderError) { + return undefined; + } + if (!state.folderUri) { + return undefined; + } + + const schedule: IAutomationSchedule = { + interval: state.interval, + scheduleHour: state.hour, + scheduleMinute: state.minute, + scheduleDay: state.day, + }; + + const prompt = getPrompt(); + const mode = getMode(); + const permissionLevel = getPermissionLevel(); + const modelId = getModelId(); + + if (isEdit && initial) { + const patch: IUpdateAutomationOptions = { + name: state.name, + prompt, + schedule, + folderUri: state.folderUri, + providerId: state.providerId ?? COPILOT_PROVIDER_ID, + sessionTypeId: state.sessionTypeId ?? SessionType.CopilotCLI, + modelId: modelId ?? null, + mode: mode ?? null, + permissionLevel: permissionLevel ?? null, + isolationMode: state.isolationMode ?? null, + branch: state.branch ?? null, + enabled: state.enabled, + }; + return { kind: 'update', id: initial.id, value: patch }; + } + + const create: ICreateAutomationOptions = { + name: state.name, + prompt, + schedule, + folderUri: state.folderUri, + providerId: state.providerId ?? COPILOT_PROVIDER_ID, + sessionTypeId: state.sessionTypeId ?? SessionType.CopilotCLI, + modelId, + mode, + permissionLevel, + isolationMode: state.isolationMode, + branch: state.branch, + enabled: state.enabled, + }; + return { kind: 'create', value: create }; + } finally { + disposables.dispose(); + } + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automationLeaderElection.ts b/src/vs/sessions/contrib/automations/browser/automationLeaderElection.ts new file mode 100644 index 00000000000000..cb82cfa72976c0 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationLeaderElection.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer } from '../../../../base/common/async.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +const LEADER_KEY = 'chat.automations.leader'; + +export const DEFAULT_HEARTBEAT_INTERVAL_MS = 30_000; + +// 3x heartbeat interval. Tolerates one missed write before failover. +export const DEFAULT_STALE_AFTER_MS = 90_000; + +interface ILeaderRecord { + readonly instanceId: string; + readonly heartbeatAt: number; + // Per-write nonce to detect races during leader claims. + readonly nonce: string; +} + +export interface IAutomationLeaderElectionOptions { + readonly heartbeatIntervalMs?: number; + readonly staleAfterMs?: number; + readonly now?: () => number; + readonly instanceId?: string; +} + +export class AutomationLeaderElection extends Disposable { + + private readonly _isLeader: ISettableObservable<boolean>; + readonly isLeader: IObservable<boolean>; + + private readonly _instanceId: string; + private readonly _heartbeatIntervalMs: number; + private readonly _staleAfterMs: number; + private readonly _now: () => number; + + private readonly _timer = this._register(new IntervalTimer()); + + constructor( + private readonly storageService: IStorageService, + private readonly logService: ILogService, + options: IAutomationLeaderElectionOptions = {}, + ) { + super(); + + this._instanceId = options.instanceId ?? generateUuid(); + this._heartbeatIntervalMs = options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS; + this._staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS; + this._now = options.now ?? Date.now; + + this._isLeader = observableValue<boolean>(this, false); + this.isLeader = this._isLeader; + + this._register(toDisposable(() => this.releaseIfLeader())); + + this.evaluate(); + + this._timer.cancelAndSet(() => this.evaluate(), this._heartbeatIntervalMs); + } + + get instanceId(): string { + return this._instanceId; + } + + /** Test-only: drive the evaluation cycle synchronously. */ + evaluateForTesting(): void { + this.evaluate(); + } + + private evaluate(): void { + const now = this._now(); + const current = this.readLeader(); + + const claimable = + !current || + current.instanceId === this._instanceId || + current.instanceId === '' || + (now - current.heartbeatAt) > this._staleAfterMs; + + if (!claimable) { + if (this._isLeader.get()) { + this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} stood down for ${current!.instanceId}.`); + } + this._isLeader.set(false, undefined); + return; + } + + // Write a nonce and verify we won by reading it back (narrows dual-leader window). + const nonce = generateUuid(); + const writeOk = this.writeLeader({ instanceId: this._instanceId, heartbeatAt: now, nonce }); + if (!writeOk) { + this._isLeader.set(false, undefined); + return; + } + const verify = this.readLeader(); + if (verify?.instanceId === this._instanceId && verify.nonce === nonce) { + if (!this._isLeader.get()) { + this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} claimed leader slot.`); + } + this._isLeader.set(true, undefined); + } else { + if (this._isLeader.get()) { + this.logService.info(`[AutomationLeaderElection] window ${this._instanceId} lost leader race to ${verify?.instanceId ?? '<none>'}.`); + } + this._isLeader.set(false, undefined); + } + } + + private readLeader(): ILeaderRecord | undefined { + let raw: string | undefined; + try { + raw = this.storageService.get(LEADER_KEY, StorageScope.APPLICATION); + } catch (err) { + this.logService.warn('[AutomationLeaderElection] storage read failed', err); + return undefined; + } + if (!raw) { + return undefined; + } + try { + const parsed = JSON.parse(raw) as ILeaderRecord; + if (typeof parsed?.instanceId !== 'string' || typeof parsed?.heartbeatAt !== 'number') { + return undefined; + } + // `nonce` is optional in older persisted records. Coerce to empty string. + return { instanceId: parsed.instanceId, heartbeatAt: parsed.heartbeatAt, nonce: typeof parsed.nonce === 'string' ? parsed.nonce : '' }; + } catch { + return undefined; + } + } + + /** Returns true if the write succeeded, false if storage threw. */ + private writeLeader(record: ILeaderRecord): boolean { + try { + this.storageService.store(LEADER_KEY, JSON.stringify(record), StorageScope.APPLICATION, StorageTarget.MACHINE); + return true; + } catch (err) { + this.logService.warn('[AutomationLeaderElection] storage write failed', err); + return false; + } + } + + // Write a tombstone on clean shutdown so the next window can claim immediately. + private releaseIfLeader(): void { + const current = this.readLeader(); + if (current?.instanceId !== this._instanceId) { + return; + } + this.writeLeader({ instanceId: '', heartbeatAt: 0, nonce: '' }); + } +} + +export interface IAutomationLeaderElection extends IDisposable { + readonly isLeader: IObservable<boolean>; + readonly instanceId: string; + evaluateForTesting(): void; +} diff --git a/src/vs/sessions/contrib/automations/browser/automationRunner.ts b/src/vs/sessions/contrib/automations/browser/automationRunner.ts new file mode 100644 index 00000000000000..0c09b6738c5eab --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationRunner.ts @@ -0,0 +1,138 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { AutomationRunTrigger, IAutomation } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { publishAutomationRun, publishAutomationRunError } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; +import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; + +/** Sessions-layer runner. Never throws; failures are recorded on the run row. */ +export class AutomationRunner implements IAutomationRunner { + + declare readonly _serviceBrand: undefined; + + constructor( + @IAutomationService private readonly automationService: IAutomationService, + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + @INotificationService private readonly notificationService: INotificationService, + ) { } + + async runOnce( + automation: IAutomation, + trigger: AutomationRunTrigger, + leaderWindowId: number, + token: CancellationToken = CancellationToken.None, + ): Promise<void> { + // Must not throw per IAutomationRunner contract. Unexpected errors are swallowed here. + try { + await this._runOnceInner(automation, trigger, leaderWindowId, token); + } catch (err) { + this.logService.error(`[AutomationRunner] unexpected error in runOnce for ${automation.id}`, err); + } + } + + private async _runOnceInner( + automation: IAutomation, + trigger: AutomationRunTrigger, + leaderWindowId: number, + token: CancellationToken, + ): Promise<void> { + if (this.automationService.getActiveRunFor(automation.id)) { + this.logService.trace(`[AutomationRunner] skipping ${automation.id}: active run already exists.`); + return; + } + + const startTimeMs = Date.now(); + let runId: string | undefined; + try { + if (!this.automationService.getAutomation(automation.id)) { + this.logService.trace(`[AutomationRunner] skipping ${automation.id}: automation was deleted.`); + return; + } + + const run = await this.automationService.recordRunStart(automation.id, trigger, leaderWindowId); + runId = run.id; + await this.automationService.updateRun(runId, { status: 'running' }); + + if (token.isCancellationRequested) { + await this._markCancelled(runId, trigger, automation, startTimeMs); + return; + } + + const options: ISendRequestOptions = { + query: automation.prompt, + background: true, + title: automation.name?.substring(0, 100), + }; + + const createOptions: ICreateNewSessionOptions | undefined = automation.providerId !== undefined || automation.sessionTypeId !== undefined || automation.modelId !== undefined || automation.mode !== undefined || automation.permissionLevel !== undefined || automation.isolationMode !== undefined || automation.branch !== undefined + ? { + providerId: automation.providerId, + sessionTypeId: automation.sessionTypeId, + modelId: automation.modelId, + modeId: automation.mode, + permissionLevel: automation.permissionLevel, + isolationMode: automation.isolationMode, + branch: automation.branch, + } + : undefined; + + this.logService.trace(`[AutomationRunner] running ${automation.id}: provider=${createOptions?.providerId ?? '(default)'}, sessionType=${createOptions?.sessionTypeId ?? '(default)'}, model=${createOptions?.modelId ?? '(default)'}, mode=${createOptions?.modeId ?? '(default)'}, permissionLevel=${createOptions?.permissionLevel ?? '(default)'}`); + + const session = await this.sessionsManagementService.createAndSendNewChatRequest(automation.folderUri, options, createOptions); + + // Re-check cancellation post-send so mid-flight timeouts surface as `failed`. + if (token.isCancellationRequested) { + await this._markCancelled(runId, trigger, automation, startTimeMs); + return; + } + + await this.automationService.updateRun(runId, { + status: 'completed', + completedAt: new Date().toISOString(), + ...(session ? { sessionResource: session.resource.toString() } : {}), + }); + publishAutomationRun(this.telemetryService, { trigger, automation, success: true, durationMs: Date.now() - startTimeMs }); + } catch (err) { + this.logService.error(`[AutomationRunner] run for ${automation.id} failed`, err); + try { + const errorMessage = err instanceof Error ? err.message : String(err); + this.notificationService.error(localize('automationRunFailed', "Automation '{0}' failed: {1}", automation.name, errorMessage)); + if (runId) { + await this.automationService.updateRun(runId, { + status: 'failed', + completedAt: new Date().toISOString(), + errorMessage, + }); + } + publishAutomationRun(this.telemetryService, { trigger, automation, success: false, durationMs: Date.now() - startTimeMs }); + publishAutomationRunError(this.telemetryService, { trigger, automation }); + } catch (innerErr) { + this.logService.error(`[AutomationRunner] error recording failure for ${automation.id}`, innerErr); + } + } + } + + private async _markCancelled(runId: string, trigger: AutomationRunTrigger, automation: IAutomation, startTimeMs: number): Promise<void> { + try { + await this.automationService.updateRun(runId, { + status: 'failed', + completedAt: new Date().toISOString(), + errorMessage: localize('automationRunner.cancelled', "Cancelled"), + }); + publishAutomationRun(this.telemetryService, { trigger, automation, success: false, durationMs: Date.now() - startTimeMs }); + } catch (err) { + this.logService.error(`[AutomationRunner] error recording cancellation for ${automation.id}`, err); + } + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automationScheduler.ts b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts new file mode 100644 index 00000000000000..d6681e3be8f06e --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationScheduler.ts @@ -0,0 +1,255 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IntervalTimer, raceTimeout } from '../../../../base/common/async.js'; +import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; +import { stringHash } from '../../../../base/common/hash.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { IAutomation } from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING, CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING, DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { AutomationLeaderElection, IAutomationLeaderElection } from './automationLeaderElection.js'; + +export const DEFAULT_SCHEDULER_TICK_MS = 60_000; + +// Stored on stale run rows on leader startup. +export const CRASH_RECOVERY_REASON = localize('automations.crashRecoveryReason', "Interrupted by app shutdown"); + +export const RUN_TIMEOUT_REASON_PREFIX = 'Timed out after'; + +export interface IAutomationSchedulerCoreOptions { + readonly tickIntervalMs?: number; + readonly now?: () => Date; + readonly leaderElection?: IAutomationLeaderElection; + readonly disableAutoTick?: boolean; + readonly isFeatureEnabled?: () => boolean; + readonly getRunTimeoutMs?: () => number; +} + +export class AutomationSchedulerCore extends Disposable { + + private readonly _leader: IAutomationLeaderElection; + + private readonly _tickIntervalMs: number; + private readonly _now: () => Date; + private readonly _isFeatureEnabled: () => boolean; + private readonly _getRunTimeoutMs: () => number; + + private readonly _timer = this._register(new IntervalTimer()); + private readonly _runCts = this._register(new CancellationTokenSource()); + + // Reset on leadership loss so a future take-over re-runs crash recovery. + private _didStartupForCurrentLeadership = false; + + private _pendingRuns: Promise<unknown> = Promise.resolve(); + + constructor( + private readonly automationService: IAutomationService, + private readonly runner: IAutomationRunner, + storageService: IStorageService, + private readonly logService: ILogService, + options: IAutomationSchedulerCoreOptions = {}, + ) { + super(); + + this._tickIntervalMs = options.tickIntervalMs ?? DEFAULT_SCHEDULER_TICK_MS; + this._now = options.now ?? (() => new Date()); + this._isFeatureEnabled = options.isFeatureEnabled ?? (() => true); + this._getRunTimeoutMs = options.getRunTimeoutMs ?? (() => DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES * 60_000); + + this._leader = options.leaderElection ?? this._register(new AutomationLeaderElection(storageService, logService)); + + this._register(autorun(reader => { + const isLeader = this._leader.isLeader.read(reader); + if (!isLeader) { + this._didStartupForCurrentLeadership = false; + return; + } + this.kickoffPendingRuns(() => this.tickOnce(true)); + })); + + if (!options.disableAutoTick) { + this._timer.cancelAndSet(() => { + this.kickoffPendingRuns(() => this.tickOnce(false)); + }, this._tickIntervalMs); + } + } + + /** Test-only: run a single tick and await it. */ + async tickForTesting(): Promise<void> { + this.kickoffPendingRuns(() => this.tickOnce(false)); + await this._pendingRuns; + } + + /** Test-only: await in-flight runs. */ + async waitForPendingRuns(): Promise<void> { + await this._pendingRuns; + } + + private kickoffPendingRuns(task: () => Promise<void>): void { + if (this._store.isDisposed) { + return; + } + // Serialized: only one dispatch at a time. Runs execute sequentially by design. + this._pendingRuns = this._pendingRuns.then(task).catch(err => { + this.logService.error('[AutomationScheduler] tick failed', err); + }); + } + + private async tickOnce(isLeadershipTransition: boolean): Promise<void> { + if (!this._leader.isLeader.get()) { + return; + } + + if (!this._isFeatureEnabled()) { + return; + } + + if (!this._didStartupForCurrentLeadership) { + this._didStartupForCurrentLeadership = true; + await this.automationService.markStaleRunsFailed(CRASH_RECOVERY_REASON); + await this.dispatchDue('catch_up'); + if (isLeadershipTransition) { + return; + } + } + + await this.dispatchDue('schedule'); + } + + private async dispatchDue(trigger: 'schedule' | 'catch_up'): Promise<void> { + const now = this._now(); + const due = this.automationService.automations.get().filter(a => isDue(a, now)); + if (due.length === 0) { + return; + } + + const leaderWindowId = stringHash(this._leader.instanceId, 0); + for (const automation of due) { + try { + await this.automationService.advanceNextRunAt(automation.id, now); + await this.runOneWithTimeout(automation, trigger, leaderWindowId); + } catch (err) { + this.logService.error('[AutomationScheduler] dispatch failed for automation', automation.id, err); + } + } + } + + private async runOneWithTimeout(automation: IAutomation, trigger: 'schedule' | 'catch_up', leaderWindowId: number): Promise<void> { + const timeoutMs = this._getRunTimeoutMs(); + const perRunCts = new CancellationTokenSource(this._runCts.token); + try { + if (timeoutMs <= 0) { + await this.runner.runOnce(automation, trigger, leaderWindowId, perRunCts.token); + return; + } + + let timedOut = false; + await raceTimeout( + this.runner.runOnce(automation, trigger, leaderWindowId, perRunCts.token), + timeoutMs, + () => { + timedOut = true; + this.logService.warn(`[AutomationScheduler] runOnce for automation ${automation.id} timed out after ${timeoutMs}ms; cancelling.`); + perRunCts.cancel(); + }, + ); + + if (!timedOut) { + return; + } + + // Best-effort: mark the active run row as failed so the UI doesn't show "running" forever. + try { + const active = this.automationService.getActiveRunFor(automation.id); + if (active) { + await this.automationService.updateRun(active.id, { + status: 'failed', + errorMessage: localize('automation.timedOut', "Timed out after {0} minute(s).", Math.round(timeoutMs / 60_000)), + completedAt: this._now().toISOString(), + }); + } + } catch (err) { + this.logService.warn('[AutomationScheduler] failed to mark timed-out run as failed', err); + } + } finally { + perRunCts.dispose(); + } + } + + override dispose(): void { + this._runCts.cancel(); + super.dispose(); + } +} + +// DI wrapper. Tests construct AutomationSchedulerCore directly. +export class AutomationScheduler extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.automationScheduler'; + + private readonly _core = this._register(new MutableDisposable<AutomationSchedulerCore>()); + + constructor( + @IAutomationService private readonly _automationService: IAutomationService, + @IAutomationRunner private readonly _runner: IAutomationRunner, + @IStorageService private readonly _storageService: IStorageService, + @ILogService private readonly _logService: ILogService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + if (this._isEnabled()) { + this._createCore(); + } + this._register(_configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING)) { + if (this._isEnabled()) { + this._createCore(); + } else { + this._core.clear(); + } + } + })); + } + + private _isEnabled(): boolean { + return this._configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + } + + private _createCore(): void { + if (this._core.value) { + return; + } + this._core.value = new AutomationSchedulerCore(this._automationService, this._runner, this._storageService, this._logService, { + isFeatureEnabled: () => this._isEnabled(), + getRunTimeoutMs: () => { + const minutes = this._configurationService.getValue<number>(CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING); + const sane = typeof minutes === 'number' && Number.isFinite(minutes) && minutes >= 1 + ? minutes + : DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES; + return sane * 60_000; + }, + }); + } +} + +function isDue(automation: IAutomation, now: Date): boolean { + if (!automation.enabled || !automation.nextRunAt) { + return false; + } + const next = Date.parse(automation.nextRunAt); + if (Number.isNaN(next)) { + return false; + } + return next <= now.getTime(); +} + diff --git a/src/vs/sessions/contrib/automations/browser/automationService.ts b/src/vs/sessions/contrib/automations/browser/automationService.ts new file mode 100644 index 00000000000000..9597749e3dfbae --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationService.ts @@ -0,0 +1,465 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI, UriComponents } from '../../../../base/common/uri.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { + AutomationRunTrigger, + IAutomation, + IAutomationRun, +} from '../../../../workbench/contrib/chat/common/automations/automation.js'; +import { + IAutomationService, + ICreateAutomationOptions, + IUpdateAutomationOptions, + IUpdateAutomationRunOptions, +} from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { publishAutomationCreated, publishAutomationDeleted, publishAutomationUpdated } from '../../../../workbench/contrib/chat/common/automations/automationTelemetry.js'; +import { computeNextRunAt } from '../../../../workbench/contrib/chat/common/automations/schedule.js'; +import { ChatPermissionLevel, isChatPermissionLevel } from '../../../../workbench/contrib/chat/common/constants.js'; + +// APPLICATION scope, non-roaming. +const STORAGE_KEY = 'chat.automations.ledger'; + +const CURRENT_SCHEMA_VERSION = 1; + +const MAX_RUNS_PER_AUTOMATION = 50; + +const VALID_ISOLATION_MODES = new Set(['worktree', 'workspace']); + +interface ISerializedAutomation { + readonly id: string; + readonly name: string; + readonly prompt: string; + readonly schedule: IAutomation['schedule']; + readonly folderUri: UriComponents; + readonly providerId?: string; + readonly sessionTypeId?: string; + readonly modelId?: string; + readonly mode?: string; + readonly permissionLevel?: string; + readonly isolationMode?: string; + readonly branch?: string; + readonly enabled: boolean; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastRunAt?: string; + readonly nextRunAt?: string; +} + +interface ISerializedLedger { + readonly schemaVersion: number; + // Optimistic-concurrency counter. 0 for legacy blobs without this field. + readonly revision?: number; + readonly automations: readonly ISerializedAutomation[]; + readonly runs: readonly IAutomationRun[]; +} + +interface ILedger { + readonly automations: readonly IAutomation[]; + readonly runs: readonly IAutomationRun[]; +} + +const EMPTY_LEDGER: ILedger = Object.freeze({ automations: [], runs: [] }); + +type ReadLedgerResult = + | { kind: 'ledger'; ledger: ILedger; revision: number } + | { kind: 'unsupportedSchema' }; + +export class AutomationService extends Disposable implements IAutomationService { + + declare readonly _serviceBrand: undefined; + + private readonly _automations: ISettableObservable<readonly IAutomation[]>; + private readonly _runs: ISettableObservable<readonly IAutomationRun[]>; + private _now: () => Date; + private readonly _runsForCache = new Map<string, IObservable<readonly IAutomationRun[]>>(); + + // Set when on-disk schema is newer than this build. Prevents writes that would destroy data. + private _unsupportedSchema = false; + + private _lastSeenRevision = 0; + + readonly automations: IObservable<readonly IAutomation[]>; + readonly runs: IObservable<readonly IAutomationRun[]>; + + constructor( + @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + + this._now = () => new Date(); + + const result = this.readLedger(); + const initial = result.kind === 'ledger' ? result.ledger : EMPTY_LEDGER; + if (result.kind === 'ledger') { + this._lastSeenRevision = result.revision; + } + this._automations = observableValue<readonly IAutomation[]>(this, initial.automations); + this._runs = observableValue<readonly IAutomationRun[]>(this, initial.runs); + this.automations = this._automations; + this.runs = this._runs; + + this._register(this.storageService.onDidChangeValue(StorageScope.APPLICATION, STORAGE_KEY, this._store)(() => { + this.refreshFromStorage(); + })); + + this._register(this.storageService.onWillSaveState(() => { + this.persist(this._automations.get(), this._runs.get()); + })); + } + + /** Test-only: swap in a deterministic clock used by create/update. */ + setClockForTesting(now: () => Date): void { + this._now = now; + } + + getAutomation(id: string): IAutomation | undefined { + return this._automations.get().find(a => a.id === id); + } + + runsFor(automationId: string): IObservable<readonly IAutomationRun[]> { + let cached = this._runsForCache.get(automationId); + if (!cached) { + cached = derived(this, reader => this._runs.read(reader).filter(r => r.automationId === automationId)); + this._runsForCache.set(automationId, cached); + } + return cached; + } + + async createAutomation(options: ICreateAutomationOptions): Promise<IAutomation> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + if (!options.folderUri) { + throw new Error('Automation requires a folderUri.'); + } + const now = this._now(); + const nowIso = now.toISOString(); + const nextRun = computeNextRunAt(options.schedule, now); + const automation: IAutomation = Object.freeze({ + id: generateUuid(), + name: options.name, + prompt: options.prompt, + schedule: options.schedule, + folderUri: options.folderUri, + providerId: options.providerId, + sessionTypeId: options.sessionTypeId, + modelId: options.modelId, + mode: options.mode, + permissionLevel: isChatPermissionLevel(options.permissionLevel) ? options.permissionLevel : undefined, + isolationMode: VALID_ISOLATION_MODES.has(options.isolationMode!) ? options.isolationMode : undefined, + branch: options.branch, + enabled: options.enabled ?? true, + createdAt: nowIso, + updatedAt: nowIso, + lastRunAt: undefined, + nextRunAt: nextRun?.toISOString(), + }); + const next = [automation, ...this._automations.get()]; + this.commit(next, this._runs.get()); + publishAutomationCreated(this.telemetryService, automation); + return automation; + } + + async updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise<IAutomation> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + const current = this.getAutomation(id); + if (!current) { + throw new Error(`Automation not found: ${id}`); + } + const merged = mergeAutomation(current, patch); + const scheduleChanged = patch.schedule !== undefined; + const enabledChanged = patch.enabled !== undefined; + const updated: IAutomation = Object.freeze({ + ...merged, + updatedAt: this._now().toISOString(), + nextRunAt: (scheduleChanged || (enabledChanged && merged.enabled)) + ? computeNextRunAt(merged.schedule, this._now())?.toISOString() + : merged.nextRunAt, + }); + const next = this._automations.get().map(a => a.id === id ? updated : a); + this.commit(next, this._runs.get()); + publishAutomationUpdated(this.telemetryService, current, updated); + return updated; + } + + async deleteAutomation(id: string): Promise<void> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + const existing = this.getAutomation(id); + const next = this._automations.get().filter(a => a.id !== id); + if (next.length === this._automations.get().length) { + return; + } + this.commit(next, this._runs.get()); + this._runsForCache.delete(id); + if (existing) { + publishAutomationDeleted(this.telemetryService, existing); + } + } + + async recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise<IAutomationRun> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + if (!this.getAutomation(automationId)) { + throw new Error(`Automation not found: ${automationId}`); + } + const run: IAutomationRun = Object.freeze({ + id: generateUuid(), + automationId, + status: 'pending', + trigger, + startedAt: this._now().toISOString(), + leaderWindowId, + }); + const nextRuns = [run, ...this._runs.get()]; + this.commit(this._automations.get(), nextRuns); + return run; + } + + async updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise<IAutomationRun | undefined> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + const current = this._runs.get().find(r => r.id === runId); + if (!current) { + return undefined; + } + const merged: IAutomationRun = Object.freeze({ + ...current, + status: patch.status ?? current.status, + sessionResource: patch.sessionResource ?? current.sessionResource, + completedAt: patch.completedAt ?? current.completedAt, + errorMessage: patch.errorMessage ?? current.errorMessage, + }); + const nextRuns = this._runs.get().map(r => r.id === runId ? merged : r); + this.commit(this._automations.get(), nextRuns); + return merged; + } + + getActiveRunFor(automationId: string): IAutomationRun | undefined { + return this._runs.get().find(r => r.automationId === automationId && (r.status === 'pending' || r.status === 'running')); + } + + async markStaleRunsFailed(reason: string): Promise<void> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + let changed = false; + const completedAt = this._now().toISOString(); + const nextRuns = this._runs.get().map(r => { + if (r.status === 'pending' || r.status === 'running') { + changed = true; + return Object.freeze({ ...r, status: 'failed' as const, completedAt, errorMessage: reason }); + } + return r; + }); + if (changed) { + this.commit(this._automations.get(), nextRuns); + } + } + + async advanceNextRunAt(id: string, now: Date = this._now()): Promise<IAutomation | undefined> { + if (this._unsupportedSchema) { + throw new Error('Cannot modify automations: storage was written by a newer version'); + } + const current = this.getAutomation(id); + if (!current) { + return undefined; + } + const updated: IAutomation = Object.freeze({ + ...current, + lastRunAt: now.toISOString(), + nextRunAt: computeNextRunAt(current.schedule, now)?.toISOString(), + updatedAt: now.toISOString(), + }); + const next = this._automations.get().map(a => a.id === id ? updated : a); + this.commit(next, this._runs.get()); + return updated; + } + + //#region Persistence + + private commit(automations: readonly IAutomation[], runs: readonly IAutomationRun[]): void { + if (this._unsupportedSchema) { + this.logService.warn('[AutomationService] Skipping commit; ledger has an unsupported (newer) schema version.'); + return; + } + const trimmedRuns = trimRunsPerAutomation(runs, MAX_RUNS_PER_AUTOMATION); + transaction(tx => { + this._automations.set(automations, tx); + this._runs.set(trimmedRuns, tx); + }); + this.persist(automations, trimmedRuns); + } + + private persist(automations: readonly IAutomation[], runs: readonly IAutomationRun[]): void { + if (this._unsupportedSchema) { + return; + } + + const nextRevision = this._lastSeenRevision + 1; + const serialized: ISerializedLedger = { + schemaVersion: CURRENT_SCHEMA_VERSION, + revision: nextRevision, + automations: automations.map(serializeAutomation), + runs: [...runs], + }; + try { + this.storageService.store(STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); + } catch (err) { + this.logService.warn('[AutomationService] Failed to persist ledger to storage', err); + return; + } + this._lastSeenRevision = nextRevision; + } + + private refreshFromStorage(): void { + const result = this.readLedger(); + if (result.kind === 'unsupportedSchema') { + return; + } + this._unsupportedSchema = false; + this._lastSeenRevision = result.revision; + transaction(tx => { + this._automations.set(result.ledger.automations, tx); + this._runs.set(result.ledger.runs, tx); + }); + } + + private readLedger(): ReadLedgerResult { + const raw = this.storageService.get(STORAGE_KEY, StorageScope.APPLICATION); + if (!raw) { + return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; + } + try { + const parsed = JSON.parse(raw) as ISerializedLedger; + if (typeof parsed?.schemaVersion === 'number' && parsed.schemaVersion > CURRENT_SCHEMA_VERSION) { + this._unsupportedSchema = true; + this.logService.warn(`[AutomationService] Ledger has schema v${parsed.schemaVersion}; this build only supports v${CURRENT_SCHEMA_VERSION}. Entering read-only mode.`); + return { kind: 'unsupportedSchema' }; + } + if (parsed?.schemaVersion !== CURRENT_SCHEMA_VERSION) { + this.logService.warn(`[AutomationService] Unsupported ledger schema version ${parsed?.schemaVersion}; ignoring.`); + return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; + } + const automations: IAutomation[] = []; + for (const entry of parsed.automations ?? []) { + if (!entry?.folderUri) { + this.logService.warn(`[AutomationService] Dropping persisted automation ${entry?.id} without a folderUri.`); + continue; + } + automations.push(deserializeAutomation(entry)); + } + const validIds = new Set(automations.map(a => a.id)); + const runs = (parsed.runs ?? []) + .filter(r => validIds.has(r.automationId)) + .map(r => Object.freeze({ ...r })); + const revision = typeof parsed.revision === 'number' ? parsed.revision : 0; + return { kind: 'ledger', ledger: { automations, runs: trimRunsPerAutomation(runs, MAX_RUNS_PER_AUTOMATION) }, revision }; + } catch (err) { + this.logService.error('[AutomationService] Failed to parse automations ledger; resetting.', err); + return { kind: 'ledger', ledger: EMPTY_LEDGER, revision: 0 }; + } + } + + //#endregion +} + +function serializeAutomation(a: IAutomation): ISerializedAutomation { + return { + id: a.id, + name: a.name, + prompt: a.prompt, + schedule: a.schedule, + folderUri: a.folderUri.toJSON() as UriComponents, + providerId: a.providerId, + sessionTypeId: a.sessionTypeId, + modelId: a.modelId, + mode: a.mode, + permissionLevel: a.permissionLevel, + isolationMode: a.isolationMode, + branch: a.branch, + enabled: a.enabled, + createdAt: a.createdAt, + updatedAt: a.updatedAt, + lastRunAt: a.lastRunAt, + nextRunAt: a.nextRunAt, + }; +} + +function deserializeAutomation(s: ISerializedAutomation): IAutomation { + const revivedUri = URI.revive(s.folderUri); + const folderUri = revivedUri; + + // Default to most restrictive if the persisted value is invalid. + const permissionLevel = isChatPermissionLevel(s.permissionLevel) + ? s.permissionLevel + : ChatPermissionLevel.Default; + + return Object.freeze({ + id: s.id, + name: s.name, + prompt: s.prompt, + schedule: s.schedule, + folderUri, + providerId: s.providerId, + sessionTypeId: s.sessionTypeId, + modelId: s.modelId, + mode: s.mode, + permissionLevel, + isolationMode: s.isolationMode, + branch: s.branch, + enabled: s.enabled, + createdAt: s.createdAt, + updatedAt: s.updatedAt, + lastRunAt: s.lastRunAt, + nextRunAt: s.nextRunAt, + }); +} + +function mergeAutomation(current: IAutomation, patch: IUpdateAutomationOptions): IAutomation { + return { + ...current, + name: patch.name ?? current.name, + prompt: patch.prompt ?? current.prompt, + schedule: patch.schedule ?? current.schedule, + folderUri: patch.folderUri ?? current.folderUri, + providerId: patch.providerId === null ? undefined : (patch.providerId ?? current.providerId), + sessionTypeId: patch.sessionTypeId === null ? undefined : (patch.sessionTypeId ?? current.sessionTypeId), + modelId: patch.modelId === null ? undefined : (patch.modelId ?? current.modelId), + mode: patch.mode === null ? undefined : (patch.mode ?? current.mode), + permissionLevel: patch.permissionLevel === null ? undefined : (patch.permissionLevel && isChatPermissionLevel(patch.permissionLevel) ? patch.permissionLevel : current.permissionLevel), + isolationMode: patch.isolationMode === null ? undefined : (patch.isolationMode && VALID_ISOLATION_MODES.has(patch.isolationMode) ? patch.isolationMode : current.isolationMode), + branch: patch.branch === null ? undefined : (patch.branch ?? current.branch), + enabled: patch.enabled ?? current.enabled, + }; +} + +function trimRunsPerAutomation(runs: readonly IAutomationRun[], max: number): readonly IAutomationRun[] { + const counts = new Map<string, number>(); + const out: IAutomationRun[] = []; + for (const run of runs) { + const count = counts.get(run.automationId) ?? 0; + if (count >= max) { + continue; + } + counts.set(run.automationId, count + 1); + out.push(run); + } + return out.length === runs.length ? runs : out; +} diff --git a/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts b/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts new file mode 100644 index 00000000000000..9d78892b73db00 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automationSessionTypeProvider.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { IAutomationSessionTypeChoice, IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; + +/** + * Sessions-layer implementation of {@link IAutomationSessionTypeProvider}. + * Decouples the workbench UI from the Sessions layer. + * When multiple providers offer the same session type for a folder, adds a + * provider label as description so the user can tell them apart. + */ +export class AutomationSessionTypeProvider implements IAutomationSessionTypeProvider { + + declare readonly _serviceBrand: undefined; + + constructor( + @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, + @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, + ) { } + + getSessionTypesForFolder(folderUri: URI): readonly IAutomationSessionTypeChoice[] { + const provided = this.sessionsManagementService.getSessionTypesForFolder(folderUri); + const providersForType = new Map<string, number>(); + for (const entry of provided) { + providersForType.set(entry.sessionType.id, (providersForType.get(entry.sessionType.id) ?? 0) + 1); + } + const providers = this.sessionsProvidersService.getProviders(); + const providerLabel = (id: string): string | undefined => providers.find(p => p.id === id)?.label; + return provided.map(entry => { + const needsDescription = (providersForType.get(entry.sessionType.id) ?? 0) > 1; + return { + providerId: entry.providerId, + sessionTypeId: entry.sessionType.id, + label: entry.sessionType.label, + description: needsDescription ? providerLabel(entry.providerId) : undefined, + }; + }); + } +} diff --git a/src/vs/sessions/contrib/automations/browser/automations.contribution.ts b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts new file mode 100644 index 00000000000000..02bef4d3e16c43 --- /dev/null +++ b/src/vs/sessions/contrib/automations/browser/automations.contribution.ts @@ -0,0 +1,81 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { ConfigurationScope, Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import product from '../../../../platform/product/common/product.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { AutomationsAccessibilityHelp } from '../../../../workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.js'; +import { IAutomationDialogService } from '../../../../workbench/contrib/chat/common/automations/automationDialogService.js'; +import { IAutomationRunner } from '../../../../workbench/contrib/chat/common/automations/automationRunner.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; +import { IAutomationSessionTypeProvider } from '../../../../workbench/contrib/chat/common/automations/automationSessionTypes.js'; +import { ChatAutomationsEnabledContext, CHAT_AUTOMATIONS_ENABLED_SETTING, CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING, DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { AutomationDialogService } from './automationDialogService.js'; +import { AutomationRunner } from './automationRunner.js'; +import { AutomationScheduler } from './automationScheduler.js'; +import { AutomationService } from './automationService.js'; +import { AutomationSessionTypeProvider } from './automationSessionTypeProvider.js'; + +registerSingleton(IAutomationService, AutomationService, InstantiationType.Delayed); +registerSingleton(IAutomationRunner, AutomationRunner, InstantiationType.Delayed); +registerSingleton(IAutomationSessionTypeProvider, AutomationSessionTypeProvider, InstantiationType.Delayed); +registerSingleton(IAutomationDialogService, AutomationDialogService, InstantiationType.Delayed); + +registerWorkbenchContribution2(AutomationScheduler.ID, AutomationScheduler, WorkbenchPhase.Eventually); + +AccessibleViewRegistry.register(new AutomationsAccessibilityHelp()); + +Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'chat', + properties: { + [CHAT_AUTOMATIONS_ENABLED_SETTING]: { + type: 'boolean', + default: false, + scope: ConfigurationScope.MACHINE, + tags: ['experimental', 'advanced'], + description: localize('chat.automations.enabled', "Enables the Automations feature: scheduling agent sessions to run on a cadence. When disabled, the Automations entry in the Customizations sidebar, the Automations section in the Customizations editor, and the Automation option in the new-session composer are hidden, and scheduled automations are not dispatched."), + included: product.quality !== 'stable', + experiment: { mode: 'auto' }, + }, + [CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING]: { + type: 'number', + default: DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES, + minimum: 1, + scope: ConfigurationScope.MACHINE, + tags: ['experimental', 'advanced'], + description: localize('chat.automations.runTimeoutMinutes', "Maximum number of minutes a scheduled automation run is allowed to take before the scheduler cancels it and marks it failed. Prevents a single hung run from permanently blocking subsequent scheduled runs."), + included: product.quality !== 'stable', + }, + }, +}); + +// Mirrors the setting into a context key for menu `when` clauses. +class ChatAutomationsEnabledContextContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.chatAutomationsEnabledContext'; + + constructor( + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + const key = ChatAutomationsEnabledContext.bindTo(contextKeyService); + const update = () => key.set(configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true); + update(); + this._register(configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING)) { + update(); + } + })); + } +} + +registerWorkbenchContribution2(ChatAutomationsEnabledContextContribution.ID, ChatAutomationsEnabledContextContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationLeaderElection.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationLeaderElection.test.ts new file mode 100644 index 00000000000000..38538f3b176a45 --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationLeaderElection.test.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { AutomationLeaderElection } from '../../browser/automationLeaderElection.js'; + +suite('AutomationLeaderElection', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function createElection(storage: InMemoryStorageService, now: () => number, instanceId: string) { + return teardown.add(new AutomationLeaderElection(storage, new NullLogService(), { + now, + instanceId, + heartbeatIntervalMs: 60_000_000, // disable real timer for tests + staleAfterMs: 90_000, + })); + } + + test('a single window claims leadership immediately on construction', () => { + const storage = teardown.add(new InMemoryStorageService()); + const now = 1_000; + const election = createElection(storage, () => now, 'window-a'); + assert.strictEqual(election.isLeader.get(), true); + }); + + test('two windows on the same storage elect exactly one leader', () => { + const storage = teardown.add(new InMemoryStorageService()); + const now = 1_000; + const a = createElection(storage, () => now, 'window-a'); + const b = createElection(storage, () => now, 'window-b'); + // First constructed claims first; B sees a fresh heartbeat + // from A and stands down. + assert.strictEqual(a.isLeader.get(), true); + assert.strictEqual(b.isLeader.get(), false); + }); + + test('loser takes over once the leaders heartbeat goes stale', () => { + const storage = teardown.add(new InMemoryStorageService()); + let now = 1_000; + const a = createElection(storage, () => now, 'window-a'); + const b = createElection(storage, () => now, 'window-b'); + assert.strictEqual(a.isLeader.get(), true); + assert.strictEqual(b.isLeader.get(), false); + + // Advance clock past the stale threshold without ticking A. + now += 91_000; + b.evaluateForTesting(); + assert.strictEqual(b.isLeader.get(), true); + }); + + test('leader keeps refreshing its own heartbeat on every evaluation', () => { + const storage = teardown.add(new InMemoryStorageService()); + let now = 1_000; + const a = createElection(storage, () => now, 'window-a'); + assert.strictEqual(a.isLeader.get(), true); + + now += 50_000; + a.evaluateForTesting(); + // Should still be leader because we re-wrote the heartbeat + // before another window could see it as stale. + assert.strictEqual(a.isLeader.get(), true); + }); + + test('disposing the leader clears the slot so the next window does not wait', () => { + const storage = teardown.add(new InMemoryStorageService()); + const now = 1_000; + const a = createElection(storage, () => now, 'window-a'); + assert.strictEqual(a.isLeader.get(), true); + + a.dispose(); + const b = createElection(storage, () => now, 'window-b'); + assert.strictEqual(b.isLeader.get(), true); + }); + + test('corrupt leader record is treated as empty and reclaimed', () => { + const storage = teardown.add(new InMemoryStorageService()); + // StorageScope.APPLICATION is -1 + storage.store('chat.automations.leader', 'not json', -1, 1); + const a = createElection(storage, () => 1_000, 'window-a'); + assert.strictEqual(a.isLeader.get(), true); + }); + + test('tombstone left on dispose is treated as immediately claimable', () => { + const storage = teardown.add(new InMemoryStorageService()); + const a = createElection(storage, () => 1_000, 'window-a'); + assert.strictEqual(a.isLeader.get(), true); + a.dispose(); + // After dispose we should see a tombstone (empty instanceId) + // in storage, not a removed key. + const raw = storage.get('chat.automations.leader', -1); + assert.ok(raw, 'tombstone should be present after release'); + const parsed = JSON.parse(raw!); + assert.strictEqual(parsed.instanceId, ''); + // Any subsequent claim, even at the same wall clock, should + // succeed without waiting for staleAfterMs. + const b = createElection(storage, () => 1_000, 'window-b'); + assert.strictEqual(b.isLeader.get(), true); + }); + + test('readback after writeLeader detects a competing concurrent write and stands down', () => { + // Custom storage that injects a competitor write immediately + // after every store() to LEADER_KEY. This simulates two windows + // reading the slot as claimable, both writing, with the second + // write landing after ours (the TOCTOU window in evaluate()). + class RacyStorage extends InMemoryStorageService { + competitor: string | undefined; + override store(key: string, value: any, scope: any, target: any, external = false): void { + super.store(key, value, scope, target, external); + if (key === 'chat.automations.leader' && this.competitor !== undefined) { + super.store(key, this.competitor, scope, target, external); + } + } + } + const storage = teardown.add(new RacyStorage()); + storage.competitor = JSON.stringify({ instanceId: 'window-b', heartbeatAt: 1_000, nonce: 'b-nonce' }); + const a = createElection(storage, () => 1_000, 'window-a'); + // Even though A claimed the slot, the readback saw B's record + // instead of A's nonce, so A must NOT be leader. + assert.strictEqual(a.isLeader.get(), false); + }); + + test('survives a throwing storage read by leaving leadership unset', () => { + class ThrowingStorage extends InMemoryStorageService { + override get(key: string, scope: any, fallbackValue?: string): any { + if (key === 'chat.automations.leader') { + throw new Error('storage unavailable'); + } + return super.get(key, scope, fallbackValue as string); + } + } + const storage = teardown.add(new ThrowingStorage()); + const a = createElection(storage, () => 1_000, 'window-a'); + // readLeader threw, the claim path can't validate via readback, + // so we should not be leader. + assert.strictEqual(a.isLeader.get(), false); + }); + + test('survives a throwing storage write by not declaring leadership', () => { + class ThrowingStorage extends InMemoryStorageService { + override store(key: string, value: any, scope: any, target: any, external = false): void { + if (key === 'chat.automations.leader') { + throw new Error('storage unavailable'); + } + super.store(key, value, scope, target, external); + } + } + const storage = teardown.add(new ThrowingStorage()); + const a = createElection(storage, () => 1_000, 'window-a'); + assert.strictEqual(a.isLeader.get(), false); + }); +}); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts new file mode 100644 index 00000000000000..8918f9ad98e6ea --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationRunner.test.ts @@ -0,0 +1,274 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { TestNotificationService } from '../../../../../platform/notification/test/common/testNotificationService.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AutomationService } from '../../browser/automationService.js'; +import { IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { ICreateNewSessionOptions, ISendRequestOptions, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { AutomationRunner } from '../../browser/automationRunner.js'; + +function hourly(): IAutomationSchedule { + return { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; +} + +const FOLDER_A = URI.parse('file:///workspace/a'); +const FOLDER_B = URI.parse('file:///workspace/b'); + +interface IRecordedCall { + readonly folderUri: URI; + readonly options: ISendRequestOptions; + readonly createOptions?: ICreateNewSessionOptions; +} + +class FakeSessionsManagementService extends mock<ISessionsManagementService>() { + + readonly calls: IRecordedCall[] = []; + + /** Configure how the next createAndSendNewChatRequest behaves. */ + nextSession: ISession | undefined; + nextError: Error | undefined; + /** Optional hook fired after the call is recorded, before returning/throwing. */ + onSendHook: (() => Promise<void> | void) | undefined; + + override async createAndSendNewChatRequest( + folderUri: URI, + options: ISendRequestOptions, + createOptions?: ICreateNewSessionOptions, + ): Promise<ISession | undefined> { + this.calls.push({ folderUri, options, createOptions }); + if (this.onSendHook) { + await this.onSendHook(); + } + if (this.nextError) { + throw this.nextError; + } + return this.nextSession; + } +} + +function fakeSession(id: string): ISession { + return upcastPartial<ISession>({ sessionId: id, resource: URI.from({ scheme: 'vscode-chat-session', authority: 'test', path: `/${id}` }) }); +} + +suite('AutomationRunner', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup() { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + const sessionsMgmt = new FakeSessionsManagementService(); + const runner = new AutomationRunner(service, sessionsMgmt, log, NullTelemetryService, new TestNotificationService()); + return { service, sessionsMgmt, runner }; + } + + test('creates a session for the automation prompt and marks the run completed', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const a = await service.createAutomation({ name: 'A', prompt: 'do the thing', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 99); + + assert.strictEqual(sessionsMgmt.calls.length, 1); + assert.strictEqual(sessionsMgmt.calls[0].folderUri.toString(), FOLDER_A.toString()); + assert.strictEqual(sessionsMgmt.calls[0].options.query, 'do the thing'); + assert.strictEqual(sessionsMgmt.calls[0].options.background, true); + + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'completed'); + assert.strictEqual(runs[0].sessionResource, 'vscode-chat-session://test/s1'); + assert.strictEqual(runs[0].trigger, 'schedule'); + assert.strictEqual(runs[0].leaderWindowId, 99); + }); + + test('always uses the automation folder regardless of the current workspace', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: hourly(), + folderUri: FOLDER_B, + }); + await runner.runOnce(a, 'schedule', 1); + + assert.strictEqual(sessionsMgmt.calls[0].folderUri.toString(), FOLDER_B.toString()); + }); + + test('truncates the session title to 100 characters', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const longName = 'A'.repeat(150); + const a = await service.createAutomation({ name: longName, prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'manual', 1); + + assert.strictEqual(sessionsMgmt.calls[0].options.title, 'A'.repeat(100)); + }); + + test('marks the run failed when createAndSendNewChatRequest throws', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextError = new Error('provider offline'); + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 1); + + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'failed'); + assert.strictEqual(runs[0].errorMessage, 'provider offline'); + }); + + test('skips when another active run exists for the same automation', async () => { + const { service, sessionsMgmt, runner } = setup(); + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await service.recordRunStart(a.id, 'manual', 1); + await runner.runOnce(a, 'schedule', 2); + assert.strictEqual(sessionsMgmt.calls.length, 0); + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'pending'); + }); + + test('marks the run failed when the cancellation token is already cancelled', async () => { + const { service, sessionsMgmt, runner } = setup(); + const cts = new CancellationTokenSource(); + cts.cancel(); + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 1, cts.token); + + assert.strictEqual(sessionsMgmt.calls.length, 0); + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'failed'); + assert.strictEqual(runs[0].errorMessage, 'Cancelled'); + cts.dispose(); + }); + + test('marks the run cancelled when the token is cancelled mid-flight', async () => { + // Regression: previously the runner only checked the token before + // `createAndSendNewChatRequest`, so a cancellation that landed during + // the in-flight send would still stamp the run as `completed`. + const { service, sessionsMgmt, runner } = setup(); + const cts = new CancellationTokenSource(); + sessionsMgmt.nextSession = fakeSession('s-mid'); + sessionsMgmt.onSendHook = () => { + cts.cancel(); + }; + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 1, cts.token); + + assert.strictEqual(sessionsMgmt.calls.length, 1); + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'failed'); + assert.strictEqual(runs[0].errorMessage, 'Cancelled'); + // Even though the service returned a session, the cancellation + // outcome wins and the session id is not stamped onto the run. + assert.strictEqual(runs[0].sessionResource, undefined); + cts.dispose(); + }); + + test('completes the run even when the service returns undefined', async () => { + const { service, runner } = setup(); + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 1, CancellationToken.None); + + const runs = service.runs.get(); + assert.strictEqual(runs.length, 1); + assert.strictEqual(runs[0].status, 'completed'); + assert.strictEqual(runs[0].sessionResource, undefined); + }); + + test('passes the captured providerId and sessionTypeId through to createAndSendNewChatRequest', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: hourly(), + folderUri: FOLDER_A, + providerId: 'local-agent-host', + sessionTypeId: 'agent-host-copilotcli', + }); + await runner.runOnce(a, 'schedule', 1); + + assert.strictEqual(sessionsMgmt.calls.length, 1); + assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { + providerId: 'local-agent-host', + sessionTypeId: 'agent-host-copilotcli', + modelId: undefined, + modeId: undefined, + permissionLevel: undefined, + isolationMode: undefined, + branch: undefined, + }); + }); + + test('passes captured mode and permission level through to createAndSendNewChatRequest', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: hourly(), + folderUri: FOLDER_A, + mode: 'agent', + permissionLevel: 'autopilot', + }); + await runner.runOnce(a, 'schedule', 1); + + assert.strictEqual(sessionsMgmt.calls.length, 1); + assert.deepStrictEqual(sessionsMgmt.calls[0].createOptions, { + providerId: undefined, + sessionTypeId: undefined, + modelId: undefined, + modeId: 'agent', + permissionLevel: 'autopilot', + isolationMode: undefined, + branch: undefined, + }); + }); + + test('omits createOptions entirely when no provider/sessionType is captured', async () => { + const { service, sessionsMgmt, runner } = setup(); + sessionsMgmt.nextSession = fakeSession('s1'); + + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await runner.runOnce(a, 'schedule', 1); + + assert.strictEqual(sessionsMgmt.calls.length, 1); + assert.strictEqual(sessionsMgmt.calls[0].createOptions, undefined); + }); + + test('does not throw if the automation is deleted mid-run', async () => { + const { service, sessionsMgmt, runner } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER_A }); + await service.deleteAutomation(a.id); + // The runner detects the deletion via getAutomation before attempting + // recordRunStart, bails early, and produces no run rows. + await runner.runOnce(a, 'manual', 1); + assert.strictEqual(sessionsMgmt.calls.length, 0); + assert.deepStrictEqual(service.runs.get(), []); + }); +}); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts new file mode 100644 index 00000000000000..5c3c130e45f90e --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationScheduler.test.ts @@ -0,0 +1,345 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { DeferredPromise } from '../../../../../base/common/async.js'; +import { IObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IAutomationLeaderElection } from '../../browser/automationLeaderElection.js'; +import { IAutomationRunner } from '../../../../../workbench/contrib/chat/common/automations/automationRunner.js'; +import { AutomationSchedulerCore, CRASH_RECOVERY_REASON, RUN_TIMEOUT_REASON_PREFIX } from '../../browser/automationScheduler.js'; +import { AutomationService } from '../../browser/automationService.js'; +import { AutomationRunTrigger, IAutomation, IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; + +const FOLDER = URI.parse('file:///workspace'); + +class FakeLeaderElection implements IAutomationLeaderElection { + private readonly _isLeader: ISettableObservable<boolean>; + readonly isLeader: IObservable<boolean>; + readonly instanceId = 'fake-leader-window'; + + constructor(initial = true) { + this._isLeader = observableValue<boolean>(this, initial); + this.isLeader = this._isLeader; + } + + set(value: boolean): void { + this._isLeader.set(value, undefined); + } + + evaluateForTesting(): void { /* no-op */ } + dispose(): void { /* no-op */ } +} + +interface RecordedRun { + readonly automationId: string; + readonly trigger: AutomationRunTrigger; +} + +class RecordingRunner implements IAutomationRunner { + declare readonly _serviceBrand: undefined; + + readonly runs: RecordedRun[] = []; + + async runOnce( + automation: IAutomation, + trigger: AutomationRunTrigger, + _leaderWindowId: number, + _token?: CancellationToken, + ): Promise<void> { + this.runs.push({ automationId: automation.id, trigger }); + } +} + +function hourly(): IAutomationSchedule { + return { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; +} + +const T0 = new Date('2025-06-01T00:00:00Z'); +const T_PAST_DUE = new Date('2025-06-01T02:00:00Z'); +const T_TOMORROW = new Date('2025-06-02T04:00:00Z'); + +suite('AutomationSchedulerCore', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup() { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + const runner = new RecordingRunner(); + // Start as non-leader so individual tests can seed automations + // before triggering the leader's catch-up pass. + const leader = new FakeLeaderElection(false); + + let now = T0; + service.setClockForTesting(() => now); + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => now, + })); + + return { + service, runner, leader, core, + setNow: (d: Date) => { now = d; }, + }; + } + + test('does not run anything if there are no automations', async () => { + const { core, runner, leader } = setup(); + leader.set(true); + await core.waitForPendingRuns(); + await core.tickForTesting(); + assert.deepStrictEqual(runner.runs, []); + }); + + test('on becoming leader, runs catch-up for due automations exactly once', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + // nextRunAt is T0+1h; advance the clock past it so the row is due. + setNow(T_PAST_DUE); + leader.set(true); + await core.waitForPendingRuns(); + + assert.strictEqual(runner.runs.length, 1); + assert.strictEqual(runner.runs[0].automationId, a.id); + assert.strictEqual(runner.runs[0].trigger, 'catch_up'); + }); + + test('subsequent scheduled ticks use trigger=schedule', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + setNow(T_PAST_DUE); + leader.set(true); + await core.waitForPendingRuns(); + assert.strictEqual(runner.runs.length, 1, 'first run should be catch-up'); + + // Advance well past the freshly-computed next slot and tick again. + setNow(T_TOMORROW); + await core.tickForTesting(); + + assert.strictEqual(runner.runs.length, 2); + assert.strictEqual(runner.runs[1].trigger, 'schedule'); + }); + + test('disabled automations are not dispatched', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + await service.updateAutomation(a.id, { enabled: false }); + setNow(T_PAST_DUE); + leader.set(true); + await core.waitForPendingRuns(); + await core.tickForTesting(); + assert.deepStrictEqual(runner.runs, []); + }); + + test('advances nextRunAt so the same automation is not picked up again on the next tick', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + setNow(T_PAST_DUE); + leader.set(true); + await core.waitForPendingRuns(); + assert.strictEqual(runner.runs.length, 1); + + // Tick again immediately - nextRunAt was advanced, so the + // automation is no longer due at the same `now`. + await core.tickForTesting(); + assert.strictEqual(runner.runs.length, 1); + + const updated = service.getAutomation(a.id); + assert.ok(updated?.nextRunAt); + const next = Date.parse(updated!.nextRunAt!); + assert.ok(next > T_PAST_DUE.getTime(), 'nextRunAt should be after the tick that just fired'); + }); + + test('does nothing while not leader', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + setNow(T_PAST_DUE); + await core.waitForPendingRuns(); + await core.tickForTesting(); + assert.strictEqual(runner.runs.length, 0); + + leader.set(true); + await core.waitForPendingRuns(); + assert.strictEqual(runner.runs.length, 1); + assert.strictEqual(runner.runs[0].trigger, 'catch_up'); + }); + + test('on becoming leader, fails any leftover pending/running runs as crash recovery', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const firstService = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + firstService.setClockForTesting(() => T0); + const a = await firstService.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + const run = await firstService.recordRunStart(a.id, 'manual', 1); + firstService.dispose(); + + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + service.setClockForTesting(() => T0); + const runner = new RecordingRunner(); + const leader = new FakeLeaderElection(true); + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => T0, + })); + await core.waitForPendingRuns(); + + const recovered = service.runs.get().find(r => r.id === run.id); + assert.strictEqual(recovered?.status, 'failed'); + assert.strictEqual(recovered?.errorMessage, CRASH_RECOVERY_REASON); + }); + + test('losing then regaining leadership re-runs catch-up', async () => { + const { core, runner, service, leader, setNow } = setup(); + setNow(T0); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + setNow(T_PAST_DUE); + leader.set(true); + await core.waitForPendingRuns(); + assert.strictEqual(runner.runs[0].trigger, 'catch_up'); + + // Lose leadership. + leader.set(false); + await core.waitForPendingRuns(); + + // Make the row due again. + setNow(T_TOMORROW); + + // Regain it - we should see another catch-up. + leader.set(true); + await core.waitForPendingRuns(); + assert.strictEqual(runner.runs.length, 2); + assert.strictEqual(runner.runs[1].trigger, 'catch_up'); + }); + + test('toggling the feature setting off then on does not crash-recover in-progress runs', async () => { + // Reproduce the bug where disabling the feature reset the + // per-leadership startup flag, causing a subsequent re-enable + // tick to call markStaleRunsFailed and incorrectly fail any + // runs that were active across the toggle. + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + service.setClockForTesting(() => T0); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + const inFlight = await service.recordRunStart(a.id, 'schedule', 1); + + const runner = new RecordingRunner(); + const leader = new FakeLeaderElection(true); + let enabled = true; + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => T0, + isFeatureEnabled: () => enabled, + })); + // First tick (as leader, feature ON) does startup recovery, + // which by design fails the in-flight row. Tests below only + // care that the *next* enable→disable→enable cycle does not + // repeat that recovery. + await core.waitForPendingRuns(); + // Reset the row back to running so we can observe whether the + // toggle re-triggers recovery. Note: updateRun's patch + // semantics treat undefined fields as "no change", so we + // cannot clear errorMessage from here; assert only on status. + await service.updateRun(inFlight.id, { status: 'running' }); + + enabled = false; + await core.tickForTesting(); + enabled = true; + await core.tickForTesting(); + + // The in-flight run must still be running. The feature toggle + // must NOT have re-triggered crash recovery. + const after = service.runs.get().find(r => r.id === inFlight.id); + assert.strictEqual(after?.status, 'running', 'feature-toggle off/on must not fail in-flight runs'); + }); + + test('runOneWithTimeout: a hung run is cancelled, marked failed, and the next due automation still fires', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const log = new NullLogService(); + const service = teardown.add(new AutomationService(storage, log, NullTelemetryService)); + + let now = T0; + service.setClockForTesting(() => now); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + const b = await service.createAutomation({ name: 'B', prompt: 'q', schedule: hourly(), folderUri: FOLDER }); + + // A runner whose first invocation hangs until we release it, + // AND that creates a run row (mimicking the real runner) so + // the timeout path has something to mark as failed. Dispatch + // order is not guaranteed to match creation order, so the + // runner records which automation it hung on. + let hungAutomationId: string | undefined; + class HangingRunner implements IAutomationRunner { + declare readonly _serviceBrand: undefined; + readonly hung = new DeferredPromise<void>(); + calls = 0; + cancelObserved = false; + async runOnce(automation: IAutomation, trigger: AutomationRunTrigger, leaderWindowId: number, token?: CancellationToken): Promise<void> { + this.calls++; + if (this.calls === 1) { + hungAutomationId = automation.id; + await service.recordRunStart(automation.id, trigger, leaderWindowId); + const listener = token?.onCancellationRequested(() => { this.cancelObserved = true; }); + try { + await this.hung.p; + } finally { + listener?.dispose(); + } + return; + } + await service.recordRunStart(automation.id, trigger, leaderWindowId); + } + } + const runner = new HangingRunner(); + const leader = new FakeLeaderElection(false); + + // Use a very short timeout so the test finishes quickly. + const core = teardown.add(new AutomationSchedulerCore(service, runner, storage, log, { + leaderElection: leader, + disableAutoTick: true, + now: () => now, + getRunTimeoutMs: () => 50, + })); + + now = T_PAST_DUE; + leader.set(true); + await core.waitForPendingRuns(); + + // Both A and B should have been dispatched (the second was + // not blocked by the first's hang). The hung automation's run + // row must be failed with the timeout reason; the runner must + // have observed cancellation. + assert.strictEqual(runner.calls, 2, 'both A and B should have been dispatched'); + assert.strictEqual(runner.cancelObserved, true, 'runner should observe cancellation on timeout'); + assert.ok(hungAutomationId, 'runner should have recorded a hung automation id'); + const otherId = hungAutomationId === a.id ? b.id : a.id; + const hungRun = service.runs.get().find(r => r.automationId === hungAutomationId); + assert.strictEqual(hungRun?.status, 'failed'); + assert.ok(hungRun?.errorMessage?.startsWith(RUN_TIMEOUT_REASON_PREFIX), `expected timeout marker, got: ${hungRun?.errorMessage}`); + // The non-hung automation's row should NOT have been touched + // by the timeout path. + const otherRun = service.runs.get().find(r => r.automationId === otherId); + assert.notStrictEqual(otherRun?.status, 'failed'); + + // Cleanup: release the hung promise so the runner can exit. + runner.hung.complete(); + await core.waitForPendingRuns(); + }); +}); diff --git a/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts new file mode 100644 index 00000000000000..922b9c719cbd56 --- /dev/null +++ b/src/vs/sessions/contrib/automations/test/browser/automationService.test.ts @@ -0,0 +1,341 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AutomationService } from '../../browser/automationService.js'; +import { IAutomationSchedule } from '../../../../../workbench/contrib/chat/common/automations/automation.js'; + +const FOLDER = URI.parse('file:///workspace'); + +function dailySchedule(hour = 9, minute = 0): IAutomationSchedule { + return { interval: 'daily', scheduleHour: hour, scheduleMinute: minute, scheduleDay: 0 }; +} + +suite('AutomationService', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(storage?: InMemoryStorageService): { service: AutomationService; storage: InMemoryStorageService } { + const sharedStorage = teardown.add(storage ?? new InMemoryStorageService()); + const service = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + return { service, storage: sharedStorage }; + } + + test('starts with an empty ledger when nothing is persisted', () => { + const { service } = createService(); + assert.deepStrictEqual(service.automations.get(), []); + assert.deepStrictEqual(service.runs.get(), []); + }); + + test('createAutomation appends an entry and computes nextRunAt for non-manual schedules', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'Daily review', + prompt: 'Summarize what changed', + schedule: dailySchedule(), + folderUri: FOLDER, + }); + assert.strictEqual(service.automations.get().length, 1); + assert.strictEqual(service.automations.get()[0].id, a.id); + assert.ok(a.nextRunAt, 'daily schedule should produce a nextRunAt'); + assert.strictEqual(a.enabled, true); + }); + + test('createAutomation with manual schedule leaves nextRunAt undefined', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'Manual', + prompt: 'p', + schedule: { interval: 'manual', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }, + folderUri: FOLDER, + }); + assert.strictEqual(a.nextRunAt, undefined); + }); + + test('createAutomation throws when folderUri is missing', async () => { + const { service } = createService(); + await assert.rejects( + () => service.createAutomation({ + name: 'X', + prompt: 'p', + schedule: dailySchedule(), + // Cast to bypass type check. Simulates a runtime caller + // forgetting the required field. + folderUri: undefined as unknown as URI, + }), + /folderUri/, + ); + }); + + test('updateAutomation recomputes nextRunAt when the schedule changes', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'A', + prompt: 'p', + schedule: dailySchedule(9, 0), + folderUri: FOLDER, + }); + const before = a.nextRunAt; + const b = await service.updateAutomation(a.id, { schedule: dailySchedule(10, 30) }); + assert.notStrictEqual(b.nextRunAt, before); + }); + + test('updateAutomation keeps nextRunAt when only the name changes', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const b = await service.updateAutomation(a.id, { name: 'B' }); + assert.strictEqual(b.nextRunAt, a.nextRunAt); + assert.strictEqual(b.name, 'B'); + }); + + test('updateAutomation can clear modelId/mode/permissionLevel by passing null but keeps folderUri', async () => { + const { service } = createService(); + const a = await service.createAutomation({ + name: 'A', prompt: 'p', schedule: dailySchedule(), + folderUri: FOLDER, + modelId: 'gpt-4', + mode: 'agent', + permissionLevel: 'autopilot', + }); + const b = await service.updateAutomation(a.id, { modelId: null, mode: null, permissionLevel: null }); + assert.strictEqual(b.modelId, undefined); + assert.strictEqual(b.mode, undefined); + assert.strictEqual(b.permissionLevel, undefined); + assert.strictEqual(b.folderUri.toString(), FOLDER.toString()); + }); + + test('updateAutomation switches folder when a new folderUri is provided', async () => { + const { service } = createService(); + const other = URI.parse('file:///other'); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const b = await service.updateAutomation(a.id, { folderUri: other }); + assert.strictEqual(b.folderUri.toString(), other.toString()); + }); + + test('deleteAutomation removes the entry and orphan runs are dropped on reload', async () => { + const sharedStorage = teardown.add(new InMemoryStorageService()); + const firstService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + const a = await firstService.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + await firstService.recordRunStart(a.id, 'manual', 1); + assert.strictEqual(firstService.runs.get().length, 1); + await firstService.deleteAutomation(a.id); + // Deleting commits a new ledger, which triggers a reload that + // drops the now-orphaned run so the ledger does not grow forever. + assert.deepStrictEqual(firstService.automations.get(), []); + assert.strictEqual(firstService.runs.get().length, 0); + firstService.dispose(); + + const secondService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + assert.deepStrictEqual(secondService.automations.get(), []); + assert.strictEqual(secondService.runs.get().length, 0); + }); + + test('recordRunStart inserts a pending run; updateRun applies a patch', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const run = await service.recordRunStart(a.id, 'schedule', 42); + assert.strictEqual(run.status, 'pending'); + assert.strictEqual(run.leaderWindowId, 42); + const updated = await service.updateRun(run.id, { status: 'completed', sessionResource: 'vscode-chat-session://copilot/sess-1', completedAt: new Date().toISOString() }); + assert.strictEqual(updated?.status, 'completed'); + assert.strictEqual(updated?.sessionResource, 'vscode-chat-session://copilot/sess-1'); + }); + + test('getActiveRunFor returns the first pending or running run for an automation', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + assert.strictEqual(service.getActiveRunFor(a.id), undefined); + const run = await service.recordRunStart(a.id, 'schedule', 1); + assert.strictEqual(service.getActiveRunFor(a.id)?.id, run.id); + await service.updateRun(run.id, { status: 'completed' }); + assert.strictEqual(service.getActiveRunFor(a.id), undefined); + }); + + test('markStaleRunsFailed moves pending and running rows to failed', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const r1 = await service.recordRunStart(a.id, 'schedule', 1); + const r2 = await service.recordRunStart(a.id, 'schedule', 1); + await service.updateRun(r1.id, { status: 'running' }); + await service.markStaleRunsFailed('Interrupted'); + const all = service.runs.get(); + assert.deepStrictEqual(all.find(r => r.id === r1.id)?.status, 'failed'); + assert.deepStrictEqual(all.find(r => r.id === r2.id)?.status, 'failed'); + assert.strictEqual(all.find(r => r.id === r1.id)?.errorMessage, 'Interrupted'); + }); + + test('runsFor filters to a single automation', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const b = await service.createAutomation({ name: 'B', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + await service.recordRunStart(a.id, 'schedule', 1); + await service.recordRunStart(b.id, 'schedule', 1); + await service.recordRunStart(a.id, 'manual', 1); + assert.strictEqual(service.runsFor(a.id).get().length, 2); + assert.strictEqual(service.runsFor(b.id).get().length, 1); + }); + + test('recordRunStart caps retained runs per automation', async () => { + const { service } = createService(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const b = await service.createAutomation({ name: 'B', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + // Push 60 runs for a (cap is 50) and 5 for b. Each automation's + // history should be bounded independently. + for (let i = 0; i < 60; i++) { + await service.recordRunStart(a.id, 'manual', 1); + } + for (let i = 0; i < 5; i++) { + await service.recordRunStart(b.id, 'manual', 1); + } + assert.strictEqual(service.runsFor(a.id).get().length, 50); + assert.strictEqual(service.runsFor(b.id).get().length, 5); + }); + + test('persists across service restarts via shared storage', async () => { + const sharedStorage = teardown.add(new InMemoryStorageService()); + const firstService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + const a = await firstService.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + await firstService.recordRunStart(a.id, 'manual', 7); + firstService.dispose(); + + const secondService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + assert.strictEqual(secondService.automations.get().length, 1); + assert.strictEqual(secondService.automations.get()[0].id, a.id); + assert.strictEqual(secondService.runs.get().length, 1); + }); + + test('two services on the same storage stay in sync via onDidChangeValue', async () => { + const sharedStorage = teardown.add(new InMemoryStorageService()); + const windowA = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + const windowB = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + + assert.deepStrictEqual(windowB.automations.get(), []); + const created = await windowA.createAutomation({ name: 'X', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + + // In-memory storage fires onDidChangeValue synchronously, so windowB + // should already see the new automation. + assert.strictEqual(windowB.automations.get().length, 1); + assert.strictEqual(windowB.automations.get()[0].id, created.id); + }); + + test('reading a ledger with a future schema version freezes observables and refuses to write', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const futureLedger = JSON.stringify({ schemaVersion: 999, revision: 7, automations: [], runs: [] }); + // StorageScope.APPLICATION is -1 + storage.store('chat.automations.ledger', futureLedger, -1, 1); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + + // Observables remain empty (no prior in-memory state to preserve) + // but the service is now in read-only mode. + assert.deepStrictEqual(service.automations.get(), []); + assert.deepStrictEqual(service.runs.get(), []); + + // A subsequent mutation must be rejected (read-only mode) and must not + // destroy the on-disk newer ledger. + await assert.rejects( + () => service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }), + /newer version/, + ); + + // In-memory state is also unchanged because the mutation was rejected + // before any commit. + assert.deepStrictEqual(service.automations.get(), []); + + assert.strictEqual(storage.get('chat.automations.ledger', -1), futureLedger); + }); + + test('refreshFromStorage preserves in-memory state when storage flips to an unsupported schema', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + await service.createAutomation({ name: 'Local', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + assert.strictEqual(service.automations.get().length, 1); + + storage.store('chat.automations.ledger', JSON.stringify({ schemaVersion: 999, revision: 99, automations: [], runs: [] }), -1, 1); + + // The onDidChangeValue refresh must NOT clear our observables to + // empty. We keep displaying what we last knew about. + assert.strictEqual(service.automations.get().length, 1); + }); + + test('persist bumps the revision counter on every write', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const rev1 = JSON.parse(storage.get('chat.automations.ledger', -1)!).revision; + await service.createAutomation({ name: 'B', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const rev2 = JSON.parse(storage.get('chat.automations.ledger', -1)!).revision; + assert.strictEqual(typeof rev1, 'number'); + assert.ok(rev2 > rev1, `expected ${rev2} > ${rev1}`); + }); + + test('persist absorbs a higher on-disk revision (concurrent-write detection)', async () => { + const storage = teardown.add(new InMemoryStorageService()); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const baseline = JSON.parse(storage.get('chat.automations.ledger', -1)!); + // Simulate another window having advanced the revision behind our + // back. The service must not write a stale-or-equal revision. + storage.store('chat.automations.ledger', JSON.stringify({ ...baseline, revision: 5000 }), -1, 1); + await service.createAutomation({ name: 'B', prompt: 'p', schedule: dailySchedule(), folderUri: FOLDER }); + const after = JSON.parse(storage.get('chat.automations.ledger', -1)!); + assert.ok(after.revision > 5000, `expected revision > 5000, got ${after.revision}`); + }); + + test('reading a corrupt ledger leaves observables empty without throwing', () => { + const storage = teardown.add(new InMemoryStorageService()); + storage.store('chat.automations.ledger', 'not json', -1, 1); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + assert.deepStrictEqual(service.automations.get(), []); + }); + + test('persisted automations without folderUri are dropped on load', () => { + const storage = teardown.add(new InMemoryStorageService()); + const ledger = { + schemaVersion: 1, + automations: [ + { id: 'orphan', name: 'Old', prompt: 'p', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }, + { id: 'keep', name: 'Valid', prompt: 'p', schedule: { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }, folderUri: FOLDER.toJSON(), enabled: true, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z' }, + ], + runs: [ + { id: 'r-orphan', automationId: 'orphan', status: 'completed', trigger: 'manual', startedAt: '2024-01-01T00:00:00Z', leaderWindowId: 1 }, + { id: 'r-keep', automationId: 'keep', status: 'completed', trigger: 'manual', startedAt: '2024-01-01T00:00:00Z', leaderWindowId: 1 }, + ], + }; + storage.store('chat.automations.ledger', JSON.stringify(ledger), -1, 1); + const service = teardown.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + assert.strictEqual(service.automations.get().length, 1); + assert.strictEqual(service.automations.get()[0].id, 'keep'); + assert.strictEqual(service.runs.get().length, 1); + assert.strictEqual(service.runs.get()[0].id, 'r-keep'); + }); + + test('round-trips a folderUri through persistence', async () => { + const sharedStorage = teardown.add(new InMemoryStorageService()); + const firstService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + const uri = URI.parse('file:///workspace/project'); + await firstService.createAutomation({ name: 'A', prompt: 'p', schedule: dailySchedule(), folderUri: uri }); + + const secondService = teardown.add(new AutomationService(sharedStorage, new NullLogService(), NullTelemetryService)); + const reloaded = secondService.automations.get()[0]; + assert.strictEqual(reloaded.folderUri.toString(), uri.toString()); + }); + + test('disposal does not interfere with later in-store reads', () => { + // Just verifies the no-leaked-disposables invariant indirectly: create + // a service and let teardown clean it up. Failure surfaces as a + // leaked-disposable assertion at suite teardown. + const store = new DisposableStore(); + const storage = store.add(new InMemoryStorageService()); + const service = store.add(new AutomationService(storage, new NullLogService(), NullTelemetryService)); + assert.deepStrictEqual(service.automations.get(), []); + store.dispose(); + }); +}); diff --git a/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts new file mode 100644 index 00000000000000..97b348334f52e5 --- /dev/null +++ b/src/vs/sessions/contrib/blockedSessions/browser/blockedSessions.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derivedOpts, IObservable, IReaderWithStore, observableFromEvent } from '../../../../base/common/observable.js'; +import { equals } from '../../../../base/common/arrays.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { IGitHubService } from '../../github/browser/githubService.js'; +import { computePullRequestIconStatus } from '../../github/browser/pullRequestIconStatus.js'; + +/** + * Why a session is surfaced as "blocked" (i.e. needs the user's attention). + */ +export const enum BlockedSessionReason { + /** The session is waiting for the user to provide input or approve an action. */ + NeedsInput = 'needsInput', + /** The session's pull request has failing CI checks. */ + FailingCI = 'failingCI', + /** The session's pull request has unresolved review comments. */ + UnresolvedComments = 'unresolvedComments', +} + +/** A blocked session paired with the reason it needs attention. */ +export interface IBlockedSession { + readonly session: ISession; + readonly reason: BlockedSessionReason; +} + +/** + * Surfaces the set of "blocked" sessions — sessions that require the user's + * attention. A session is considered blocked when it: + * + * - needs input (`SessionStatus.NeedsInput`), or + * - has failing CI checks while not in progress, or + * - has unresolved pull request comments while not in progress. + * + * Archived (done) sessions are never reported as blocked. + */ +export class BlockedSessions extends Disposable { + + private readonly _allSessions: IObservable<readonly ISession[]>; + + /** The blocked sessions, most-recently-updated first. */ + readonly blockedSessions: IObservable<readonly ISession[]>; + + /** The blocked sessions paired with their reason, most-recently-updated first. */ + readonly blockedSessionsWithReasons: IObservable<readonly IBlockedSession[]>; + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IGitHubService private readonly _gitHubService: IGitHubService, + ) { + super(); + + this._allSessions = observableFromEvent( + this, + this._sessionsManagementService.onDidChangeSessions, + () => this._sessionsManagementService.getSessions(), + ); + + // Structural equality keeps the deriveds from propagating when a recompute + // (e.g. an `updatedAt` tick that doesn't reorder, or a reason-only change) + // yields the same result, so downstream autoruns/renders don't churn. + this.blockedSessionsWithReasons = derivedOpts({ + owner: this, + equalsFn: (a, b) => equals(a, b, (x, y) => x.session.sessionId === y.session.sessionId && x.reason === y.reason), + }, reader => { + const blocked: IBlockedSession[] = []; + for (const session of this._allSessions.read(reader)) { + // `derivedOpts` under-types the store-backed reader as `IReader`; it is an `IDerivedReader` at runtime. + const reason = this._getBlockedReason(reader as IReaderWithStore, session); + if (reason !== undefined) { + blocked.push({ session, reason }); + } + } + return blocked.sort((a, b) => b.session.updatedAt.read(reader).getTime() - a.session.updatedAt.read(reader).getTime()); + }); + + this.blockedSessions = derivedOpts({ + owner: this, + equalsFn: (a, b) => equals(a, b, (x, y) => x.sessionId === y.sessionId), + }, reader => this.blockedSessionsWithReasons.read(reader).map(blocked => blocked.session)); + } + + private _getBlockedReason(reader: IReaderWithStore, session: ISession): BlockedSessionReason | undefined { + if (session.isArchived.read(reader)) { + return undefined; + } + + const status = session.status.read(reader); + if (status === SessionStatus.NeedsInput) { + return BlockedSessionReason.NeedsInput; + } + + // CI failures and pull request comments only count while the session is + // not actively in progress. + if (status === SessionStatus.InProgress) { + return undefined; + } + + const gitHubInfo = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + if (!gitHubInfo?.pullRequest) { + return undefined; + } + + const prRef = reader.store.add(this._gitHubService.createPullRequestModelReference(gitHubInfo.owner, gitHubInfo.repo, gitHubInfo.pullRequest.number)); + const livePR = prRef.object.pullRequest.read(reader); + if (!livePR) { + return undefined; + } + + const prStatus = computePullRequestIconStatus(reader, this._gitHubService, gitHubInfo.owner, gitHubInfo.repo, livePR); + if (prStatus.hasFailingChecks) { + return BlockedSessionReason.FailingCI; + } + if (prStatus.hasUnresolvedComments) { + return BlockedSessionReason.UnresolvedComments; + } + return undefined; + } +} diff --git a/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts new file mode 100644 index 00000000000000..886ea713eff3d0 --- /dev/null +++ b/src/vs/sessions/contrib/blockedSessions/test/browser/blockedSessions.test.ts @@ -0,0 +1,277 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore, ImmortalReference, type IReference } from '../../../../../base/common/lifecycle.js'; +import { autorun, ISettableObservable, observableValue, type IObservable } from '../../../../../base/common/observable.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 { IGitHubService } from '../../../github/browser/githubService.js'; +import { GitHubPullRequestCIModel } from '../../../github/browser/models/githubPullRequestCIModel.js'; +import { GitHubPullRequestModel } from '../../../github/browser/models/githubPullRequestModel.js'; +import { GitHubPullRequestReviewThreadsModel } from '../../../github/browser/models/githubPullRequestReviewThreadsModel.js'; +import { GitHubCIOverallStatus, GitHubPullRequestState, IGitHubPullRequest, IGitHubPullRequestReviewThread } from '../../../github/common/types.js'; +import { ISession, IGitHubInfo, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; +import { BlockedSessionReason, BlockedSessions } from '../../browser/blockedSessions.js'; + +suite('BlockedSessions', () => { + + const store = new DisposableStore(); + + teardown(() => store.clear()); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createService(sessions: TestSession[], gitHubService: TestGitHubService): { service: BlockedSessions; management: TestSessionsManagementService } { + const management = new TestSessionsManagementService(sessions as unknown as ISession[]); + const service = store.add(new BlockedSessions(management as unknown as ISessionsManagementService, gitHubService as unknown as IGitHubService)); + // Keep the derived live so per-session model references are actually read. + store.add(autorun(reader => { service.blockedSessions.read(reader); })); + return { service, management }; + } + + function blockedIds(service: BlockedSessions): string[] { + return service.blockedSessions.get().map(s => s.sessionId); + } + + function blockedReasons(service: BlockedSessions): Array<[string, BlockedSessionReason]> { + return service.blockedSessionsWithReasons.get().map((b): [string, BlockedSessionReason] => [b.session.sessionId, b.reason]); + } + + test('session needing input is blocked', () => { + const session = new TestSession('s1', SessionStatus.NeedsInput); + const { service } = createService([session], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), ['s1']); + }); + + test('in-progress, completed (no PR) and archived sessions are not blocked', () => { + const inProgress = new TestSession('inprogress', SessionStatus.InProgress); + const completed = new TestSession('completed', SessionStatus.Completed); + const archived = new TestSession('archived', SessionStatus.NeedsInput, { archived: true }); + const { service } = createService([inProgress, completed, archived], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('completed session with failing CI checks is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 7, openPullRequest(7, 'sha7')); + gitHub.setCIStatus('owner', 'repo', 7, 'sha7', GitHubCIOverallStatus.Failure); + const session = new TestSession('ci', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 7 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), ['ci']); + }); + + test('completed session with unresolved PR comments is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 8, openPullRequest(8, 'sha8')); + gitHub.setReviewThreads('owner', 'repo', 8, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const session = new TestSession('comments', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 8 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), ['comments']); + }); + + test('in-progress session with failing CI is not blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 9, openPullRequest(9, 'sha9')); + gitHub.setCIStatus('owner', 'repo', 9, 'sha9', GitHubCIOverallStatus.Failure); + const session = new TestSession('busy', SessionStatus.InProgress, { pr: { owner: 'owner', repo: 'repo', number: 9 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('completed session with passing CI and resolved comments is not blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 10, openPullRequest(10, 'sha10')); + gitHub.setCIStatus('owner', 'repo', 10, 'sha10', GitHubCIOverallStatus.Success); + gitHub.setReviewThreads('owner', 'repo', 10, [{ isResolved: true } as IGitHubPullRequestReviewThread]); + const session = new TestSession('clean', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 10 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedIds(service), []); + }); + + test('blocked sessions update reactively when status changes', () => { + const session = new TestSession('reactive', SessionStatus.Completed); + const { service } = createService([session], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), []); + session.setStatus(SessionStatus.NeedsInput); + assert.deepStrictEqual(blockedIds(service), ['reactive']); + }); + + test('blocked sessions are sorted most-recently-updated first', () => { + const older = new TestSession('older', SessionStatus.NeedsInput, { updatedAt: new Date(1000) }); + const newer = new TestSession('newer', SessionStatus.NeedsInput, { updatedAt: new Date(5000) }); + const { service } = createService([older, newer], new TestGitHubService()); + assert.deepStrictEqual(blockedIds(service), ['newer', 'older']); + }); + + test('reports the reason each session is blocked', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 20, openPullRequest(20, 'sha20')); + gitHub.setCIStatus('owner', 'repo', 20, 'sha20', GitHubCIOverallStatus.Failure); + gitHub.setPullRequest('owner', 'repo', 21, openPullRequest(21, 'sha21')); + gitHub.setReviewThreads('owner', 'repo', 21, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const needsInput = new TestSession('needsinput', SessionStatus.NeedsInput, { updatedAt: new Date(3000) }); + const failingCI = new TestSession('failingci', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 20 }, updatedAt: new Date(2000) }); + const comments = new TestSession('comments', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 21 }, updatedAt: new Date(1000) }); + const { service } = createService([needsInput, failingCI, comments], gitHub); + assert.deepStrictEqual(blockedReasons(service), [ + ['needsinput', BlockedSessionReason.NeedsInput], + ['failingci', BlockedSessionReason.FailingCI], + ['comments', BlockedSessionReason.UnresolvedComments], + ]); + }); + + test('failing CI takes precedence over unresolved comments', () => { + const gitHub = new TestGitHubService(); + gitHub.setPullRequest('owner', 'repo', 22, openPullRequest(22, 'sha22')); + gitHub.setCIStatus('owner', 'repo', 22, 'sha22', GitHubCIOverallStatus.Failure); + gitHub.setReviewThreads('owner', 'repo', 22, [{ isResolved: false } as IGitHubPullRequestReviewThread]); + const session = new TestSession('both', SessionStatus.Completed, { pr: { owner: 'owner', repo: 'repo', number: 22 } }); + const { service } = createService([session], gitHub); + assert.deepStrictEqual(blockedReasons(service), [['both', BlockedSessionReason.FailingCI]]); + }); +}); + +function openPullRequest(number: number, headSha: string): IGitHubPullRequest { + return { number, headSha, isDraft: false, state: GitHubPullRequestState.Open } as unknown as IGitHubPullRequest; +} + +interface ITestSessionOptions { + readonly archived?: boolean; + readonly pr?: { owner: string; repo: string; number: number }; + readonly updatedAt?: Date; +} + +class TestSession { + readonly sessionId: string; + readonly resource: URI; + readonly status: ISettableObservable<SessionStatus>; + readonly isArchived: IObservable<boolean>; + readonly updatedAt: IObservable<Date>; + readonly workspace: IObservable<unknown>; + + private readonly _status: ISettableObservable<SessionStatus>; + + constructor(id: string, status: SessionStatus, options: ITestSessionOptions = {}) { + this.sessionId = id; + this.resource = URI.parse(`test-session:/${id}`); + this._status = observableValue<SessionStatus>(`test.status.${id}`, status); + this.status = this._status; + this.isArchived = observableValue<boolean>(`test.archived.${id}`, options.archived ?? false); + this.updatedAt = observableValue<Date>(`test.updatedAt.${id}`, options.updatedAt ?? new Date(0)); + + const gitHubInfo: IGitHubInfo | undefined = options.pr + ? { owner: options.pr.owner, repo: options.pr.repo, pullRequest: { number: options.pr.number, uri: URI.parse(`https://github.com/${options.pr.owner}/${options.pr.repo}/pull/${options.pr.number}`) } } + : undefined; + const gitHubInfoObs = observableValue<IGitHubInfo | undefined>(`test.gitHubInfo.${id}`, gitHubInfo); + this.workspace = observableValue<unknown>(`test.workspace.${id}`, { + folders: [{ gitRepository: { gitHubInfo: gitHubInfoObs } }], + }); + } + + setStatus(status: SessionStatus): void { + this._status.set(status, undefined); + } +} + +class TestSessionsManagementService extends mock<ISessionsManagementService>() { + + private readonly _onDidChangeSessions = new Emitter<ISessionsChangeEvent>(); + override readonly onDidChangeSessions = this._onDidChangeSessions.event; + + constructor(private readonly _sessions: ISession[]) { + super(); + } + + override getSessions(): ISession[] { + return this._sessions; + } + + override getSession(resource: URI): ISession | undefined { + return this._sessions.find(s => s.resource.toString() === resource.toString()); + } +} + +class TestGitHubService extends mock<IGitHubService>() { + + private readonly _prModels = new Map<string, TestPullRequestModel>(); + private readonly _ciModels = new Map<string, TestCIModel>(); + private readonly _reviewThreadModels = new Map<string, TestReviewThreadsModel>(); + + override createPullRequestModelReference(owner: string, repo: string, prNumber: number): IReference<GitHubPullRequestModel> { + return new ImmortalReference(this._prModel(owner, repo, prNumber) as unknown as GitHubPullRequestModel); + } + + override createPullRequestCIModelReference(owner: string, repo: string, prNumber: number, headSha: string): IReference<GitHubPullRequestCIModel> { + return new ImmortalReference(this._ciModel(owner, repo, prNumber, headSha) as unknown as GitHubPullRequestCIModel); + } + + override createPullRequestReviewThreadsModelReference(owner: string, repo: string, prNumber: number): IReference<GitHubPullRequestReviewThreadsModel> { + return new ImmortalReference(this._reviewThreadModel(owner, repo, prNumber) as unknown as GitHubPullRequestReviewThreadsModel); + } + + setPullRequest(owner: string, repo: string, prNumber: number, pullRequest: IGitHubPullRequest): void { + this._prModel(owner, repo, prNumber).set(pullRequest); + } + + setCIStatus(owner: string, repo: string, prNumber: number, headSha: string, status: GitHubCIOverallStatus): void { + this._ciModel(owner, repo, prNumber, headSha).set(status); + } + + setReviewThreads(owner: string, repo: string, prNumber: number, threads: readonly IGitHubPullRequestReviewThread[]): void { + this._reviewThreadModel(owner, repo, prNumber).set(threads); + } + + private _prModel(owner: string, repo: string, prNumber: number): TestPullRequestModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._prModels.get(key); + if (!model) { + model = new TestPullRequestModel(); + this._prModels.set(key, model); + } + return model; + } + + private _ciModel(owner: string, repo: string, prNumber: number, headSha: string): TestCIModel { + const key = `${owner}/${repo}/${prNumber}/${headSha}`; + let model = this._ciModels.get(key); + if (!model) { + model = new TestCIModel(); + this._ciModels.set(key, model); + } + return model; + } + + private _reviewThreadModel(owner: string, repo: string, prNumber: number): TestReviewThreadsModel { + const key = `${owner}/${repo}/${prNumber}`; + let model = this._reviewThreadModels.get(key); + if (!model) { + model = new TestReviewThreadsModel(); + this._reviewThreadModels.set(key, model); + } + return model; + } +} + +class TestPullRequestModel { + private readonly _pullRequest = observableValue<IGitHubPullRequest | undefined>('test.pullRequest', undefined); + readonly pullRequest: IObservable<IGitHubPullRequest | undefined> = this._pullRequest; + set(pullRequest: IGitHubPullRequest): void { this._pullRequest.set(pullRequest, undefined); } +} + +class TestCIModel { + private readonly _overallStatus = observableValue<GitHubCIOverallStatus>('test.ciStatus', GitHubCIOverallStatus.Neutral); + readonly overallStatus: IObservable<GitHubCIOverallStatus> = this._overallStatus; + set(status: GitHubCIOverallStatus): void { this._overallStatus.set(status, undefined); } +} + +class TestReviewThreadsModel { + private readonly _reviewThreads = observableValue<readonly IGitHubPullRequestReviewThread[]>('test.reviewThreads', []); + readonly reviewThreads: IObservable<readonly IGitHubPullRequestReviewThread[]> = this._reviewThreads; + set(threads: readonly IGitHubPullRequestReviewThread[]): void { this._reviewThreads.set(threads, undefined); } +} diff --git a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts index f778f812f1eb12..bab5739bcc4372 100644 --- a/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts +++ b/src/vs/sessions/contrib/browserView/browser/sessionBrowserView.ts @@ -5,6 +5,7 @@ import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Emitter } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; import { BrowserEditorInput } from '../../../../workbench/contrib/browserView/common/browserEditorInput.js'; @@ -59,15 +60,16 @@ export class SessionBrowserViewController extends Disposable implements IWorkben this._register(this._browserViewService.registerContextualFilter({ include: (input, context) => { const tracked = this._trackedInputs.get(input.id); - // `owner.sessionId` is the session *resource* URI string (set by the - // browser tools from `sessionResource.toString()`), not the composite - // `ISession.sessionId` (`providerId:resource`). Compare resource-to-resource. - const sessionResource = input.model?.owner.sessionId ?? tracked?.session.resource.toString(); - if (!sessionResource) { + const ownerId = input.model?.owner.sessionId ?? tracked?.session.resource.toString(); + if (!ownerId) { return true; // no owning session known } - const activeSessionResource = context.activeSessionId ?? this._sessionsService.activeSession.read(undefined)?.resource.toString(); - return sessionResource === activeSessionResource; + const owningSession = this._resolveOwningSession(ownerId) ?? tracked?.session; + if (!owningSession) { + return true; // owning chat/session no longer known; don't hide it + } + const activeSession = context.activeSessionId ? this._resolveOwningSession(context.activeSessionId) : this._sessionsService.activeSession.read(undefined); + return activeSession?.sessionId === owningSession.sessionId; }, onDidChange: onDidChangeActiveSession.event, })); @@ -78,8 +80,12 @@ export class SessionBrowserViewController extends Disposable implements IWorkben if (!owner.sessionId) { return true; // no owning session known; open in the active session } - const activeSessionResource = this._sessionsService.activeSession.read(undefined)?.resource.toString(); - return owner.sessionId === activeSessionResource; + const owningSession = this._resolveOwningSession(owner.sessionId); + if (!owningSession) { + return true; // owning chat/session no longer known; open in the active session + } + const activeSession = this._sessionsService.activeSession.read(undefined); + return owningSession.sessionId === activeSession?.sessionId; }, })); @@ -102,6 +108,24 @@ export class SessionBrowserViewController extends Disposable implements IWorkben })); } + /** + * Resolves a browser view owner id (the *chat* resource string the + * browser tools stamp onto `IBrowserViewOwner.sessionId`) to the + * `ISession` that owns it. A session's browsers can be opened from any + * of its chats (main, peer, or subagent), so this looks up the owning + * session across all chats rather than comparing against the session's + * own resource directly. + */ + private _resolveOwningSession(ownerId: string): ISession | undefined { + let resource: URI; + try { + resource = URI.parse(ownerId); + } catch { + return undefined; + } + return this._sessionManagementService.getSessionForChatResource(resource)?.session ?? this._sessionManagementService.getSession(resource); + } + private _attachLifecycle(input: BrowserEditorInput): void { if (this._trackedInputs.has(input.id)) { return; diff --git a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts index b49834b133c628..62d52b2703e518 100644 --- a/src/vs/sessions/contrib/changes/browser/changes.contribution.ts +++ b/src/vs/sessions/contrib/changes/browser/changes.contribution.ts @@ -4,15 +4,22 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../nls.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { IViewContainersRegistry, ViewContainerLocation, IViewsRegistry, Extensions as ViewContainerExtensions, WindowEnablement } from '../../../../workbench/common/views.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; -import { ChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; -import { IsPhoneLayoutContext } from '../../../common/contextkeys.js'; +import { ChangesViewPane, SinglePaneChangesViewPane, ChangesViewPaneContainer } from './changesView.js'; +import { SessionChangesEditor } from './sessionChangesEditor.js'; +import { SessionChangesEditorInput, SessionChangesEditorSerializer } from './sessionChangesEditorInput.js'; +import { IsPhoneLayoutContext, SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ISessionChangesService, SessionChangesService } from './sessionChangesService.js'; import './changesActions.js'; import './changesViewActions.js'; @@ -21,9 +28,47 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { ChangesViewService } from './changesViewService.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { SessionsChangesAccessibilityHelp } from './sessionsChangesAccessibilityHelp.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; registerSingleton(ISessionChangesService, SessionChangesService, InstantiationType.Delayed); +/** + * Registers the custom single-pane Changes editor (multi-diff pane with the header + * toolbar) and its serializer, only when the single-pane layout is enabled. In the + * standard layout, changes open as a plain multi-diff editor instead. Registered at + * startup (before editor restore) so persisted Changes tabs can be deserialized. + */ +class SinglePaneChangesEditorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneChangesEditor'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create(SessionChangesEditor, SessionChangesEditor.ID, localize('sessionChangesEditor.label', "Changes")), + [new SyncDescriptor(SessionChangesEditorInput)] + )); + + this._register(Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer( + SessionChangesEditorInput.ID, + SessionChangesEditorSerializer + )); + } +} + +registerWorkbenchContribution2(SinglePaneChangesEditorContribution.ID, SinglePaneChangesEditorContribution, WorkbenchPhase.BlockStartup); + +AccessibleViewRegistry.register(new SessionsChangesAccessibilityHelp()); + const changesViewIcon = registerIcon('changes-view-icon', Codicon.gitCompare, localize2('changesViewIcon', 'View icon for the Changes view.').value); @@ -36,7 +81,7 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ order: 10, ctorDescriptor: new SyncDescriptor(ChangesViewPaneContainer), storageId: CHANGES_VIEW_CONTAINER_ID, - hideIfEmpty: false, + hideIfEmpty: true, openCommandActionDescriptor: { id: CHANGES_VIEW_CONTAINER_ID, mnemonicTitle: localize({ key: 'miChanges', comment: ['&& denotes a mnemonic'] }, "Chan&&ges"), @@ -53,18 +98,38 @@ const changesViewContainer = viewContainersRegistry.registerViewContainer({ const viewsRegistry = Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry); -viewsRegistry.registerViews([{ - id: CHANGES_VIEW_ID, - name: localize2('changes', 'Changes'), - containerIcon: changesViewIcon, - ctorDescriptor: new SyncDescriptor(ChangesViewPane), - canToggleVisibility: false, - canMoveView: false, - weight: 100, - order: 1, - when: IsPhoneLayoutContext.negate(), - windowEnablement: WindowEnablement.Sessions, -}], changesViewContainer); +/** + * Registers the Changes view with the layout-appropriate pane class: the single-pane + * {@link SinglePaneChangesViewPane} when the single-pane layout is enabled, otherwise + * the standard {@link ChangesViewPane}. Registered at startup (the setting is resolved + * once; toggling requires a window reload). + */ +class ChangesViewContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.changesView'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + const ctor = layoutService.isSinglePaneLayoutEnabled ? SinglePaneChangesViewPane : ChangesViewPane; + viewsRegistry.registerViews([{ + id: CHANGES_VIEW_ID, + name: localize2('changes', 'Changes'), + containerIcon: changesViewIcon, + ctorDescriptor: new SyncDescriptor(ctor), + canToggleVisibility: false, + canMoveView: false, + weight: 100, + order: 1, + when: ContextKeyExpr.and(IsPhoneLayoutContext.negate(), SessionHasWorkspaceContext), + windowEnablement: WindowEnablement.Sessions, + }], changesViewContainer); + } +} + +registerWorkbenchContribution2(ChangesViewContribution.ID, ChangesViewContribution, WorkbenchPhase.BlockStartup); Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', diff --git a/src/vs/sessions/contrib/changes/browser/changesActions.ts b/src/vs/sessions/contrib/changes/browser/changesActions.ts index bbc30a8b060d54..b8432c607f037b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesActions.ts @@ -9,27 +9,43 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { autorun, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { autorun, derivedOpts, IObservable, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { ActiveEditorContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { MultiDiffEditor } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditor.js'; +import { SessionChangesEditor } from './sessionChangesEditor.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; -import { SessionHasChangesContext } from '../../../common/contextkeys.js'; +import { SessionHasChangesContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; import { IChangesViewService } from '../common/changesViewService.js'; -import { ChangesMultiDiffSourceResolver } from './changesMultiDiffSourceResolver.js'; +import { ChangesMultiDiffSourceResolver, SessionChangesFileResourceContext, SessionChangesReviewedFilesContext } from './changesMultiDiffSourceResolver.js'; import { ISessionChangesService } from './sessionChangesService.js'; +import { isEqual } from '../../../../base/common/resources.js'; + +/** + * Command id of the {@link ViewAllChangesAction}. Opens the session's multi-file + * diff editor. Exported so other session surfaces (e.g. the chat input pills) + * can trigger the same "View Changes" behavior without duplicating the id. + */ +export const VIEW_SESSION_CHANGES_COMMAND_ID = 'workbench.agentSessions.action.viewChanges'; // --- View All Changes action class ViewAllChangesAction extends Action2 { - static readonly ID = 'workbench.agentSessions.action.viewChanges'; + static readonly ID = VIEW_SESSION_CHANGES_COMMAND_ID; constructor() { super({ @@ -44,16 +60,16 @@ class ViewAllChangesAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: 0, - when: SessionHasChangesContext + when: ContextKeyExpr.and(SessionHasChangesContext, IsQuickChatSessionContext.negate()) }, }); } override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise<void> { - const editorService = accessor.get(IEditorService); const sessionsService = accessor.get(ISessionsService); const sessionChangesService = accessor.get(ISessionChangesService); const changesViewService = accessor.get(IChangesViewService); + const layoutService = accessor.get(IAgentWorkbenchLayoutService); // The clicked session is forwarded as the argument by the session header, // which has already promoted it to be the active session. Fall back to the @@ -68,17 +84,60 @@ class ViewAllChangesAction extends Action2 { // (a shared per-session resource) shows the same changes as the pill. changesViewService.setChangesetId(undefined); - // Open the multi-file diff editor in the editor part. The resource list is + // Opening the Changes editor from the pill is a deliberate user action, so + // reveal the (possibly hidden) editor area explicitly — the automatic + // single-pane hide rules must not undo it. + layoutService.revealEditorPartExplicitly(); + + // Open the session Changes editor in the editor part. The resource list is // resolved reactively via the `ChangesMultiDiffSourceResolver` registered as // a workbench contribution. - await editorService.openEditor({ - multiDiffSource: sessionChangesService.getChangesEditorResource(sessionResource), - label: localize('sessions.changes.title', 'Session Changes'), - }); + await sessionChangesService.openChangesEditor(sessionResource); } } registerAction2(ViewAllChangesAction); +// --- Open File action (per-file toolbar in the single-pane session changes editor) + +/** + * Opens the file shown in a diff row of the Agents window's single-pane session + * Changes editor ({@link SessionChangesEditor}) as a regular editor. The workbench + * {@link GoToFileAction} only appears for the generic {@link MultiDiffEditor}, so + * the custom single-pane editor needs its own entry in the per-file toolbar. It is + * scoped to the {@link SessionChangesEditor} rather than the shared + * `changes-multi-diff-source` scheme so it does not duplicate the workbench action + * when the same changes are shown in the generic multi-file diff editor. + */ +class OpenChangedFileAction extends Action2 { + + static readonly ID = 'workbench.agentSessions.changes.openFile'; + + constructor() { + super({ + id: OpenChangedFileAction.ID, + title: localize2('agentSessions.changes.openFile', 'Open File'), + icon: Codicon.goToFile, + f1: false, + menu: { + id: MenuId.MultiDiffEditorFileToolbar, + when: ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), + group: 'navigation', + order: 22, + }, + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> { + const resource = args[0]; + if (!(resource instanceof URI)) { + return; + } + + await accessor.get(IEditorService).openEditor({ resource }); + } +} +registerAction2(OpenChangedFileAction); + // --- View All Changes action view item (session header diff stats) interface IDiffStats { @@ -96,9 +155,10 @@ interface IDiffStats { * action, which opens the multi-file diff editor. * * The stats are read from the {@link ISessionContext} so the correct per-session changes - * are shown even when several session views are visible at once. The counts reflect the - * changeset the provider marks as {@link ISessionChangeset.isDefault}, falling back to the - * session's top-level {@link IActiveSession.changes} when none is default. + * are shown even when several session views are visible at once. The counts come from the + * session's {@link ISession.changesSummary} when available, falling back to aggregating the + * changeset the provider marks as {@link ISessionChangeset.isDefault} (or the session's + * top-level {@link IActiveSession.changes} when none is default). */ export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewItem { @@ -113,16 +173,37 @@ export class ViewAllChangesActionViewItem extends SessionHeaderMetaActionViewIte this._diffStatsObs = derivedOpts<IDiffStats>({ owner: this, equalsFn: structuralEquals }, reader => { const session = sessionContext.session.read(reader); + const workspace = session?.workspace.read(reader); + const branch = workspace?.folders[0]?.gitRepository?.branchName?.trim(); + + // Prefer the provider-supplied changes summary which reflects the + // session's authoritative aggregate. Fall back to aggregating the + // default changeset's changes when no summary is available. + const changesSummary = session?.changesSummary?.read(reader); + if (changesSummary) { + return { + branch, + files: changesSummary.files, + insertions: changesSummary.additions, + deletions: changesSummary.deletions, + } satisfies IDiffStats; + } + const defaultChangeset = session?.changesets.read(reader)?.find(c => c.isDefault.read(reader)); const changes = (defaultChangeset?.changes.read(reader) ?? session?.changes.read(reader)) ?? []; - let insertions = 0; - let deletions = 0; + + let insertions = 0, deletions = 0; for (const change of changes) { insertions += change.insertions; deletions += change.deletions; } - const branch = session?.workspace.read(reader)?.folders[0]?.gitRepository?.branchName?.trim() || undefined; - return { files: changes.length, insertions, deletions, branch }; + + return { + branch, + files: changes.length, + insertions, + deletions, + } satisfies IDiffStats; }); this._register(autorun(reader => { @@ -223,5 +304,140 @@ class ChangesMultiDiffSourceResolverContribution extends Disposable implements I } } +class ChangesetOperationsActionControllerContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.sessions.changesetOperationsActionController'; + + constructor( + @IChangesViewService changesViewService: IChangesViewService, + @IContextKeyService contextKeyService: IContextKeyService + ) { + super(); + + // Use to optimistically update the toolbars until the server confirms + // the state. As soon as the server confirms the state, the client array + // will be reset to `undefined` so that the server state takes precedence. + const clientReviewedFilesObs = observableValue<string[] | undefined>(this, undefined); + + // Authoritative source of reviewed files. This will be updated + // when the state is saved on the server and confirmed back to + // the client + const agentHostReviewedFilesObs = observableValue<string[]>(this, []); + + this._register(autorun(reader => { + const changes = changesViewService.activeSessionChangesObs.read(reader); + + const reviewedFiles = changes + .filter(change => change.reviewed) + .map(change => change.modifiedUri?.toString() ?? change.originalUri?.toString()) + .filter((uri: string | undefined) => uri !== undefined); + + transaction(tx => { + clientReviewedFilesObs.set(undefined, tx); + agentHostReviewedFilesObs.set(reviewedFiles, tx); + }); + })); + + this._register(bindContextKey<string[]>(SessionChangesReviewedFilesContext, contextKeyService, reader => { + return clientReviewedFilesObs.read(reader) ?? agentHostReviewedFilesObs.read(reader); + })); + + this._register(autorun(reader => { + const changeset = changesViewService.activeSessionChangesetObs.read(reader); + const resourceOperations = (changeset?.operations.read(reader) ?? []) + .filter(op => op.scopes.includes(SessionChangesetOperationScope.Resource)); + + if (resourceOperations.length === 0) { + return; + } + + for (const operation of resourceOperations) { + reader.store.add(registerAction2(class extends Action2 { + constructor() { + super({ + id: `workbench.contrib.sessions.changesetOperation.${operation.id}`, + title: operation.label, + icon: operation.icon, + f1: false, + toggled: ContextKeyExpr.in( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key), + menu: [{ + id: MenuId.AgentsChangeInlineToolbar, + // This is a temporary solution until the agent host protocol + // adds support to specify operations for each individual file + when: operation.group === 'review' + ? ContextKeyExpr.false() + : ContextKeyExpr.true(), + group: 'navigation', + order: 100 + }, + { + id: MenuId.MultiDiffEditorFileToolbar, + // This is a temporary solution until the agent host protocol + // adds support to specify operations for each individual file + when: operation.group === 'review' + ? operation.id === 'mark-as-reviewed' + ? ContextKeyExpr.and( + ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + ContextKeyExpr.notIn( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key)) + : ContextKeyExpr.and( + ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + ContextKeyExpr.in( + SessionChangesFileResourceContext.key, + SessionChangesReviewedFilesContext.key)) + : ContextKeyExpr.equals('resourceScheme', 'changes-multi-diff-source'), + group: 'navigation', + order: 100 + }] + }); + } + + async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> { + const activeEditorPane = accessor.get(IEditorService).activeEditorPane; + + // The Changes view provides the resource as the third argument (uses a + // custom action runner) while the multi-file diff editor provides the + // resource as the first argument. + const resource = args.length === 3 ? args[2] : args[0]; + if (!resource || !(resource instanceof URI)) { + return; + } + + // Optimistic update the state + if (operation.id === 'mark-as-reviewed') { + // Update context key for the toolbar + const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined); + clientReviewedFilesObs.set([...agentHostReviewedFiles, resource.toString()], undefined); + + // Collapse multi-file diff editor item + if (activeEditorPane instanceof MultiDiffEditor) { + const viewModel = activeEditorPane.viewModel; + const item = viewModel?.items.read(undefined) + .find(i => isEqual(i.modifiedUri, resource) || isEqual(i.originalUri, resource)); + + if (item) { + viewModel!.collapse(item); + } + } + } else if (operation.id === 'mark-as-unreviewed') { + // Update context key for the toolbar + const agentHostReviewedFiles = agentHostReviewedFilesObs.read(undefined); + clientReviewedFilesObs.set([...agentHostReviewedFiles.filter(f => f !== resource.toString())], undefined); + } + + await changeset?.invokeOperation(operation.id, { + kind: 'resource', + resource, + }); + } + })); + } + })); + } +} + registerWorkbenchContribution2(ChangesMultiDiffSourceResolverContribution.ID, ChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChangesetOperationsActionControllerContribution.ID, ChangesetOperationsActionControllerContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ViewAllChangesActionViewItemContribution.ID, ViewAllChangesActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts index d3abb3f53158b4..eaa289efd78214 100644 --- a/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts +++ b/src/vs/sessions/contrib/changes/browser/changesMultiDiffSourceResolver.ts @@ -14,6 +14,23 @@ import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMul import { ISessionFileChange } from '../../../services/sessions/common/session.js'; import { IChangesViewService } from '../common/changesViewService.js'; import { ISessionChangesService } from './sessionChangesService.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; + +/** + * Global context key holding the URIs (as strings) of every file the user has + * marked as reviewed in the active session. Combined with + * {@link SessionChangesFileResourceContext} via the `in` / `not in` operators, + * file toolbar menu items can toggle between "Mark as Reviewed" and + * "Unmark as Reviewed". + */ +export const SessionChangesReviewedFilesContext = new RawContextKey<string[]>('sessions.changesReviewedFiles', []); + +/** + * Per-file context key set on each entry in the changes multi-diff editor. + * Holds the URI (as a string) of the file shown in that diff row, so it can be + * tested for membership in {@link SessionChangesReviewedFilesContext}. + */ +export const SessionChangesFileResourceContext = new RawContextKey<string>('sessions.changesFileResource', undefined); function compareChanges(a: ISessionFileChange, b: ISessionFileChange): number { const aPath = isIChatSessionFileChange2(a) ? a.uri.fsPath : a.modifiedUri.fsPath; @@ -42,7 +59,7 @@ export class ChangesMultiDiffSourceResolver extends Disposable implements IMulti const changesObs = derivedObservableWithCache<readonly ISessionFileChange[]>({ owner: this, }, (reader, lastValue) => { - if (this.changesViewService.activeSessionIsLoadingObs.read(reader)) { + if (this.changesViewService.activeSessionLoadingObs.read(reader)) { return lastValue ?? []; } @@ -62,7 +79,9 @@ export class ChangesMultiDiffSourceResolver extends Disposable implements IMulti }, reader => { const changes = changesObs.read(reader); return [...changes].sort(compareChanges).map(change => - new MultiDiffEditorItem(change.originalUri, change.modifiedUri, change.modifiedUri)); + new MultiDiffEditorItem(change.originalUri, change.modifiedUri, change.modifiedUri, undefined, { + [SessionChangesFileResourceContext.key]: change.modifiedUri?.toString() ?? change.originalUri?.toString() ?? '', + })); }); return { resources: new ValueWithChangeEventFromObservable(resourcesObs) }; diff --git a/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts new file mode 100644 index 00000000000000..5812b2b60bc321 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/changesSummaryWidget.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/changesSummaryWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, derivedObservableWithCache, derivedOpts, IObservable } from '../../../../base/common/observable.js'; +import { ISessionChangesSummary } from '../../../services/sessions/common/session.js'; +import { IChangesViewService } from '../common/changesViewService.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { AnimatedCounterWidget } from '../../../../workbench/browser/animatedCounterWidget.js'; + +export class ChangesSummaryWidget extends Disposable { + private readonly _summaryObs: IObservable<ISessionChangesSummary | undefined>; + get summary() { return this._summaryObs; } + + constructor( + @IChangesViewService changesViewService: IChangesViewService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + const summaryRawObs = derivedObservableWithCache<ISessionChangesSummary | undefined>(this, (reader, lastValue) => { + const isLoading = changesViewService.activeSessionLoadingObs.read(reader); + if (isLoading) { + return lastValue; + } + + const entries = changesViewService.activeSessionChangesObs.read(reader); + if (entries.length === 0) { + return undefined; + } + + let additions = 0, deletions = 0; + for (const entry of entries) { + additions += entry.insertions; + deletions += entry.deletions; + } + + return { + additions, + deletions, + files: entries.length, + } satisfies ISessionChangesSummary; + }); + + this._summaryObs = derivedOpts<ISessionChangesSummary | undefined>({ + equalsFn: structuralEquals + }, reader => summaryRawObs.read(reader)); + } + + render(container: HTMLElement) { + const element = dom.$('div.changes-summary-widget'); + container.appendChild(element); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, element, { + prefix: '+', + direction: 'topToBottom', + cssClassName: 'changes-summary-lines-added', + count: derived(this, (reader) => { + return this._summaryObs.read(reader)?.additions; + }) + })); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, element, { + prefix: '-', + direction: 'bottomToTop', + cssClassName: 'changes-summary-lines-removed', + count: derived(this, (reader) => { + return this._summaryObs.read(reader)?.deletions; + }) + })); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 0c92558cc1fac5..9568dff2f012ad 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -5,7 +5,7 @@ import './media/changesView.css'; import * as dom from '../../../../base/browser/dom.js'; -import { ActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { Schemas } from '../../../../base/common/network.js'; import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; @@ -13,8 +13,8 @@ import { IObjectTreeElement, ITreeSorter } from '../../../../base/browser/ui/tre import { ActionRunner, IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; -import { Event } from '../../../../base/common/event.js'; -import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { autorun, derived, derivedObservableWithCache, IObservable, observableFromEvent, observableValue } from '../../../../base/common/observable.js'; import { CountBadge } from '../../../../base/browser/ui/countBadge/countBadge.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; @@ -22,6 +22,8 @@ import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { MenuWorkbenchButtonBar, WorkbenchButtonBar } from '../../../../platform/actions/browser/buttonbar.js'; import { getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { ActionWidgetDropdownActionViewItem } from '../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { MenuId, Action2, MenuItemAction, registerAction2, IMenuService } from '../../../../platform/actions/common/actions.js'; @@ -59,10 +61,12 @@ import { getChangesEditorLabels } from './changesEditorLabels.js'; import { ISessionChangesService } from './sessionChangesService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { CIStatusWidget } from './checksWidget.js'; +import { SessionFilesWidget } from './sessionFilesWidget.js'; +import { SessionFilesViewModel } from './sessionFilesViewModel.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISessionChangesetOperation, SessionChangesetOperationScope, SessionChangesetOperationStatus, SessionStatus } from '../../../services/sessions/common/session.js'; import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; -import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; +import { IView, LayoutPriority, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; import { Color } from '../../../../base/common/color.js'; import { PANEL_SECTION_BORDER } from '../../../../workbench/common/theme.js'; import { EditorResourceAccessor, SideBySideEditor } from '../../../../workbench/common/editor.js'; @@ -74,18 +78,29 @@ import { AGENT_HOST_SKILL_BUTTON_UPDATE_PR_ID, isAgentHostSkillButtonId } from ' import { ActiveSessionContextKeys, CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, IsolationMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; import { buildTreeChildren, ChangesTreeElement, ChangesTreeRenderer, IChangesFileItem, IChangesTreeRootInfo, isChangesFileItem, toIChangesFileItem } from './changesViewRenderer.js'; import { ResourceTree } from '../../../../base/common/resourceTree.js'; -import { structuralEquals } from '../../../../base/common/equals.js'; import { compareFileNames, comparePaths } from '../../../../base/common/comparers.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { ChangesSummaryWidget } from './changesSummaryWidget.js'; +import { Menus } from '../../../browser/menus.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; const $ = dom.$; // --- Constants const RUN_SESSION_CODE_REVIEW_ACTION_ID = 'sessions.codeReview.run'; +const VERSIONS_PICKER_ACTION_ID = 'chatEditing.versionsPicker'; +const DIFF_STATS_ACTION_ID = 'workbench.changesView.action.viewChanges'; +const EMPTY_FILE_CHANGES_MIN_HEIGHT = 140; + +/** Maximum number of file rows the tree pane's minimum size grows to accommodate. */ +const TREE_PANE_MIN_SIZE_MAX_ROWS = 13; + +/** Breathing room rendered beneath the last file row when the whole list fits. */ +const TREE_PANE_LIST_BOTTOM_PADDING = 12; // --- ButtonBar widget @@ -310,6 +325,11 @@ class ChangesWorkbenchButtonBarWidget extends Disposable { }); this._register(autorun(reader => { + const isLoading = changesViewService.activeSessionLoadingObs.read(reader); + if (isLoading) { + return; + } + const operationActionGroups = operationActionGroupsObs.read(reader); const menuActions = menuActionsObs.read(reader); @@ -342,6 +362,126 @@ class ChangesWorkbenchButtonBarWidget extends Disposable { } } +/** + * Renders the session changes action button-bar (e.g. "Create Pull Request") into + * a container, choosing the agent-host or git variant based on the active session. + * Used to host the actions in the single-pane Changes editor header. + */ +export class ChangesActionsBar extends Disposable { + constructor( + container: HTMLElement, + @IInstantiationService instantiationService: IInstantiationService, + @IChangesViewService changesViewService: IChangesViewService, + @ISessionsService sessionsService: ISessionsService, + @IContextKeyService contextKeyService: IContextKeyService, + ) { + super(); + + container.classList.add('changes-actions-bar'); + + const hasGitOperationInProgressGlobalObs = observableFromEvent(contextKeyService.onDidChangeContext, () => + contextKeyService.getContextKeyValue('sessions.hasGitOperationInProgress') === true); + const hasGitOperationInProgressObs = derived(reader => { + if (hasGitOperationInProgressGlobalObs.read(reader)) { + return true; + } + return changesViewService.activeSessionStateObs.read(reader)?.hasGitOperationInProgress === true; + }); + + const isAgentHostSessionObs = derived(reader => { + const activeSession = sessionsService.activeSession.read(reader); + return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; + }); + + this._register(autorun(reader => { + dom.clearNode(container); + + const widget = isAgentHostSessionObs.read(reader) + ? instantiationService.createInstance(ChangesWorkbenchButtonBarWidget, container) + : instantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, container, hasGitOperationInProgressObs); + reader.store.add(widget); + })); + + this._register(autorun(reader => { + const status = sessionsService.activeSession.read(reader)?.status.read(reader); + dom.setVisibility(status !== SessionStatus.Untitled, container); + })); + } +} + +// --- Editor header menus (single-pane): the Changes editor declares +// Menus.SessionsEditorHeaderPrimary (Branch Changes picker + diff stats) and +// Menus.SessionsEditorHeaderSecondary (the actions bar) and the editor group renders +// them. The custom action view items below are registered globally by menu id so +// the group's generic toolbars render them. + +const CHANGES_HEADER_ACTIONS_ID = 'workbench.changesView.headerActions'; + +/** Anchor action for the trailing header toolbar; rendered as the {@link ChangesActionsBar}. */ +class ChangesHeaderActionsAction extends Action2 { + constructor() { + super({ + id: CHANGES_HEADER_ACTIONS_ID, + title: localize2('changesView.headerActions', 'Changes Actions'), + f1: false, + menu: { id: Menus.SessionsEditorHeaderSecondary, group: 'navigation', order: 1 }, + }); + } + override async run(): Promise<void> { } +} +registerAction2(ChangesHeaderActionsAction); + +/** Renders the {@link ChangesActionsBar} widget as the trailing header action item. */ +class ChangesActionsBarActionViewItem extends BaseActionViewItem { + constructor( + action: IAction, + options: IActionViewItemOptions, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + this._register(this.instantiationService.createInstance(ChangesActionsBar, container)); + } +} + +/** Registers the Changes editor-header action view items keyed by the editor-header menu ids. */ +class ChangesEditorHeaderContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.changesEditorHeader'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + const onDidRegister = this._register(new Emitter<void>()); + + this._register(actionViewItemService.register(Menus.SessionsEditorHeaderPrimary, VERSIONS_PICKER_ACTION_ID, (action, _options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(ChangesPickerActionItem, action); + }, onDidRegister.event)); + + this._register(actionViewItemService.register(Menus.SessionsEditorHeaderPrimary, DIFF_STATS_ACTION_ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(SinglePaneChangesDiffStatsActionItem, action, options); + }, onDidRegister.event)); + + this._register(actionViewItemService.register(Menus.SessionsEditorHeaderSecondary, CHANGES_HEADER_ACTIONS_ID, (action, options, instantiationService) => { + return instantiationService.createInstance(ChangesActionsBarActionViewItem, action, options); + }, onDidRegister.event)); + + onDidRegister.fire(); + } +} +registerWorkbenchContribution2(ChangesEditorHeaderContribution.ID, ChangesEditorHeaderContribution, WorkbenchPhase.BlockRestore); + // --- View Pane export class ChangesViewPane extends ViewPane { @@ -358,8 +498,15 @@ export class ChangesViewPane extends ViewPane { private changesProgressBar!: ProgressBar; private tree: WorkbenchCompressibleObjectTree<ChangesTreeElement> | undefined; private ciStatusWidget: CIStatusWidget | undefined; + private sessionFilesWidget: SessionFilesWidget | undefined; private splitView: SplitView | undefined; private splitViewContainer: HTMLElement | undefined; + private readonly treePaneSizeChange = this._register(new Emitter<number | undefined>()); + + /** Computes the CI pane's default height (content, capped to a third of the split). */ + private computeCIPreferredHeight: (() => number) | undefined; + /** Once the user drags a sash we stop imposing the CI pane's default height. */ + private ciPaneUserResized = false; private readonly isMergeBaseBranchProtectedContextKey: IContextKey<boolean>; private readonly isolationModeContextKey: IContextKey<IsolationMode>; @@ -401,6 +548,7 @@ export class ChangesViewPane extends ViewPane { @ILogService private readonly logService: ILogService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ISessionChangesService private readonly sessionChangesService: ISessionChangesService, + @IWorkbenchLayoutService private readonly workbenchLayoutService: IWorkbenchLayoutService, ) { super({ ...options, titleMenuId: MenuId.ChatEditingSessionTitleToolbar }, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); @@ -490,32 +638,10 @@ export class ChangesViewPane extends ViewPane { updateHasFileIcons(); this._register(this.themeService.onDidFileIconThemeChange(updateHasFileIcons)); - // Files header - this.filesHeaderNode = dom.append(this.contentContainer, $('.changes-files-header')); - - // Changesets toolbar - const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar')); - this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { - menuOptions: { shouldForwardArgs: true }, - actionViewItemProvider: (action) => { - if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) { - return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action); - } - return undefined; - }, - })); - - // File header right-aligned toolbar - this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar')); - this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, { - menuOptions: { shouldForwardArgs: true }, - actionViewItemProvider: (action, options) => { - if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) { - return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, options); - } - return undefined; - }, - })); + // Files header (Branch Changes dropdown + diff stats). In the single-pane + // redesign these live in the custom Changes editor instead, so the panel + // omits its header; otherwise (original layout) the header is shown here. + this.createFilesHeader(this.contentContainer); // Changes card progress bar const progressContainer = dom.append(this.contentContainer, $('.changes-progress')); @@ -532,6 +658,9 @@ export class ChangesViewPane extends ViewPane { const welcomeMessage = dom.append(this.welcomeContainer, $('.changes-welcome-message')); welcomeMessage.textContent = localize('changesView.noChanges', "Changed files and other session artifacts will appear here."); + // Other Files widget - middle pane (files edited outside the workspace) + this.sessionFilesWidget = this._register(this.scopedInstantiationService.createInstance(SessionFilesWidget, this.splitViewContainer)); + // CI Status widget — bottom pane this.ciStatusWidget = this._register(this.scopedInstantiationService.createInstance(CIStatusWidget, this.splitViewContainer)); @@ -543,28 +672,64 @@ export class ChangesViewPane extends ViewPane { // Shared constants for pane sizing const ciMinHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.MIN_BODY_HEIGHT; - const treeMinHeight = 3 * ChangesTreeDelegate.ROW_HEIGHT; + const sessionFilesMinHeight = SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.MIN_BODY_HEIGHT; + const getSessionFilesContentHeight = () => Math.max(SessionFilesWidget.HEADER_HEIGHT, this.sessionFilesWidget?.desiredHeight ?? 0); + const getSessionFilesMinimumHeight = () => this.sessionFilesWidget?.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Math.min(sessionFilesMinHeight, getSessionFilesContentHeight()); + const getSessionFilesPreferredHeight = () => Math.max(getSessionFilesMinimumHeight(), SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT); + const getCIContentHeight = () => Math.max(CIStatusWidget.HEADER_HEIGHT, this.ciStatusWidget?.desiredHeight ?? 0); + const getCIMinimumHeight = () => this.ciStatusWidget?.collapsed ? CIStatusWidget.HEADER_HEIGHT : Math.min(ciMinHeight, getCIContentHeight()); + // Preferred default size for the CI pane: content height, capped to a third of the split. + const getCIPreferredHeight = () => { + const contentHeight = getCIContentHeight(); + if (this.ciStatusWidget?.collapsed) { + return CIStatusWidget.HEADER_HEIGHT; + } + const availableHeight = this.getSplitViewAvailableHeight(); + if (availableHeight > 0) { + return Math.max(getCIMinimumHeight(), Math.min(contentHeight, Math.round(availableHeight / 3))); + } + return contentHeight; + }; + this.computeCIPreferredHeight = getCIPreferredHeight; + const thisView = this; // Top pane: file tree const treePane: IView = { element: this.contentContainer, - minimumSize: treeMinHeight, - maximumSize: Number.POSITIVE_INFINITY, - onDidChange: Event.None, + get minimumSize() { return thisView.getTreePaneMinimumSize(); }, + get maximumSize() { return thisView.getTreePaneMaximumSize(); }, + onDidChange: this.treePaneSizeChange.event, layout: (height) => { this.contentContainer!.style.height = `${height}px`; this._layoutTreeInPane(height); }, }; + // Middle pane: other files + const sessionFilesElement = this.sessionFilesWidget.element; + const sessionFilesWidget = this.sessionFilesWidget; + const sessionFilesPane: IView = { + element: sessionFilesElement, + get minimumSize() { return getSessionFilesMinimumHeight(); }, + get maximumSize() { return sessionFilesWidget.collapsed ? SessionFilesWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, + priority: LayoutPriority.High, + onDidChange: Event.map(this.sessionFilesWidget.onDidChangeHeight, () => undefined), + layout: (height) => { + sessionFilesElement.style.height = `${height}px`; + const bodyHeight = Math.max(0, height - SessionFilesWidget.HEADER_HEIGHT); + sessionFilesWidget.layout(bodyHeight); + }, + }; + // Bottom pane: CI checks const ciElement = this.ciStatusWidget.element; const ciWidget = this.ciStatusWidget; const ciPane: IView = { element: ciElement, - get minimumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : ciMinHeight; }, - get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : Number.POSITIVE_INFINITY; }, - onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => undefined), + get minimumSize() { return getCIMinimumHeight(); }, + get maximumSize() { return ciWidget.collapsed ? CIStatusWidget.HEADER_HEIGHT : getCIContentHeight(); }, + priority: LayoutPriority.Low, + onDidChange: Event.map(this.ciStatusWidget.onDidChangeHeight, () => getCIContentHeight()), layout: (height) => { ciElement.style.height = `${height}px`; const bodyHeight = Math.max(0, height - CIStatusWidget.HEADER_HEIGHT); @@ -573,7 +738,8 @@ export class ChangesViewPane extends ViewPane { }; this.splitView.addView(treePane, Sizing.Distribute, 0, true); - this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 1, true); + this.splitView.addView(sessionFilesPane, SessionFilesWidget.HEADER_HEIGHT + SessionFilesWidget.PREFERRED_BODY_HEIGHT, 1, true); + this.splitView.addView(ciPane, CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT, 2, true); // Style the sash as a visible separator between sections const updateSplitViewStyles = () => { @@ -583,39 +749,19 @@ export class ChangesViewPane extends ViewPane { updateSplitViewStyles(); this._register(this.themeService.onDidColorThemeChange(updateSplitViewStyles)); - // Initially hide CI pane until checks arrive + // A manual sash drag hands layout control to the user: stop imposing the CI default size. + this._register(this.splitView.onDidSashChange(() => { this.ciPaneUserResized = true; })); + + // Initially hide the other files and CI panes until content arrives this.splitView.setViewVisible(1, false); + this.splitView.setViewVisible(2, false); - let savedCIPaneHeight = CIStatusWidget.HEADER_HEIGHT + CIStatusWidget.PREFERRED_BODY_HEIGHT; - this._register(this.ciStatusWidget.onDidToggleCollapsed(collapsed => { - if (!this.splitView || !this.ciStatusWidget) { - return; - } - if (collapsed) { - // Save current size before collapsing - const currentSize = this.splitView.getViewSize(1); - if (currentSize > CIStatusWidget.HEADER_HEIGHT) { - savedCIPaneHeight = currentSize; - } - this.splitView.resizeView(1, CIStatusWidget.HEADER_HEIGHT); - } else { - // Restore saved size on expand - this.splitView.resizeView(1, savedCIPaneHeight); - } - this.layoutSplitView(); - })); + // Other files pane (index 1) + this._wireSectionPane(this.sessionFilesWidget, 1, SessionFilesWidget.HEADER_HEIGHT, getSessionFilesPreferredHeight); + this._register(this.sessionFilesWidget.onDidChangeHeight(() => this.fireTreePaneSizeChange())); - this._register(this.ciStatusWidget.onDidChangeHeight(() => { - if (!this.splitView || !this.ciStatusWidget) { - return; - } - const visible = this.ciStatusWidget.visible; - const isCurrentlyVisible = this.splitView.isViewVisible(1); - if (visible !== isCurrentlyVisible) { - this.splitView.setViewVisible(1, visible); - } - this.layoutSplitView(); - })); + // CI checks pane (index 2) + this._wireSectionPane(this.ciStatusWidget, 2, CIStatusWidget.HEADER_HEIGHT, getCIPreferredHeight, () => { this.ciPaneUserResized = false; }); this._register(this.onDidChangeBodyVisibility(visible => { if (visible) { @@ -646,7 +792,7 @@ export class ChangesViewPane extends ViewPane { // Loading this.renderDisposables.add(autorun(reader => { - const isLoading = this.changesViewService.activeSessionIsLoadingObs.read(reader); + const isLoading = this.changesViewService.activeSessionChangesetLoadingObs.read(reader); if (isLoading) { this.changesProgressBar.infinite().show(200); } else { @@ -662,7 +808,7 @@ export class ChangesViewPane extends ViewPane { // Changes statistics const topLevelStats = derivedObservableWithCache<{ files: number; added: number; removed: number } | undefined>(this, (reader, lastValue) => { - const isLoading = this.changesViewService.activeSessionIsLoadingObs.read(reader); + const isLoading = this.changesViewService.activeSessionChangesetLoadingObs.read(reader); if (isLoading) { return lastValue; } @@ -684,21 +830,9 @@ export class ChangesViewPane extends ViewPane { // Bind context keys this._bindContextKeys(topLevelStats); - const isAgentHostSessionObs = derived(reader => { - const activeSession = this.sessionsService.activeSession.read(reader); - return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; - }); - - this.renderDisposables.add(autorun(reader => { - dom.clearNode(this.actionsContainer!); - - const isAgentHostSession = isAgentHostSessionObs.read(reader); - - const widget = isAgentHostSession - ? this.scopedInstantiationService.createInstance(ChangesWorkbenchButtonBarWidget, this.actionsContainer!) - : this.scopedInstantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, this.actionsContainer!, this.hasGitOperationInProgressObs); - reader.store.add(widget); - })); + // In the single-pane redesign the Create PR actions render in the Changes + // editor header instead of the detail panel. + this.createActionsButtonBar(); } const activeSessionStatusObs = derived(reader => { @@ -708,7 +842,7 @@ export class ChangesViewPane extends ViewPane { // Update visibility based on entries this.renderDisposables.add(autorun(reader => { - if (this.changesViewService.activeSessionIsLoadingObs.read(reader)) { + if (this.changesViewService.activeSessionLoadingObs.read(reader)) { return; } @@ -716,19 +850,17 @@ export class ChangesViewPane extends ViewPane { const activeSessionStatus = activeSessionStatusObs.read(reader); const isUntitled = activeSessionStatus === SessionStatus.Untitled; if (this.actionsContainer) { - dom.setVisibility(!isUntitled, this.actionsContainer); + dom.setVisibility(this.isActionsContainerVisible(isUntitled), this.actionsContainer); } - const hasGitRepository = this.changesViewService.activeSessionHasGitRepositoryObs.read(reader); - const stats = topLevelStats.read(reader); const hasEntries = stats !== undefined && stats.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(!isUntitled && (hasGitRepository || hasEntries), this.filesHeaderNode!); - + // Files header visibility (original layout only; absent in single-pane redesign). + if (this.filesHeaderNode) { + const hasGitRepository = this.changesViewService.activeSessionHasGitRepositoryObs.read(reader); + dom.setVisibility(!isUntitled && (hasGitRepository || hasEntries), this.filesHeaderNode); + } if (this.fileHeaderToolbarContainer) { dom.setVisibility(hasEntries, this.fileHeaderToolbarContainer); } @@ -736,6 +868,7 @@ export class ChangesViewPane extends ViewPane { dom.setVisibility(hasEntries, this.listContainer!); dom.setVisibility(!hasEntries, this.welcomeContainer!); + this.fireTreePaneSizeChange(); this.layoutSplitView(); })); @@ -748,8 +881,11 @@ export class ChangesViewPane extends ViewPane { if (this.tree) { const tree = this.tree; - // Re-layout when collapse state changes so the card height adjusts - this.renderDisposables.add(tree.onDidChangeContentHeight(() => this.layoutSplitView())); + // Re-layout when tree content changes so the card height adjusts + this.renderDisposables.add(tree.onDidChangeContentHeight(() => { + this.fireTreePaneSizeChange(); + this.layoutSplitView(); + })); this.renderDisposables.add(tree.onDidOpen((e) => { if (!e.element || !isChangesFileItem(e.element)) { @@ -758,13 +894,17 @@ export class ChangesViewPane extends ViewPane { logChangesViewFileSelect(this.telemetryService, e.element.changeType); - const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal'); - if (modalEditorMode === 'all') { + if (this.shouldOpenModalDiff()) { const items = changesObs.get(); this._openFileItem(e.element, items, e.sideBySide, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned, items.length > 1); return; } + if (this.shouldRevealFileInMultiDiffEditor()) { + void this._openMultiFileDiffEditor(e.element.uri); + return; + } + // Holding Alt inverts the configured single/multi file diff behavior. const altKey = !!(e.browserEvent as MouseEvent | KeyboardEvent | undefined)?.altKey; const openSingleFileDiff = this.configurationService.getValue<boolean>(SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING) !== altKey; @@ -788,16 +928,25 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(this.ciStatusWidget.setInput(checksViewModel)); } + // Other files (files edited outside the workspace during the session) + if (this.sessionFilesWidget) { + const sessionFilesViewModel = this.scopedInstantiationService.createInstance(SessionFilesViewModel); + this.renderDisposables.add(sessionFilesViewModel); + + this.renderDisposables.add(this.sessionFilesWidget.setInput(sessionFilesViewModel)); + } + // Update tree data with combined entries this.renderDisposables.add(autorun(reader => { const changes = changesObs.read(reader); const viewMode = this.changesViewService.viewModeObs.read(reader); - const isLoading = this.changesViewService.activeSessionIsLoadingObs.read(reader); - // Read session state so this autorun re-runs when git state (e.g. branch name) - // arrives asynchronously, since the tree root label depends on it. + const changesetLoading = this.changesViewService.activeSessionChangesetLoadingObs.read(reader); + + // Read session state so this autorun re-runs when git state (e.g. branch + // name) arrives asynchronously, since the tree root label depends on it. this.changesViewService.activeSessionStateObs.read(reader); - if (!this.tree || isLoading) { + if (!this.tree || changesetLoading) { return; } @@ -818,6 +967,7 @@ export class ChangesViewPane extends ViewPane { this.tree.setChildren(null, listChildren); } + this.fireTreePaneSizeChange(); this.layoutSplitView(); })); } @@ -867,28 +1017,137 @@ export class ChangesViewPane extends ViewPane { return; } - // Subtract the files header height within the content container + // Subtract the files header height (present in the original layout only). const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; const treeHeight = Math.max(0, paneHeight - filesHeaderHeight); this.tree.layout(treeHeight, this.currentBodyWidth); this.tree.getHTMLElement().style.height = `${treeHeight}px`; } - /** Layout the SplitView to fill available body space. */ - private layoutSplitView(): void { - if (!this.splitView || !this.splitViewContainer) { - return; + private getTreePaneMinimumSize(): number { + if (this.listContainer?.style.display === 'none') { + return EMPTY_FILE_CHANGES_MIN_HEIGHT; + } + + // Grow the minimum size to fit the file list (capped at TREE_PANE_MIN_SIZE_MAX_ROWS rows) plus header chrome. + const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; + const treeContentHeight = this.tree?.contentHeight ?? 0; + const maxRowsHeight = TREE_PANE_MIN_SIZE_MAX_ROWS * ChangesTreeDelegate.ROW_HEIGHT; + const cappedContentHeight = Math.min(treeContentHeight, maxRowsHeight); + const bottomPadding = treeContentHeight <= maxRowsHeight ? TREE_PANE_LIST_BOTTOM_PADDING : 0; + + return Math.max(EMPTY_FILE_CHANGES_MIN_HEIGHT, filesHeaderHeight + cappedContentHeight + bottomPadding); + } + + private getTreePaneMaximumSize(): number { + if (!this.sessionFilesWidget?.visible || this.sessionFilesWidget.collapsed) { + return Number.POSITIVE_INFINITY; } + + const filesHeaderHeight = this.filesHeaderNode?.offsetHeight ?? 0; + const treeContentHeight = this.listContainer?.style.display === 'none' ? 0 : this.tree?.contentHeight ?? 0; + const bottomPadding = treeContentHeight > 0 ? TREE_PANE_LIST_BOTTOM_PADDING : 0; + return Math.max(this.getTreePaneMinimumSize(), filesHeaderHeight + treeContentHeight + bottomPadding); + } + + private fireTreePaneSizeChange(): void { + this.treePaneSizeChange.fire(undefined); + } + + /** Compute the height available to the SplitView within the body. */ + private getSplitViewAvailableHeight(): number { const bodyHeight = this.currentBodyHeight; if (bodyHeight <= 0) { - return; + return 0; } const bodyPadding = 16; // 8px top + 8px bottom from .changes-view-body const actionsHeight = this.actionsContainer?.offsetHeight ?? 0; const actionsMargin = actionsHeight > 0 ? 8 : 0; - const availableHeight = Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin); + return Math.max(0, bodyHeight - bodyPadding - actionsHeight - actionsMargin); + } + + /** Layout the SplitView to fill available body space. */ + private layoutSplitView(): void { + if (!this.splitView || !this.splitViewContainer) { + return; + } + const availableHeight = this.getSplitViewAvailableHeight(); + if (availableHeight <= 0) { + return; + } this.splitViewContainer.style.height = `${availableHeight}px`; this.splitView.layout(availableHeight); + this.applyCIDefaultSize(); + } + + /** + * Re-assert the CI pane's default height (capped to a third of the split) after layout. + * This is where the split height is reliably known — the preferred height can otherwise be + * evaluated during wiring when the body height is still 0, yielding an uncapped fallback. + * Once the user drags a sash we back off and preserve their chosen size. + */ + private applyCIDefaultSize(): void { + if (!this.splitView || this.ciPaneUserResized || !this.computeCIPreferredHeight) { + return; + } + if (!this.ciStatusWidget?.visible || this.ciStatusWidget.collapsed) { + return; + } + const preferred = this.computeCIPreferredHeight(); + if (this.splitView.getViewSize(2) !== preferred) { + this.splitView.resizeView(2, preferred); + } + } + + /** + * Wires a collapsible section widget (CI checks / other files) to its + * SplitView pane: toggling its header collapses/restores the pane, and + * changes to its content show/hide the pane and re-layout. Both section + * widgets share the same structural contract so this logic is reused. + */ + private _wireSectionPane( + widget: { readonly collapsed: boolean; readonly visible: boolean; readonly onDidToggleCollapsed: Event<boolean>; readonly onDidChangeHeight: Event<void> }, + paneIndex: number, + headerHeight: number, + getPreferredHeight: () => number, + onDidBecomeVisible?: () => void, + ): void { + let savedPaneHeight = getPreferredHeight(); + + this._register(widget.onDidToggleCollapsed(collapsed => { + if (!this.splitView) { + return; + } + if (collapsed) { + // Save current size before collapsing + const currentSize = this.splitView.getViewSize(paneIndex); + if (currentSize > headerHeight) { + savedPaneHeight = currentSize; + } + this.splitView.resizeView(paneIndex, headerHeight); + } else { + // Restore saved size on expand + this.splitView.resizeView(paneIndex, savedPaneHeight); + } + this.layoutSplitView(); + })); + + this._register(widget.onDidChangeHeight(() => { + if (!this.splitView) { + return; + } + const visible = widget.visible; + const isCurrentlyVisible = this.splitView.isViewVisible(paneIndex); + if (visible !== isCurrentlyVisible) { + this.splitView.setViewVisible(paneIndex, visible); + if (visible && !widget.collapsed) { + onDidBecomeVisible?.(); + savedPaneHeight = getPreferredHeight(); + this.splitView.resizeView(paneIndex, savedPaneHeight); + } + } + this.layoutSplitView(); + })); } private getTreeSelection(): IChangesFileItem[] { @@ -1115,8 +1374,7 @@ export class ChangesViewPane extends ViewPane { return; } - const modalEditorMode = this.configurationService.getValue<string>('workbench.editor.useModal'); - if (modalEditorMode === 'all') { + if (this.shouldOpenModalDiff()) { const changes = toIChangesFileItem(items); const changeToOpen = resource ? changes.find(c => isEqual(c.uri, resource)) : undefined; await this._openFileItem(changeToOpen ?? changes[0], changes, false, false, false, changes.length > 1); @@ -1127,6 +1385,86 @@ export class ChangesViewPane extends ViewPane { await this._openMultiFileDiffEditor(resource); } + /** + * Renders the files header (Branch Changes dropdown + diff stats) into the panel. + * Standard layout only; {@link SinglePaneChangesViewPane} overrides this to a no-op + * because the header lives in the custom Changes editor instead. + */ + protected createFilesHeader(contentContainer: HTMLElement): void { + this.filesHeaderNode = dom.append(contentContainer, $('.changes-files-header')); + + const filesHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-toolbar')); + this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, filesHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action) => { + if (action.id === 'chatEditing.versionsPicker' && action instanceof MenuItemAction) { + return this.scopedInstantiationService.createInstance(ChangesPickerActionItem, action); + } + return undefined; + }, + })); + + this.fileHeaderToolbarContainer = dom.append(this.filesHeaderNode, $('.changes-files-header-right-toolbar')); + this._register(this.scopedInstantiationService.createInstance(MenuWorkbenchToolBar, this.fileHeaderToolbarContainer, MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, { + menuOptions: { shouldForwardArgs: true }, + actionViewItemProvider: (action, options) => { + if (action.id === ChangesDiffStatsAction.ID && action instanceof MenuItemAction) { + return this.scopedInstantiationService.createInstance(ChangesDiffStatsActionItem, action, options); + } + return undefined; + }, + })); + } + + /** + * Renders the Create-PR actions button bar into the actions container. Standard + * layout only; {@link SinglePaneChangesViewPane} overrides this to a no-op because + * the actions render in the Changes editor header instead. + */ + protected createActionsButtonBar(): void { + if (!this.actionsContainer) { + return; + } + + const isAgentHostSessionObs = derived(reader => { + const activeSession = this.sessionsService.activeSession.read(reader); + return activeSession ? isAgentHostProviderId(activeSession.providerId) : false; + }); + + this.renderDisposables.add(autorun(reader => { + dom.clearNode(this.actionsContainer!); + + const isAgentHostSession = isAgentHostSessionObs.read(reader); + + const widget = isAgentHostSession + ? this.scopedInstantiationService.createInstance(ChangesWorkbenchButtonBarWidget, this.actionsContainer!) + : this.scopedInstantiationService.createInstance(ChangesMenuWorkbenchButtonBarWidget, this.actionsContainer!, this.hasGitOperationInProgressObs); + reader.store.add(widget); + })); + } + + /** + * Whether the actions container should be shown for the given session state. + * Standard layout shows it for non-untitled sessions; {@link SinglePaneChangesViewPane} + * never shows it (the actions live in the Changes editor). + */ + protected isActionsContainerVisible(isUntitled: boolean): boolean { + return !isUntitled; + } + + /** + * Whether clicking a file opens the modal single-file diff (vs the multi-file + * diff editor). Standard layout honors the `workbench.editor.useModal` setting; + * {@link SinglePaneChangesViewPane} always opens the multi-file diff. + */ + protected shouldOpenModalDiff(): boolean { + return this.configurationService.getValue<string>('workbench.editor.useModal') === 'all'; + } + + protected shouldRevealFileInMultiDiffEditor(): boolean { + return false; + } + /** * Reveal the CI checks section: expand it if collapsed and move keyboard * focus into it. No-op when there are no checks to show. @@ -1214,6 +1552,12 @@ export class ChangesViewPane extends ViewPane { return; } + // Opening a file diff is a deliberate action, so reveal the (possibly hidden) + // editor area explicitly to show it. The Changes editor is otherwise excluded + // from auto reveal-on-open, and the explicit reveal is not undone by the + // automatic single-pane hide rules. + (this.workbenchLayoutService as IAgentWorkbenchLayoutService).revealEditorPartExplicitly(); + // Determine the reveal target (original/modified URI pair) from the // current change list, so the multi-diff editor can navigate to it. let options: IMultiDiffEditorOptions | undefined; @@ -1233,14 +1577,10 @@ export class ChangesViewPane extends ViewPane { } } - // Open the multi-diff editor using the sessions source URI. The resource - // list is resolved via `SessionsMultiDiffSourceResolver` and updates - // reactively as `activeSessionChangesObs` changes. - await this.editorService.openEditor({ - multiDiffSource: this.sessionChangesService.getChangesEditorResource(sessionResource), - label: localize('sessions.changes.title', 'Session Changes'), - options, - }); + // Open the session Changes editor using the sessions source URI. The + // resource list is resolved via `ChangesMultiDiffSourceResolver` and + // updates reactively as `activeSessionChangesObs` changes. + await this.sessionChangesService.openChangesEditor(sessionResource, options); } override dispose(): void { @@ -1249,6 +1589,35 @@ export class ChangesViewPane extends ViewPane { } } +/** + * Changes view for the single-pane layout: the files list lives in the docked + * detail panel while the Branch Changes header, Create-PR actions, and diffs are + * shown in the custom Changes editor. Overrides the standard hooks to omit the + * in-panel header/actions and always open the multi-file diff. + */ +export class SinglePaneChangesViewPane extends ChangesViewPane { + + protected override createFilesHeader(_contentContainer: HTMLElement): void { + // No in-panel header in single-pane; it lives in the Changes editor. + } + + protected override createActionsButtonBar(): void { + // No in-panel Create-PR actions in single-pane; they live in the Changes editor header. + } + + protected override isActionsContainerVisible(_isUntitled: boolean): boolean { + return false; + } + + protected override shouldOpenModalDiff(): boolean { + return false; + } + + protected override shouldRevealFileInMultiDiffEditor(): boolean { + return true; + } +} + export class ChangesViewPaneContainer extends ViewPaneContainer { constructor( @IWorkbenchLayoutService layoutService: IWorkbenchLayoutService, @@ -1360,7 +1729,7 @@ class SetChangesListViewModeAction extends ViewAction<ChangesViewPane> { title: localize('setListViewMode', "View as List"), viewId: CHANGES_VIEW_ID, f1: false, - icon: Codicon.listTree, + icon: Codicon.listFlat, toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.List), menu: { id: MenuId.ChatEditingSessionTitleToolbar, @@ -1383,7 +1752,7 @@ class SetChangesTreeViewModeAction extends ViewAction<ChangesViewPane> { title: localize('setTreeViewMode', "View as Tree"), viewId: CHANGES_VIEW_ID, f1: false, - icon: Codicon.listFlat, + icon: Codicon.listTree, toggled: ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.Tree), menu: { id: MenuId.ChatEditingSessionTitleToolbar, @@ -1419,6 +1788,11 @@ class VersionsPickerAction extends Action2 { group: 'navigation', order: 9, when: ActiveSessionContextKeys.HasGitRepository, + }, { + id: Menus.SessionsEditorHeaderPrimary, + group: 'navigation', + order: 1, + when: ActiveSessionContextKeys.HasGitRepository, }], }); } @@ -1427,7 +1801,7 @@ class VersionsPickerAction extends Action2 { } registerAction2(VersionsPickerAction); -class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { +export class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { constructor( action: MenuItemAction, @IActionWidgetService actionWidgetService: IActionWidgetService, @@ -1472,6 +1846,11 @@ class ChangesPickerActionItem extends ActionWidgetDropdownActionViewItem { })); } + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('changes-picker-action-rich'); + } + protected override renderLabel(element: HTMLElement): IDisposable | null { const changeset = this.changesViewService.activeSessionChangesetObs.get(); if (!changeset) { @@ -1494,12 +1873,17 @@ class ChangesDiffStatsAction extends Action2 { id: ChangesDiffStatsAction.ID, title: localize2('changesView.viewChanges', 'View All Changes'), f1: false, - menu: { + menu: [{ id: MenuId.ChatEditingSessionChangesFileHeaderRightToolbar, group: 'navigation', order: 1, when: ChatContextKeys.hasAgentSessionChanges - }, + }, { + id: Menus.SessionsEditorHeaderPrimary, + group: 'navigation', + order: 2, + when: ChatContextKeys.hasAgentSessionChanges + }], }); } @@ -1536,44 +1920,23 @@ class RevealCIChecksAction extends Action2 { registerAction2(RevealCIChecksAction); class ChangesDiffStatsActionItem extends ActionViewItem { - private readonly diffStatsObs: IObservable<{ files: number; insertions: number; deletions: number } | undefined>; + protected readonly _widget: ChangesSummaryWidget; constructor( action: MenuItemAction, options: IActionViewItemOptions, - @IChangesViewService changesViewService: IChangesViewService + @IInstantiationService instantiationService: IInstantiationService, ) { - super(null, action, { ...options, icon: false, label: true }); + super(null, action, { ...options, icon: false, label: false }); - const diffStatsRawObs = derivedObservableWithCache<{ files: number; insertions: number; deletions: number } | undefined>(this, - (reader, lastValue) => { - const entries = changesViewService.activeSessionChangesObs.read(reader); - const isLoading = changesViewService.activeSessionIsLoadingObs.read(reader); - - if (isLoading) { - return lastValue; - } - - let insertions = 0, deletions = 0; - for (const entry of entries) { - insertions += entry.insertions; - deletions += entry.deletions; - } - - return { files: entries.length, insertions, deletions }; - }); - - this.diffStatsObs = derivedOpts<{ files: number; insertions: number; deletions: number } | undefined>({ - equalsFn: structuralEquals - }, reader => diffStatsRawObs.read(reader)); + this._widget = this._register(instantiationService.createInstance(ChangesSummaryWidget)); this._register(autorun(reader => { - const diffStats = this.diffStatsObs.read(reader); - if (diffStats === undefined) { + const changesSummary = this._widget.summary.read(reader); + if (changesSummary === undefined) { return; } - this.updateLabel(); this.updateTooltip(); })); } @@ -1581,34 +1944,67 @@ class ChangesDiffStatsActionItem extends ActionViewItem { override render(container: HTMLElement): void { super.render(container); container.classList.add('changes-diff-stats-action'); - } - protected override updateLabel(): void { if (!this.label) { return; } - const diffStats = this.diffStatsObs.get(); - if (diffStats === undefined) { - return; - } - - const { insertions, deletions } = diffStats; + this.renderLabelContents(this.label); + } - dom.reset( - this.label, - dom.$('span.working-set-lines-added', undefined, `+${insertions}`), - dom.$('span.working-set-lines-removed', undefined, `-${deletions}`) - ); + /** + * Renders the diff-stats content into the action label. The base shows the + * animated +/- summary; {@link SinglePaneChangesDiffStatsActionItem} overrides + * this to a richer "N files +X -Y" label for the single-pane editor header. + */ + protected renderLabelContents(label: HTMLElement): void { + this._widget.render(label); } protected override getTooltip(): string | undefined { - const diffStats = this.diffStatsObs.get(); - if (diffStats === undefined) { + const changesSummary = this._widget.summary.get(); + if (changesSummary === undefined) { return undefined; } - const { files, insertions, deletions } = diffStats; - return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, insertions, deletions); + const { files, additions, deletions } = changesSummary; + return localize('changesView.diffStats.label', '{0} files, {1} additions, {2} deletions', files, additions, deletions); + } +} + +/** + * Diff-stats label for the single-pane Changes editor header: a richer + * "$(diff-multiple) N files +X -Y" rendering (the detail-panel header uses the + * compact animated base rendering). Adds the `changes-diff-stats-action-rich` + * marker class so its styling applies wherever it renders (the classic internal + * header or the single-pane editor-group header). + */ +export class SinglePaneChangesDiffStatsActionItem extends ChangesDiffStatsActionItem { + + override render(container: HTMLElement): void { + super.render(container); + container.classList.add('changes-diff-stats-action-rich'); + } + + protected override renderLabelContents(label: HTMLElement): void { + this._register(autorun(reader => { + const summary = this._widget.summary.read(reader); + if (summary === undefined) { + return; + } + + const { files, additions, deletions } = summary; + const filesLabel = files === 1 + ? localize('changesView.diffStats.file', "1 file") + : localize('changesView.diffStats.files', "{0} files", files); + + dom.reset( + label, + ...renderLabelWithIcons('$(diff-multiple)'), + dom.$('span.changes-diff-stats-files', undefined, filesLabel), + dom.$('span.working-set-lines-added', undefined, `+${additions}`), + dom.$('span.working-set-lines-removed', undefined, `-${deletions}`) + ); + })); } } diff --git a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts index e10d3c6accaf69..3c64dc7d7d92c2 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewActions.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewActions.ts @@ -13,13 +13,22 @@ import { IViewsService } from '../../../../workbench/services/views/common/views import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; -import { IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; +import { ActiveSessionContextKeys, CHANGES_VIEW_ID, ChangesContextKeys, ChangesViewMode, SESSIONS_CHANGES_OPEN_SINGLE_FILE_DIFF_SETTING } from '../common/changes.js'; +import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; import { isEqual } from '../../../../base/common/resources.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IListService } from '../../../../platform/list/browser/listService.js'; +import { resolveCommandsContext } from '../../../../workbench/browser/parts/editor/editorCommandsContext.js'; import { IChangesViewService } from '../common/changesViewService.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionChangesEditor } from './sessionChangesEditor.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { logChangesViewViewModeChange } from '../../../common/sessionsTelemetry.js'; const openChangesViewActionOptions: IAction2Options = { id: 'workbench.action.agentSessions.openChangesView', @@ -51,6 +60,7 @@ class ChangesViewActionsContribution extends Disposable implements IWorkbenchCon constructor( @IContextKeyService contextKeyService: IContextKeyService, @ISessionsService sessionsService: ISessionsService, + @IChangesViewService changesViewService: IChangesViewService, ) { super(); @@ -63,6 +73,10 @@ class ChangesViewActionsContribution extends Disposable implements IWorkbenchCon const changes = activeSession.changes.read(reader); return changes.length > 0; })); + + this._register(bindContextKey(ChangesContextKeys.ViewMode, contextKeyService, reader => { + return changesViewService.viewModeObs.read(reader); + })); } } @@ -107,6 +121,174 @@ class OpenPullRequestAction extends Action2 { registerAction2(OpenPullRequestAction); +const singlePaneChangesEditorActive = ContextKeyExpr.and( + IsSessionsWindowContext, + ActiveEditorContext.isEqualTo(SessionChangesEditor.ID), + ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true) +); + +const singlePaneChangesEditorTitleVisible = ContextKeyExpr.and( + singlePaneChangesEditorActive, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext +); + +class SetChangesListViewModeAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.setChangesListViewMode'; + + constructor() { + super({ + id: SetChangesListViewModeAction.ID, + title: localize2('agentSessions.setChangesListViewMode', "View as List"), + icon: Codicon.listFlat, + f1: false, + menu: { + id: Menus.SessionsEditorTitle, + group: '1_changesView', + order: 10, + when: ContextKeyExpr.and( + singlePaneChangesEditorTitleVisible, + AuxiliaryBarVisibleContext, + ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.Tree)) + } + }); + } + + run(accessor: ServicesAccessor): void { + logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.List); + accessor.get(IChangesViewService).setViewMode(ChangesViewMode.List); + } +} + +registerAction2(SetChangesListViewModeAction); + +class SetChangesTreeViewModeAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.setChangesTreeViewMode'; + + constructor() { + super({ + id: SetChangesTreeViewModeAction.ID, + title: localize2('agentSessions.setChangesTreeViewMode', "View as Tree"), + icon: Codicon.listTree, + f1: false, + menu: { + id: Menus.SessionsEditorTitle, + group: '1_changesView', + order: 10, + when: ContextKeyExpr.and( + singlePaneChangesEditorTitleVisible, + AuxiliaryBarVisibleContext, + ChangesContextKeys.ViewMode.isEqualTo(ChangesViewMode.List)) + } + }); + } + + run(accessor: ServicesAccessor): void { + logChangesViewViewModeChange(accessor.get(ITelemetryService), ChangesViewMode.Tree); + accessor.get(IChangesViewService).setViewMode(ChangesViewMode.Tree); + } +} + +registerAction2(SetChangesTreeViewModeAction); + +class CollapseAllSessionChangesDiffsAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.collapseAllDiffs'; + + constructor() { + super({ + id: CollapseAllSessionChangesDiffsAction.ID, + title: localize2('agentSessions.collapseAllDiffs', "Collapse All Diffs"), + icon: Codicon.collapseAll, + f1: false, + menu: { + id: Menus.SessionsEditorTitle, + group: 'navigation', + order: 100, + when: ContextKeyExpr.and( + singlePaneChangesEditorTitleVisible, + ContextKeyExpr.not('multiDiffEditorAllCollapsed')) + } + }); + } + + run(accessor: ServicesAccessor): void { + const activeEditorPane = accessor.get(IEditorService).activeEditorPane; + if (activeEditorPane instanceof SessionChangesEditor) { + activeEditorPane.collapseAllDiffs(); + } + } +} + +registerAction2(CollapseAllSessionChangesDiffsAction); + +class ExpandAllSessionChangesDiffsAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.expandAllDiffs'; + + constructor() { + super({ + id: ExpandAllSessionChangesDiffsAction.ID, + title: localize2('agentSessions.expandAllDiffs', "Expand All Diffs"), + icon: Codicon.expandAll, + f1: false, + menu: { + id: Menus.SessionsEditorTitle, + group: 'navigation', + order: 100, + when: ContextKeyExpr.and( + singlePaneChangesEditorActive, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext, + ContextKeyExpr.has('multiDiffEditorAllCollapsed')) + } + }); + } + + run(accessor: ServicesAccessor): void { + const activeEditorPane = accessor.get(IEditorService).activeEditorPane; + if (activeEditorPane instanceof SessionChangesEditor) { + activeEditorPane.expandAllDiffs(); + } + } +} + +registerAction2(ExpandAllSessionChangesDiffsAction); + +class ToggleSessionChangesInlineViewAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.toggleInlineView'; + + constructor() { + super({ + id: ToggleSessionChangesInlineViewAction.ID, + title: localize2('agentSessions.toggleInlineView', "Toggle Inline View"), + icon: Codicon.diffSidebyside, + f1: false, + toggled: EditorContextKeys.multiDiffEditorRenderSideBySide.negate(), + menu: { + id: Menus.SessionsEditorTitle, + group: 'navigation', + order: 99, + when: ContextKeyExpr.and( + singlePaneChangesEditorActive, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext) + } + }); + } + + run(accessor: ServicesAccessor, ...args: unknown[]): void { + const resolvedContext = resolveCommandsContext(args, accessor.get(IEditorService), accessor.get(IEditorGroupsService), accessor.get(IListService)); + const pane = resolvedContext.groupedEditors[0]?.group.activeEditorPane ?? accessor.get(IEditorService).activeEditorPane; + if (pane instanceof SessionChangesEditor) { + pane.toggleInlineView(); + } + } +} + +registerAction2(ToggleSessionChangesInlineViewAction); + class OpenChangesAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.openChanges'; @@ -188,5 +370,3 @@ class OpenFileAction extends Action2 { } registerAction2(OpenFileAction); - - diff --git a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts index 46919b5b77d4d7..33122cfd42dbbb 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewRenderer.ts @@ -7,16 +7,14 @@ import * as dom from '../../../../base/browser/dom.js'; import { ICompressedTreeElement, ICompressedTreeNode } from '../../../../base/browser/ui/tree/compressedObjectTreeModel.js'; import { ICompressibleTreeRenderer } from '../../../../base/browser/ui/tree/objectTree.js'; import { ITreeNode } from '../../../../base/browser/ui/tree/tree.js'; -import { ActionRunner, toAction } from '../../../../base/common/actions.js'; +import { ActionRunner } from '../../../../base/common/actions.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { autorun } from '../../../../base/common/observable.js'; import { basename, dirname, extUriBiasedIgnorePathCase, relativePath } from '../../../../base/common/resources.js'; import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; -import { getActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { IMenu, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -28,7 +26,7 @@ import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actio import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ModifiedFileEntryState } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { GITHUB_REMOTE_FILE_SCHEME, ISessionChangeset, ISessionChangesetOperation, ISessionFileChange, SessionChangesetOperationScope } from '../../../services/sessions/common/session.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionFileChange } from '../../../services/sessions/common/session.js'; import { ActiveSessionContextKeys, ChangesContextKeys, ChangesViewMode } from '../common/changes.js'; import { IChangesViewService } from '../common/changesViewService.js'; @@ -157,8 +155,7 @@ export function buildTreeChildren(items: IChangesFileItem[], treeRootInfo?: ICha interface IChangesTreeTemplate { readonly label: IResourceLabel; - readonly toolbar: WorkbenchToolBar; - readonly toolbarMenu: IMenu; + readonly toolbar: MenuWorkbenchToolBar; readonly changeKindContextKey: IContextKey<'root' | 'folder' | 'file'>; readonly reviewCommentsBadge: HTMLElement; readonly agentFeedbackBadge: HTMLElement; @@ -174,9 +171,6 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre static TEMPLATE_ID = 'changesTreeRenderer'; readonly templateId: string = ChangesTreeRenderer.TEMPLATE_ID; - private readonly _changeset: IObservable<ISessionChangeset | undefined>; - private readonly _operations: IObservable<readonly ISessionChangesetOperation[]>; - constructor( private labels: ResourceLabels, private actionRunner: ActionRunner | undefined, @@ -185,22 +179,8 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre @IChangesViewService private readonly changesViewService: IChangesViewService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @ILabelService private readonly labelService: ILabelService, - @IMenuService private readonly menuService: IMenuService, @ISessionsService private readonly sessionsService: ISessionsService, - ) { - this._changeset = derived(reader => { - return changesViewService.activeSessionChangesetObs.read(reader); - }); - - this._operations = derived(reader => { - const activeSessionChangeset = changesViewService.activeSessionChangesetObs.read(reader); - const activeSessionChangesetOperations = activeSessionChangeset?.operations.read(reader); - - return activeSessionChangesetOperations - ? activeSessionChangesetOperations.filter(o => o.scopes.includes(SessionChangesetOperationScope.Resource)) - : []; - }); - } + ) { } renderTemplate(container: HTMLElement): IChangesTreeTemplate { const templateDisposables = new DisposableStore(); @@ -222,11 +202,11 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre const actionBarContainer = $('.chat-collapsible-list-action-bar'); const contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); - const toolbar = templateDisposables.add(scopedInstantiationService.createInstance(WorkbenchToolBar, actionBarContainer, { actionRunner: this.actionRunner, menuOptions: { shouldForwardArgs: true, arg: undefined } })); + const toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.AgentsChangeInlineToolbar, { + menuOptions: { shouldForwardArgs: true, arg: undefined }, actionRunner: this.actionRunner + })); label.element.appendChild(actionBarContainer); - const toolbarMenu = templateDisposables.add(this.menuService.createMenu(MenuId.AgentsChangeInlineToolbar, contextKeyService)); - templateDisposables.add(bindContextKey(ChatContextKeys.agentSessionType, contextKeyService, reader => { const activeSession = this.sessionsService.activeSession.read(reader); return activeSession?.sessionType ?? ''; @@ -245,7 +225,7 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre const decorationBadge = dom.$('.changes-decoration-badge'); label.element.appendChild(decorationBadge); - return { label, toolbar, toolbarMenu, changeKindContextKey, reviewCommentsBadge, agentFeedbackBadge, decorationBadge, addedSpan, removedSpan, lineCountsContainer, elementDisposables: new DisposableStore(), templateDisposables }; + return { label, toolbar, changeKindContextKey, reviewCommentsBadge, agentFeedbackBadge, decorationBadge, addedSpan, removedSpan, lineCountsContainer, elementDisposables: new DisposableStore(), templateDisposables }; } renderElement(node: ITreeNode<ChangesTreeElement, void>, _index: number, templateData: IChangesTreeTemplate): void { @@ -380,44 +360,7 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.remove('modified'); } - // Menu actions - const menuActionsObs = observableFromEvent(templateData.toolbarMenu.onDidChange, () => { - return getActionBarActions(templateData.toolbarMenu.getActions({ shouldForwardArgs: true })); - }); - - // Operation actions - const operationActionsObs = derived(reader => { - const changeset = this._changeset.read(reader); - if (!changeset) { - return []; - } - - const operations = this._operations.read(reader); - const actions = operations.map(operation => toAction({ - id: operation.id, - label: operation.label, - class: operation.icon - ? ThemeIcon.asClassName(operation.icon) - : undefined, - enabled: true, - run: () => changeset.invokeOperation(operation.id, { - kind: 'resource', - resource: data.uri, - }) - })); - - return actions; - }); - - templateData.elementDisposables.add(autorun(reader => { - const operationActions = operationActionsObs.read(reader); - const menuActions = menuActionsObs.read(reader); - - templateData.toolbar.setActions([...menuActions.primary, ...operationActions], menuActions.secondary); - })); - templateData.toolbar.context = data; - templateData.changeKindContextKey.set('file'); } @@ -435,9 +378,7 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre templateData.decorationBadge.style.display = 'none'; templateData.lineCountsContainer.style.display = 'none'; - templateData.toolbar.setActions([], []); templateData.toolbar.context = data.uri; - templateData.changeKindContextKey.set('root'); } @@ -453,9 +394,7 @@ export class ChangesTreeRenderer implements ICompressibleTreeRenderer<ChangesTre templateData.decorationBadge.style.display = 'none'; templateData.lineCountsContainer.style.display = 'none'; - templateData.toolbar.setActions([], []); templateData.toolbar.context = node; - templateData.changeKindContextKey.set('folder'); } diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 63c1aa91656fbc..224aded92600a0 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -26,13 +26,15 @@ export class ChangesViewService extends Disposable implements IChangesViewServic readonly activeSessionIsVirtualWorkspaceObs: IObservable<boolean>; readonly activeSessionChangesObs: IObservable<readonly ISessionFileChange[]>; readonly activeSessionChangesetsObs: IObservable<readonly ISessionChangeset[] | undefined>; + readonly activeSessionChangesetsLoadingObs: IObservable<boolean>; readonly activeSessionChangesetObs: IObservable<ISessionChangeset | undefined>; + readonly activeSessionChangesetLoadingObs: IObservable<boolean>; readonly activeSessionChangesetOperationsObs: IObservable<readonly ISessionChangesetOperation[]>; readonly activeSessionHasGitRepositoryObs: IObservable<boolean>; readonly activeSessionReviewCommentCountByFileObs: IObservable<Map<string, number>>; readonly activeSessionAgentFeedbackCountByFileObs: IObservable<Map<string, number>>; readonly activeSessionStateObs: IObservable<ActiveSessionState | undefined>; - readonly activeSessionIsLoadingObs: IObservable<boolean>; + readonly activeSessionLoadingObs: IObservable<boolean>; private readonly _selectedChangesetId = observableValue<string | undefined>(this, undefined); setChangesetId(changesetId: string | undefined): void { @@ -86,35 +88,57 @@ export class ChangesViewService extends Disposable implements IChangesViewServic return workspace?.folders[0].gitRepository !== undefined; }); - // Active session state - const { isLoading, state } = this._getActiveSessionState(); - this.activeSessionIsLoadingObs = isLoading; - this.activeSessionStateObs = state; - // Active session review comment count by file this.activeSessionReviewCommentCountByFileObs = this._getActiveSessionReviewComments(); // Active session agent feedback count by file this.activeSessionAgentFeedbackCountByFileObs = this._getActiveSessionAgentFeedback(); - // Changeset + // Changesets this.activeSessionChangesetsObs = derived(reader => { const activeSession = this.sessionsService.activeSession.read(reader); return activeSession?.changesets.read(reader); }); + this.activeSessionChangesetsLoadingObs = derived(reader => { + return this.activeSessionChangesetsObs.read(reader) === undefined; + }); + + // Changeset this.activeSessionChangesetObs = derived<ISessionChangeset | undefined>(reader => { const selectedChangesetId = this._selectedChangesetId.read(reader); - const activeSessionChangesets = this.activeSessionChangesetsObs.read(reader) ?? []; + const activeSessionChangesets = this.activeSessionChangesetsObs.read(reader); + if (!activeSessionChangesets) { + return undefined; + } // Honor an explicit selection only while it is still enabled; otherwise fall - // back to the default changeset so the picker never shows a disabled selection. + // back to the default, first enabled changeset so the picker never shows a + // disabled selection. const selectedChangeset = selectedChangesetId ? activeSessionChangesets .find(c => c.id === selectedChangesetId && c.isEnabled.read(reader)) : undefined; - return selectedChangeset ?? activeSessionChangesets.find(c => c.isDefault.read(reader)); + if (selectedChangeset) { + return selectedChangeset; + } + + const defaultChangeset = activeSessionChangesets + .find(c => c.isDefault.read(reader)); + + const firstEnabledChangeset = activeSessionChangesets + .find(c => c.isEnabled.read(reader)); + + return defaultChangeset ?? firstEnabledChangeset; + }); + + this.activeSessionChangesetLoadingObs = derived(reader => { + const changeset = this.activeSessionChangesetObs.read(reader); + // Not having an active changeset indicates that we have switched + // between sessions and the changesets are still being loaded. When + // switching between sessions, we need to clear the changes list. + return changeset?.isLoadingChanges.read(reader) ?? false; }); this.activeSessionChangesetOperationsObs = derived(reader => { @@ -128,6 +152,18 @@ export class ChangesViewService extends Disposable implements IChangesViewServic return changeset?.changes.read(reader) ?? []; }); + this.activeSessionLoadingObs = derived(reader => { + const activeSession = this.sessionsService.activeSession.read(reader); + const activeSessionLoading = activeSession?.loading.read(reader) ?? true; + const activeSessionChangesetsLoading = this.activeSessionChangesetsLoadingObs.read(reader); + const activeSessionChangesetLoading = this.activeSessionChangesetLoadingObs.read(reader); + + return activeSessionLoading || activeSessionChangesetsLoading || activeSessionChangesetLoading; + }); + + // Active session state + this.activeSessionStateObs = this._getActiveSessionState(); + // View mode const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE); const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List; @@ -140,15 +176,10 @@ export class ChangesViewService extends Disposable implements IChangesViewServic })); } - private _getActiveSessionState(): { isLoading: IObservable<boolean>; state: IObservable<ActiveSessionState | undefined> } { - const isLoadingObs = derived(reader => { - const changeset = this.activeSessionChangesetObs.read(reader); - return changeset?.isLoadingChanges.read(reader) ?? false; - }); - + private _getActiveSessionState(): IObservable<ActiveSessionState | undefined> { const activeSessionStateObs = derivedObservableWithCache<ActiveSessionState | undefined>(this, (reader, lastValue) => { - const isLoading = isLoadingObs.read(reader); - if (isLoading) { + const loading = this.activeSessionLoadingObs.read(reader); + if (loading) { return lastValue; } @@ -205,11 +236,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic } satisfies ActiveSessionState; }); - return { - isLoading: isLoadingObs, - state: derivedOpts({ equalsFn: structuralEquals }, - reader => activeSessionStateObs.read(reader)) - }; + return derivedOpts({ equalsFn: structuralEquals }, + reader => activeSessionStateObs.read(reader)); } private _getActiveSessionReviewComments(): IObservable<Map<string, number>> { diff --git a/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css new file mode 100644 index 00000000000000..34b544301b40b9 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/changesSummaryWidget.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.changes-summary-widget { + display: inline-flex; + gap: 4px; + + .changes-summary-lines-added { + color: var(--vscode-chat-linesAddedForeground); + } + + .changes-summary-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css new file mode 100644 index 00000000000000..9e6f3cff2b390d --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionChangesEditor.css @@ -0,0 +1,301 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.session-changes-editor { + display: flex; + flex-direction: column; + height: 100%; + background: var(--vscode-editor-background); + color: var(--vscode-agentsPanel-foreground); +} + +.session-changes-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 35px; + min-height: 35px; + min-width: 0; + padding: 0 var(--vscode-spacing-size80); + gap: var(--vscode-spacing-size80); +} + +.session-changes-editor-header-left, +.session-changes-editor-header-right { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; +} + +.session-changes-editor-header-left { + flex: 1 1 auto; + gap: var(--vscode-spacing-size40); +} + +.session-changes-editor-header-left > .monaco-toolbar, +.session-changes-editor-header-left .monaco-action-bar, +.session-changes-editor-header-left .actions-container, +.session-changes-editor-header-left .action-item { + min-width: 0; + max-width: 100%; +} + +.session-changes-editor-header-left > .monaco-toolbar:first-child { + flex: 0 1 auto; +} + +.session-changes-editor-header-left > .monaco-toolbar:last-child { + flex: 1 1 auto; +} + +.session-changes-editor-header-left .action-label > span:not(.codicon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-changes-editor-header-left .action-label { + font-size: var(--vscode-agents-fontSize-label1); + align-items: center; + min-width: 0; + max-width: 100%; + + > span { + margin-left: 2px; + } + + > .codicon { + font-size: var(--vscode-codiconFontSize-compact) !important; + padding-left: var(--vscode-spacing-size40); + width: 12px; + height: 12px; + flex-shrink: 0; + } +} + +/* Diff-stats item (rich "N files +X -Y" rendering) styling, keyed off the item's + * own marker class so it applies in both the classic internal changes-editor + * header and the single-pane editor-group header (where it renders inside + * `.editor-group-header-primary`). */ +.changes-diff-stats-action-rich { + flex: 1 1 auto; + min-width: 0; + cursor: pointer; +} + +.changes-diff-stats-action-rich .action-label { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + padding: 0 var(--vscode-spacing-size40); + border-radius: var(--vscode-cornerRadius-small); + cursor: pointer; +} + +/* The pill is a clickable action (opens the multi-file diff), so it shows the + * standard toolbar hover/active background. */ +.monaco-workbench .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action-rich .action-label:hover:not(.disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-workbench .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action-rich .action-label:active:not(.disabled), +.monaco-workbench .monaco-action-bar:not(.vertical) .action-item.changes-diff-stats-action-rich.active .action-label { + background-color: var(--vscode-toolbar-activeBackground, var(--vscode-toolbar-hoverBackground)); +} + +/* Reset the picker-chevron overrides so the diff-stats icon/labels lay out via `gap`. */ +.changes-diff-stats-action-rich .action-label > span { + margin-left: 0; + min-width: 0; +} + +.changes-diff-stats-action-rich .action-label > .codicon { + font-size: var(--vscode-codiconFontSize-compact) !important; + padding-left: 0; + width: auto; + height: auto; + flex-shrink: 0; +} + +.changes-diff-stats-action-rich .changes-diff-stats-files { + color: var(--vscode-foreground); + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.changes-diff-stats-action-rich .working-set-lines-added { + color: var(--vscode-chat-linesAddedForeground); + flex-shrink: 0; +} + +.changes-diff-stats-action-rich .working-set-lines-removed { + color: var(--vscode-chat-linesRemovedForeground); + flex-shrink: 0; +} + +/* Branch Changes picker (label + chevron) styling, keyed off the item's own + * marker class so its chevron stays compact and vertically centered in both the + * classic internal changes-editor header and the single-pane editor-group header. */ +.changes-picker-action-rich .action-label { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + min-width: 0; + max-width: 100%; + font-size: var(--vscode-agents-fontSize-label1); +} + +.changes-picker-action-rich .action-label > span:not(.codicon) { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.changes-picker-action-rich .action-label > .codicon { + font-size: var(--vscode-codiconFontSize-compact) !important; + width: auto; + height: auto; + padding-left: 0; + flex-shrink: 0; +} + +.session-changes-editor-header-right { + flex: 0 2 auto; + min-width: 0; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: var(--vscode-spacing-size40); +} + +.session-changes-editor-header-right .monaco-button, +.session-changes-editor-header-right .monaco-button-dropdown, +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button { + width: auto; + min-width: 0; +} + +.session-changes-editor-header-right .monaco-button { + height: 26px; + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size80); + box-sizing: border-box; + font-size: var(--vscode-agents-fontSize-label1); + line-height: 18px; + white-space: nowrap; +} + +.session-changes-editor-header-right > .monaco-button, +.session-changes-editor-header-right > .monaco-button-dropdown { + flex: 0 1 auto; +} + +.session-changes-editor-header-right > .monaco-button.secondary, +.session-changes-editor-header-right > .monaco-button-dropdown > .monaco-button.secondary.monaco-text-button { + flex: 0 0 auto; +} + +.session-changes-editor-header-right .monaco-button-dropdown { + display: flex; + align-items: center; + height: 26px; + overflow: hidden; +} + +.session-changes-editor-header-right .monaco-button > span:not(.codicon) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button-dropdown-separator { + flex: 0 0 auto; + margin: 0; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button:not(.monaco-dropdown-button) { + flex: 1 1 auto; + overflow: hidden; +} + +.session-changes-editor-header-right .monaco-button-dropdown > .monaco-button.monaco-dropdown-button { + flex: 0 0 auto; + padding: var(--vscode-spacing-size40); + min-width: 0; + border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0; +} + +.session-changes-editor-header-right .monaco-button.secondary.monaco-text-button.codicon { + flex: 0 0 auto; + padding: var(--vscode-spacing-size20) var(--vscode-spacing-size60); + font-size: var(--vscode-codiconFontSize) !important; +} + +/* Compact the ChangesActionsBar buttons to 24px (2px shorter than the classic + * 26px header buttons) so the changes-editor header stays compact without extra + * header padding. Keyed off the bar's marker class so it applies in both the + * classic internal header and the single-pane editor-group header (where the + * classic `.session-changes-editor-header-right` normalization does not reach, + * leaving default 28px dropdown / 24px text buttons). */ +.changes-actions-bar .monaco-button, +.changes-actions-bar .monaco-button-dropdown { + height: 24px; + box-sizing: border-box; +} + +/* Single-pane editor-group header: this bar is rendered as a toolbar action item + * (`.action-item.changes-actions-bar` inside a `.monaco-action-bar`), where the + * classic `.session-changes-editor-header-right` normalization does not reach. + * Scope these to the action-item context so the classic internal changes-editor + * header and the aux-bar Changes view are left untouched. */ +.monaco-action-bar .action-item.changes-actions-bar { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: var(--vscode-spacing-size40); +} + +/* Primary split-button grows to fill; the trailing secondary icons stay natural size. */ +.monaco-action-bar .action-item.changes-actions-bar > .monaco-button-dropdown { + flex: 1 1 auto; + min-width: 0; +} + +.monaco-action-bar .action-item.changes-actions-bar > .monaco-button-dropdown > .monaco-button:not(.monaco-dropdown-button) { + flex: 1 1 auto; + min-width: 0; +} + +/* The generic actionbar rule `.monaco-action-bar .action-item .codicon { width: + * 16px; height: 16px }` clamps every codicon — including the button elements + * themselves (which carry the `codicon` class), squashing the secondary icon + * buttons and detaching the split-button chevron. Un-clamp them (higher + * specificity than the base rule) so the buttons size to their own content. */ +.monaco-action-bar .action-item.changes-actions-bar .codicon { + width: auto; + height: auto; +} + +.session-changes-editor-body { + position: relative; + flex: 1; + min-height: 0; +} diff --git a/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css new file mode 100644 index 00000000000000..b112f66b5210b8 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Other Files widget - between the files list and the CI checks widget */ +.session-files-widget { + display: flex; + flex-direction: column; + flex-shrink: 0; + box-sizing: border-box; + font-size: var(--vscode-agents-fontSize-label1, 12px); +} + +/* Header */ +.session-files-widget-header { + position: relative; + display: flex; + align-items: center; + padding: 4px; + margin-top: 6px; + border-radius: var(--vscode-cornerRadius-medium); + min-height: 20px; + font-weight: var(--vscode-agents-fontWeight-semiBold, 600); + cursor: pointer; + user-select: none; +} + +.session-files-widget-header:hover { + padding-right: 32px; +} + +.session-files-widget-header:focus-visible { + padding-right: 32px; +} + +.session-files-widget-header.collapsed { + padding-right: 32px; +} + +/* Hover/keyboard focus promote the title to the strong foreground instead of + painting a row background — matches the sessions list section labels. */ +.session-files-widget-header:hover .session-files-widget-title, +.session-files-widget-header:focus-visible .session-files-widget-title { + color: var(--vscode-strongForeground); +} + +.session-files-widget-header:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Suppress the outline for mouse-driven focus — keyboard navigation only */ +.session-files-widget-header:focus:not(:focus-visible) { + outline: none !important; +} + +/* Chevron — right-aligned, visible on hover, keyboard focus, or when collapsed */ +.session-files-widget-header .group-chevron { + position: absolute; + right: 4px; + top: 50%; + transform: translateY(-50%); + width: 22px; + height: 22px; + border-radius: var(--vscode-cornerRadius-small); + display: flex; + align-items: center; + justify-content: center; + font-size: var(--vscode-codiconFontSize-compact, 12px); + visibility: hidden; + opacity: 0; +} + +.session-files-widget-header:hover .group-chevron, +.session-files-widget-header:focus-visible .group-chevron, +.session-files-widget-header.collapsed .group-chevron { + visibility: visible; + opacity: 0.7; +} + +.session-files-widget-header .group-chevron:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Title - single line, overflow ellipsis */ +.session-files-widget-title { + flex: 1; + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; + color: var(--vscode-foreground); +} + +.session-files-widget-title-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* File count — shown in the header only while collapsed */ +.session-files-widget-count { + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-agents-fontSize-label2, 11px); + font-weight: var(--vscode-agents-fontWeight-regular); + line-height: 1; +} + +.session-files-widget-count.hidden { + display: none; +} + +/* Body - file list */ +.session-files-widget-body { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.session-files-widget-list { + height: 100%; + background-color: transparent; +} + +.session-files-widget-list > .monaco-list, +.session-files-widget-list > .monaco-list > .monaco-scrollable-element { + background-color: transparent; +} + +/* Individual file row */ +.session-files-widget .session-files-widget-list .monaco-list-row { + border-radius: var(--vscode-cornerRadius-small); +} + +.session-files-widget-file { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + height: 100%; + width: 100%; + box-sizing: border-box; + min-width: 0; +} + +.session-files-widget-file .monaco-icon-label { + display: flex; + flex: 1; + min-width: 0; + width: 100%; +} + +.session-files-widget-file .monaco-icon-label .label-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Inline action bar (Open File), shown on hover/focus/select — mirrors the changes view */ +.session-files-widget .monaco-list-row .chat-collapsible-list-action-bar { + padding-left: 6px; + display: none; +} + +.session-files-widget .monaco-list-row:hover .chat-collapsible-list-action-bar:not(.has-no-actions), +.session-files-widget .monaco-list-row.focused .chat-collapsible-list-action-bar:not(.has-no-actions), +.session-files-widget .monaco-list-row.selected .chat-collapsible-list-action-bar:not(.has-no-actions) { + display: inherit; +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts new file mode 100644 index 00000000000000..605d607516ef51 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditor.ts @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionChangesEditor.css'; +import { $, append, Dimension } from '../../../../base/browser/dom.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { Range } from '../../../../editor/common/core/range.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { bindContextKey } from '../../../../platform/observable/common/platformObservableUtils.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { ResourceLabel } from '../../../../workbench/browser/labels.js'; +import { IEditorHeaderActions, IEditorOpenContext } from '../../../../workbench/common/editor.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { MultiDiffEditorWidget } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IResourceLabel, IWorkbenchUIElementFactory } from '../../../../editor/browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; +import { Menus } from '../../../browser/menus.js'; +import { shouldUseSinglePaneLayout } from '../../../browser/parts/singlePaneEditorPart.js'; +import { ActiveSessionContextKeys } from '../common/changes.js'; +import { IChangesViewService } from '../common/changesViewService.js'; +import { ChangesActionsBar } from './changesView.js'; +import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; + +const HEADER_HEIGHT = 35; + +class SessionChangesUIElementFactory implements IWorkbenchUIElementFactory { + + readonly headerClickToCollapse = true; + + constructor( + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + createResourceLabel(element: HTMLElement): IResourceLabel { + const label = this.instantiationService.createInstance(ResourceLabel, element, {}); + return { + setUri(uri, options = {}) { + if (!uri) { + label.element.clear(); + } else { + label.element.setFile(uri, { strikethrough: options.strikethrough }); + } + }, + dispose() { + label.dispose(); + } + }; + } +} + +/** + * Changes editor for the Agents window: a "Branch Changes" versions dropdown and + * diff stats header sitting above an embedded multi-diff editor showing the + * session's file diffs. + */ +export class SessionChangesEditor extends EditorPane { + + static readonly ID = SessionChangesEditorInput.EDITOR_ID; + + private widget: MultiDiffEditorWidget | undefined; + private viewModel: MultiDiffEditorViewModel | undefined; + private bodyContainer: HTMLElement | undefined; + + private _singlePane = false; + private _scopedInstantiationService: IInstantiationService | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IChangesViewService private readonly changesViewService: IChangesViewService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(SessionChangesEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + const root = append(parent, $('.session-changes-editor')); + + const scopedContextKeyService = this._register(this.contextKeyService.createScoped(root)); + this._register(bindContextKey(ActiveSessionContextKeys.HasGitRepository, scopedContextKeyService, reader => + this.changesViewService.activeSessionHasGitRepositoryObs.read(reader))); + this._register(bindContextKey(ChatContextKeys.hasAgentSessionChanges, scopedContextKeyService, reader => + this.changesViewService.activeSessionChangesObs.read(reader).length > 0)); + const scopedInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, scopedContextKeyService]))); + this._scopedInstantiationService = scopedInstantiationService; + + // In single-pane, the header (Branch Changes dropdown, diff stats and primary + // actions) is hosted by the editor part's full-width header instead of inside + // this editor, so it spans the editor content and the docked detail panel. + this._singlePane = shouldUseSinglePaneLayout(this.configurationService); + if (!this._singlePane) { + const header = append(root, $('.session-changes-editor-header')); + const left = append(header, $('.session-changes-editor-header-left')); + const right = append(header, $('.session-changes-editor-header-right')); + this._register(this._buildHeaderToolbars(left, right, scopedInstantiationService)); + } + + this.bodyContainer = append(root, $('.session-changes-editor-body')); + + // Create the widget in the editor-pane context (not the deeper scoped one) + // so its own multiDiffEditor* context keys (all-collapsed, render-side-by-side) + // are visible to the EditorTitle menu that drives the collapse/expand-all and + // inline-view toggle actions. + const paneInstantiationService = this._register(this.instantiationService.createChild( + new ServiceCollection([IContextKeyService, this.contextKeyService]))); + this.widget = this._register(paneInstantiationService.createInstance( + MultiDiffEditorWidget, + this.bodyContainer, + paneInstantiationService.createInstance(SessionChangesUIElementFactory), + )); + this.widget.setRenderSideBySide(this.configurationService.getValue<boolean>('diffEditor.renderSideBySide') ?? true); + } + + toggleInlineView(): void { + this.widget?.toggleRenderSideBySide(); + } + + /** Creates the classic (non-single-pane) internal header toolbars. */ + private _buildHeaderToolbars(left: HTMLElement, right: HTMLElement, instantiationService: IInstantiationService): IDisposable { + const store = new DisposableStore(); + + // The Branch Changes picker + diff stats render as the leading header menu; + // their custom action view items resolve globally via IActionViewItemService. + store.add(instantiationService.createInstance(MenuWorkbenchToolBar, left, Menus.SessionsEditorHeaderPrimary, { + menuOptions: { shouldForwardArgs: true }, + })); + + // Create Pull Request (and related) actions render on the right of the header row. + store.add(instantiationService.createInstance(ChangesActionsBar, right)); + + return store; + } + + /** + * In single-pane, opt this editor in to the group's full-width header (spanning + * the editor content and docked detail), providing this editor's scoped context + * so the header actions' `when` clauses evaluate correctly. + */ + getHeaderActions(): IEditorHeaderActions | undefined { + if (!this._singlePane || !this._scopedInstantiationService) { + return undefined; + } + return { instantiationService: this._scopedInstantiationService }; + } + + override async setInput(input: SessionChangesEditorInput, options: IMultiDiffEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> { + await super.setInput(input, options, context, token); + const viewModel = await input.getViewModel(); + if (token.isCancellationRequested) { + return; + } + this.viewModel = viewModel; + this.widget?.setViewModel(viewModel, { preserveFocus: options?.preserveFocus }); + this._applyOptions(options); + } + + collapseAllDiffs(): void { + this.viewModel?.collapseAll(); + } + + expandAllDiffs(): void { + this.viewModel?.expandAll(); + } + + override setOptions(options: IMultiDiffEditorOptions | undefined): void { + this._applyOptions(options); + } + + private _applyOptions(options: IMultiDiffEditorOptions | undefined): void { + const revealData = options?.viewState?.revealData; + if (!revealData) { + return; + } + this.widget?.reveal(revealData.resource, { + range: revealData.range ? Range.lift(revealData.range) : undefined, + highlight: true, + }); + } + + override clearInput(): void { + this.viewModel = undefined; + this.widget?.setViewModel(undefined); + super.clearInput(); + } + + override focus(): void { + super.focus(); + this.widget?.getActiveControl()?.focus(); + } + + override layout(dimension: Dimension): void { + // In single-pane the header is external (the editor part reserves a top inset), + // so the diff fills the full dimension; otherwise reserve the internal header. + const bodyHeight = this._singlePane ? dimension.height : Math.max(0, dimension.height - HEADER_HEIGHT); + this.widget?.layout(new Dimension(dimension.width, bodyHeight)); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts new file mode 100644 index 00000000000000..ba3efbc66e5994 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesEditorInput.ts @@ -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. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { MultiDiffEditorViewModel } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorViewModel.js'; + +/** + * Editor input for the Agents window Changes tab. It wraps the session's + * multi-diff source and exposes the resolved multi-diff view model so the + * {@link SessionChangesEditor} can render the diffs beneath its own header. + */ +export class SessionChangesEditorInput extends EditorInput { + + static readonly ID = 'workbench.input.agentSessions.sessionChanges'; + static readonly EDITOR_ID = 'workbench.editor.agentSessions.sessionChanges'; + + private _innerInput: MultiDiffEditorInput | undefined; + + constructor( + readonly multiDiffSource: URI, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + } + + override get resource(): URI { + return this.multiDiffSource; + } + + override get typeId(): string { + return SessionChangesEditorInput.ID; + } + + override get editorId(): string { + return SessionChangesEditorInput.EDITOR_ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Singleton | EditorInputCapabilities.Readonly; + } + + override getName(): string { + return localize('sessionChangesEditor.name', "Changes"); + } + + override getIcon(): ThemeIcon { + return Codicon.diffMultiple; + } + + override getTitle(_verbosity?: Verbosity): string { + return this.getName(); + } + + private get innerInput(): MultiDiffEditorInput { + if (!this._innerInput) { + this._innerInput = this._register(MultiDiffEditorInput.fromResourceMultiDiffEditorInput({ + multiDiffSource: this.multiDiffSource, + label: this.getName(), + }, this.instantiationService)); + } + return this._innerInput; + } + + async getViewModel(): Promise<MultiDiffEditorViewModel> { + return this.innerInput.getViewModel(); + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + if (this === otherInput) { + return true; + } + return otherInput instanceof SessionChangesEditorInput + && otherInput.multiDiffSource.toString() === this.multiDiffSource.toString(); + } +} + +interface ISerializedSessionChangesEditorInput { + readonly multiDiffSourceUri: string; +} + +export class SessionChangesEditorSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): editorInput is SessionChangesEditorInput { + return editorInput instanceof SessionChangesEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + if (!this.canSerialize(editorInput)) { + return undefined; + } + const data: ISerializedSessionChangesEditorInput = { multiDiffSourceUri: editorInput.multiDiffSource.toString() }; + return JSON.stringify(data); + } + + deserialize(instantiationService: IInstantiationService, serializedEditor: string): EditorInput | undefined { + try { + const data = JSON.parse(serializedEditor) as ISerializedSessionChangesEditorInput; + return instantiationService.createInstance(SessionChangesEditorInput, URI.parse(data.multiDiffSourceUri)); + } catch { + return undefined; + } + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts index 4759993a5aabf1..77dd507e498e9e 100644 --- a/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts +++ b/src/vs/sessions/contrib/changes/browser/sessionChangesService.ts @@ -4,7 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { localize } from '../../../../nls.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { IEditorService, PreferredGroup } from '../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { SessionChangesEditorInput } from './sessionChangesEditorInput.js'; export const ISessionChangesService = createDecorator<ISessionChangesService>('sessionChangesService'); @@ -13,7 +20,7 @@ export const ISessionChangesService = createDecorator<ISessionChangesService>('s * the single source of truth for the `changes-multi-diff-source:` resource that * the multi-diff editor is opened with, so callers don't have to know the URI * shape: the session header action and the Changes view open the editor with - * {@link getChangesEditorResource}, the layout controller recognizes the active + * {@link openChangesEditor}, the layout controller recognizes the active * editor as a Changes editor with {@link getSessionResource}, and the * multi-diff source resolver uses both. */ @@ -34,6 +41,12 @@ export interface ISessionChangesService { * otherwise `undefined`. */ getSessionResource(editorResource: URI): URI | undefined; + + /** + * Open the Changes editor for a session. In the single-pane layout this opens + * the custom {@link SessionChangesEditorInput}; otherwise a plain multi-diff editor. + */ + openChangesEditor(sessionResource: URI, options?: IMultiDiffEditorOptions, group?: PreferredGroup): Promise<IEditorGroup | undefined>; } const CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'changes-multi-diff-source'; @@ -46,6 +59,12 @@ export class SessionChangesService implements ISessionChangesService { declare readonly _serviceBrand: undefined; + constructor( + @IEditorService private readonly editorService: IEditorService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { } + getChangesEditorResource(sessionResource: URI): URI { return URI.from({ scheme: CHANGES_MULTI_DIFF_SOURCE_SCHEME, @@ -71,4 +90,25 @@ export class SessionChangesService implements ISessionChangesService { return URI.parse(fields.sessionResource); } + + async openChangesEditor(sessionResource: URI, options?: IMultiDiffEditorOptions, group?: PreferredGroup): Promise<IEditorGroup | undefined> { + const multiDiffSource = this.getChangesEditorResource(sessionResource); + + // Read the setting directly (rather than via IAgentWorkbenchLayoutService) so this + // singleton also resolves in minimal environments — component fixtures / tests — that + // don't register the Agents-window layout service. The layout service remains the + // single source of truth for contributions that run only in the real window. + if (this.configurationService.getValue<boolean>(DOCK_DETAIL_PANEL_SETTING) === true) { + const input = this.instantiationService.createInstance(SessionChangesEditorInput, multiDiffSource); + const pane = await this.editorService.openEditor(input, { ...options, pinned: true }, group); + return pane?.group; + } + + const pane = await this.editorService.openEditor({ + multiDiffSource, + label: localize('sessions.changes.title', 'Session Changes'), + options, + }, group); + return pane?.group; + } } diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts new file mode 100644 index 00000000000000..ea8bc77fec8b5b --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesViewModel.ts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { derived, IObservable } from '../../../../base/common/observable.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { ISessionFile } from '../../../services/sessions/common/session.js'; + +/** Shared stable empty result so "no session / no files" doesn't churn observers. */ +const EMPTY_SESSION_FILES: readonly ISessionFile[] = Object.freeze([]); + +/** + * View model backing the "Other Files" section in the changes view. Exposes + * the files created/edited/deleted outside the workspace by the active session. + */ +export class SessionFilesViewModel extends Disposable { + readonly sessionFilesObs: IObservable<readonly ISessionFile[]>; + + constructor( + @ISessionsService sessionsService: ISessionsService, + ) { + super(); + + // The underlying `externalChanges` observable carries its own structural + // equality, so when it is unchanged it returns the same array reference + // and this derived does not propagate. The shared empty constant keeps + // the "no session" case equally stable. + this.sessionFilesObs = derived(this, reader => { + const activeSession = sessionsService.activeSession.read(reader); + return activeSession?.externalChanges?.read(reader) ?? EMPTY_SESSION_FILES; + }); + } +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts new file mode 100644 index 00000000000000..54be85f4ed9fc5 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts @@ -0,0 +1,406 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/sessionFilesWidget.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { basename } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { WorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { FileKind, IFileService } from '../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../platform/label/common/label.js'; +import { WorkbenchList } from '../../../../platform/list/browser/listService.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { DEFAULT_LABELS_CONTAINER, IResourceLabel, ResourceLabels } from '../../../../workbench/browser/labels.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../workbench/contrib/files/browser/views/explorerView.js'; +import { ACTIVE_GROUP, IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { ISessionFile, SessionFileOperation } from '../../../services/sessions/common/session.js'; + +const $ = dom.$; + +/** Minimal input contract for {@link SessionFilesWidget.setInput}. */ +export interface ISessionFilesInput { + readonly sessionFilesObs: IObservable<readonly ISessionFile[]>; +} + +class SessionFileListDelegate implements IListVirtualDelegate<ISessionFile> { + static readonly ITEM_HEIGHT = 22; + + getHeight(_element: ISessionFile): number { + return SessionFileListDelegate.ITEM_HEIGHT; + } + + getTemplateId(_element: ISessionFile): string { + return SessionFileListRenderer.TEMPLATE_ID; + } +} + +interface ISessionFileTemplateData { + readonly label: IResourceLabel; + readonly toolbar: WorkbenchToolBar; + readonly templateDisposables: DisposableStore; +} + +class SessionFileListRenderer implements IListRenderer<ISessionFile, ISessionFileTemplateData> { + static readonly TEMPLATE_ID = 'sessionFile'; + readonly templateId = SessionFileListRenderer.TEMPLATE_ID; + + constructor( + private readonly _labels: ResourceLabels, + private readonly _onOpenFile: (file: ISessionFile) => void, + @ILabelService private readonly _labelService: ILabelService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { } + + renderTemplate(container: HTMLElement): ISessionFileTemplateData { + const templateDisposables = new DisposableStore(); + const row = dom.append(container, $('.session-files-widget-file')); + const label = templateDisposables.add(this._labels.create(row)); + + const actionBarContainer = $('.chat-collapsible-list-action-bar'); + const toolbar = templateDisposables.add(this._instantiationService.createInstance(WorkbenchToolBar, actionBarContainer, undefined)); + label.element.appendChild(actionBarContainer); + + return { label, toolbar, templateDisposables }; + } + + renderElement(element: ISessionFile, _index: number, templateData: ISessionFileTemplateData): void { + templateData.label.setResource({ + resource: element.uri, + name: basename(element.uri), + }, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + strikethrough: element.operation === SessionFileOperation.Deleted, + title: getSessionFileTitle(element, this._labelService), + }); + + templateData.toolbar.setActions([toAction({ + id: 'sessionFiles.openFile', + label: localize('sessionFiles.openFileAction', "Open File"), + class: ThemeIcon.asClassName(Codicon.goToFile), + run: () => this._onOpenFile(element), + })]); + } + + disposeTemplate(templateData: ISessionFileTemplateData): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * A widget that lists the files created, edited or deleted **outside** the + * session workspace during the session. Rendered between the changes tree and + * the CI checks widget in the changes view as a resizable SplitView pane. + * + * The collapse/resize behaviour mirrors {@link CIStatusWidget}. + */ +export class SessionFilesWidget extends Disposable { + + static readonly HEADER_HEIGHT = 34; // 6px header margin-top + 8px header padding + 20px header min-height + static readonly MIN_BODY_HEIGHT = 3 * SessionFileListDelegate.ITEM_HEIGHT + 2; + static readonly PREFERRED_BODY_HEIGHT = 4 * SessionFileListDelegate.ITEM_HEIGHT; + static readonly MAX_BODY_HEIGHT = 240; + + private readonly _domNode: HTMLElement; + private readonly _headerNode: HTMLElement; + private readonly _titleNode: HTMLElement; + private readonly _titleLabelNode: HTMLElement; + private readonly _countNode: HTMLElement; + private readonly _chevronNode: HTMLElement; + private readonly _bodyNode: HTMLElement; + private readonly _list: WorkbenchList<ISessionFile>; + private readonly _labels: ResourceLabels; + + private readonly _onDidChangeHeight = this._register(new Emitter<void>()); + readonly onDidChangeHeight = this._onDidChangeHeight.event; + + private readonly _onDidToggleCollapsed = this._register(new Emitter<boolean>()); + readonly onDidToggleCollapsed = this._onDidToggleCollapsed.event; + + private _fileCount = 0; + private _collapsed = false; + + get element(): HTMLElement { + return this._domNode; + } + + /** The full content height the widget would like (header + all files). */ + get desiredHeight(): number { + if (this._fileCount === 0) { + return 0; + } + if (this._collapsed) { + return SessionFilesWidget.HEADER_HEIGHT; + } + return SessionFilesWidget.HEADER_HEIGHT + this._fileCount * SessionFileListDelegate.ITEM_HEIGHT; + } + + /** Whether the widget is currently visible (has files to show). */ + get visible(): boolean { + return this._fileCount > 0; + } + + /** Whether the body is collapsed (header-only). */ + get collapsed(): boolean { + return this._collapsed; + } + + constructor( + container: HTMLElement, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @ILabelService private readonly _labelService: ILabelService, + @IEditorService private readonly _editorService: IEditorService, + @IHoverService private readonly _hoverService: IHoverService, + @IFileService private readonly _fileService: IFileService, + @IThemeService private readonly _themeService: IThemeService, + ) { + super(); + this._labels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._domNode = dom.append(container, $('.session-files-widget')); + this._domNode.style.display = 'none'; + + // Enable file icons from the active file icon theme for the resource + // labels rendered in this widget's list. + this._register(createFileIconThemableTreeContainerScope(this._domNode, this._themeService)); + + // Header (always visible, click to collapse/expand) + this._headerNode = dom.append(this._domNode, $('.session-files-widget-header')); + this._titleNode = dom.append(this._headerNode, $('.session-files-widget-title')); + this._titleLabelNode = dom.append(this._titleNode, $('.session-files-widget-title-label')); + this._titleLabelNode.textContent = localize('sessionFiles.label', "Other Files"); + // File count shown in the header only while collapsed (mirrors the + // customizations section in the sessions view). + this._countNode = dom.append(this._headerNode, $('.session-files-widget-count.hidden')); + this._chevronNode = dom.append(this._headerNode, $('.group-chevron')); + this._chevronNode.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + + this._headerNode.setAttribute('role', 'button'); + this._headerNode.setAttribute('aria-label', localize('sessionFiles.toggle', "Toggle Other Files")); + this._headerNode.setAttribute('aria-expanded', 'true'); + this._headerNode.tabIndex = 0; + + this._register(this._hoverService.setupManagedHover( + getDefaultHoverDelegate('mouse'), + this._headerNode, + localize('sessionFiles.hover', "Files created, edited, or deleted outside the workspace during this session. These files are not part of the workspace and won't be committed."), + )); + + // Register the gesture target so the toggle works on touch platforms + // (notably iOS) in the Sessions window, then handle both mouse click and + // touch tap. + this._register(Gesture.addTarget(this._headerNode)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._register(dom.addDisposableListener(this._headerNode, eventType, () => { + this._toggleCollapsed(); + })); + } + this._register(dom.addDisposableListener(this._headerNode, dom.EventType.KEY_DOWN, e => { + if ((e.key === 'Enter' || e.key === ' ') && e.target === this._headerNode) { + e.preventDefault(); + this._toggleCollapsed(); + } + })); + + // Body (list of files) + const bodyId = 'session-files-widget-body'; + this._bodyNode = dom.append(this._domNode, $(`.${bodyId}`)); + this._bodyNode.id = bodyId; + this._headerNode.setAttribute('aria-controls', bodyId); + + const listContainer = $('.session-files-widget-list'); + this._list = this._register(this._instantiationService.createInstance( + WorkbenchList<ISessionFile>, + 'SessionFilesWidget', + listContainer, + new SessionFileListDelegate(), + [this._instantiationService.createInstance(SessionFileListRenderer, this._labels, (file: ISessionFile) => this._openFilePlain(file))], + { + multipleSelectionSupport: false, + openOnSingleClick: true, + accessibilityProvider: { + getWidgetAriaLabel: () => localize('sessionFiles.listAriaLabel', "Other Files"), + getAriaLabel: item => localize('sessionFiles.fileAriaLabel', "{0}, {1}", basename(item.uri), getSessionFileOperationLabel(item.operation)), + }, + keyboardNavigationLabelProvider: { + getKeyboardNavigationLabel: item => basename(item.uri), + }, + }, + )); + this._bodyNode.appendChild(listContainer); + + this._register(this._list.onDidOpen(e => { + if (e.element) { + void this._openFile(e.element, !!e.editorOptions?.preserveFocus, !!e.editorOptions?.pinned); + } + })); + } + + setInput(input: ISessionFilesInput): IDisposable { + return autorun(reader => { + const files = input.sessionFilesObs.read(reader); + + const oldCount = this._fileCount; + this._fileCount = files.length; + + if (files.length === 0) { + this._setCollapsed(false); + this._renderBody([]); + this._domNode.style.display = 'none'; + if (oldCount !== 0) { + this._onDidChangeHeight.fire(); + } + return; + } + + this._domNode.style.display = ''; + this._renderBody(files); + this._renderCount(); + + if (this._fileCount !== oldCount) { + this._onDidChangeHeight.fire(); + } + }); + } + + /** + * Layout the widget body list to the given height. + * Called by the parent view after computing available space. + */ + layout(height: number): void { + if (this._collapsed) { + this._bodyNode.style.display = 'none'; + return; + } + this._bodyNode.style.display = ''; + this._list.layout(height); + } + + private _toggleCollapsed(): void { + this._setCollapsed(!this._collapsed); + this._onDidToggleCollapsed.fire(this._collapsed); + this._onDidChangeHeight.fire(); + } + + /** + * Expand the body if it is currently collapsed, notifying listeners so the + * parent pane restores its size. No-op when already expanded. + */ + expand(): void { + if (!this._collapsed) { + return; + } + this._setCollapsed(false); + this._onDidToggleCollapsed.fire(false); + this._onDidChangeHeight.fire(); + } + + /** + * Move keyboard focus into the files list. Falls back to the header when the + * body is collapsed or there is nothing to focus. + */ + focus(): void { + if (this._collapsed || this._fileCount === 0) { + this._headerNode.focus(); + return; + } + this._list.domFocus(); + if (this._list.length > 0 && this._list.getFocus().length === 0) { + this._list.setFocus([0]); + } + } + + private _setCollapsed(collapsed: boolean): void { + this._collapsed = collapsed; + this._updateChevron(); + this._headerNode.classList.toggle('collapsed', collapsed); + this._headerNode.setAttribute('aria-expanded', String(!collapsed)); + this._renderCount(); + } + + /** Show the file count in the header only while collapsed. */ + private _renderCount(): void { + this._countNode.textContent = this._fileCount > 0 ? `${this._fileCount}` : ''; + this._countNode.classList.toggle('hidden', !this._collapsed || this._fileCount === 0); + } + + private _updateChevron(): void { + this._chevronNode.className = 'group-chevron'; + this._chevronNode.classList.add( + ...ThemeIcon.asClassNameArray( + this._collapsed ? Codicon.chevronRight : Codicon.chevronDown + ) + ); + } + + private _renderBody(files: readonly ISessionFile[]): void { + this._list.splice(0, this._list.length, files); + } + + private async _openFile(file: ISessionFile, preserveFocus: boolean, pinned: boolean): Promise<void> { + // Created and deleted files open normally; modified files open a diff + // against their pre-session content when it is available and non-empty. + if (file.operation === SessionFileOperation.Modified && file.originalUri && await this._hasContent(file.originalUri)) { + await this._editorService.openEditor({ + original: { resource: file.originalUri }, + modified: { resource: file.uri }, + label: getDiffEditorLabel(file.uri, this._labelService), + options: { preserveFocus, pinned }, + }, ACTIVE_GROUP); + return; + } + + await this._editorService.openEditor({ + resource: file.uri, + options: { preserveFocus, pinned }, + }, ACTIVE_GROUP); + } + + private async _hasContent(resource: URI): Promise<boolean> { + try { + const content = await this._fileService.readFile(resource); + return content.value.byteLength > 0; + } catch { + return false; + } + } + + /** Open the file in a normal editor, ignoring the pre-session diff. */ + private _openFilePlain(file: ISessionFile): void { + void this._editorService.openEditor({ resource: file.uri }, ACTIVE_GROUP); + } +} + +function getSessionFileOperationLabel(operation: SessionFileOperation): string { + switch (operation) { + case SessionFileOperation.Created: + return localize('sessionFiles.created', "Created"); + case SessionFileOperation.Modified: + return localize('sessionFiles.modified', "Modified"); + case SessionFileOperation.Deleted: + return localize('sessionFiles.deleted', "Deleted"); + } +} + +function getSessionFileTitle(file: ISessionFile, labelService: ILabelService): string { + const path = labelService.getUriLabel(file.uri); + return localize('sessionFiles.title', "{0} ({1})", path, getSessionFileOperationLabel(file.operation)); +} + +function getDiffEditorLabel(uri: URI, labelService: ILabelService): string { + return localize('sessionFiles.diffLabel', "{0} (Session Changes)", basename(uri) || labelService.getUriLabel(uri)); +} diff --git a/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts new file mode 100644 index 00000000000000..b4ea1e5f28cfc2 --- /dev/null +++ b/src/vs/sessions/contrib/changes/browser/sessionsChangesAccessibilityHelp.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; +import { localize } from '../../../../nls.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import { FocusedViewContext } from '../../../../workbench/common/contextkeys.js'; +import { CHANGES_VIEW_ID } from '../common/changes.js'; +import { ChangesViewPane } from './changesView.js'; + +/** + * Accessibility help dialog for the Changes view. Documents the file tree and + * the two collapsible sections beneath it - Other Files and Checks - and how + * to operate them with the keyboard. + */ +export class SessionsChangesAccessibilityHelp implements IAccessibleViewImplementation { + readonly priority = 115; + readonly name = 'sessionsChanges'; + readonly type = AccessibleViewType.Help; + readonly when = FocusedViewContext.isEqualTo(CHANGES_VIEW_ID); + + getProvider(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + + const content: string[] = []; + content.push(localize('sessionsChanges.overview', "You are in the Changes view. It shows the files changed by the current session as a tree, followed by two collapsible sections: Other Files and Checks.")); + content.push(localize('sessionsChanges.tree', "Use the up and down arrow keys to move between changed files, and the left and right arrow keys to collapse or expand folders. Press Enter to open the selected file's diff.")); + content.push(localize('sessionsChanges.sessionFiles', "The Other Files section lists files that were created, edited, or deleted outside the workspace during this session, such as configuration files in your home directory. These files are not part of the workspace and won't be committed.")); + content.push(localize('sessionsChanges.sessionFilesToggle', "The Other Files header is a button. Press Enter or Space to collapse or expand the list. When expanded, use the arrow keys to move through the files and press Enter to open one: created or deleted files open in an editor, while edited files open as a diff against their pre-session content.")); + content.push(localize('sessionsChanges.checks', "The Checks section lists the continuous integration checks for the session's pull request. Its header is a button: press Enter or Space to collapse or expand it{0}.", '<keybinding:sessions.action.revealCIChecks>')); + content.push(localize('sessionsChanges.viewMode', "The Changes view can show files as a tree or a flat list. Use the view's toolbar actions to switch between Tree and List modes.")); + + return new AccessibleContentProvider( + AccessibleViewProviderId.SessionsChanges, + { type: AccessibleViewType.Help }, + () => content.join('\n'), + () => { + const view = viewsService.getViewWithId<ChangesViewPane>(CHANGES_VIEW_ID); + view?.focus(); + }, + AccessibilityVerbositySettingId.SessionsChanges, + ); + } +} diff --git a/src/vs/sessions/contrib/changes/common/changesViewService.ts b/src/vs/sessions/contrib/changes/common/changesViewService.ts index b564f3299c1a2c..c506e6064e8068 100644 --- a/src/vs/sessions/contrib/changes/common/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/common/changesViewService.ts @@ -36,13 +36,15 @@ export interface IChangesViewService { readonly activeSessionIsVirtualWorkspaceObs: IObservable<boolean>; readonly activeSessionChangesObs: IObservable<readonly ISessionFileChange[]>; readonly activeSessionChangesetsObs: IObservable<readonly ISessionChangeset[] | undefined>; + readonly activeSessionChangesetsLoadingObs: IObservable<boolean>; readonly activeSessionChangesetObs: IObservable<ISessionChangeset | undefined>; + readonly activeSessionChangesetLoadingObs: IObservable<boolean>; readonly activeSessionChangesetOperationsObs: IObservable<readonly ISessionChangesetOperation[]>; readonly activeSessionHasGitRepositoryObs: IObservable<boolean>; readonly activeSessionReviewCommentCountByFileObs: IObservable<Map<string, number>>; readonly activeSessionAgentFeedbackCountByFileObs: IObservable<Map<string, number>>; readonly activeSessionStateObs: IObservable<ActiveSessionState | undefined>; - readonly activeSessionIsLoadingObs: IObservable<boolean>; + readonly activeSessionLoadingObs: IObservable<boolean>; setChangesetId(changesetId: string | undefined): void; diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts new file mode 100644 index 00000000000000..44c9339bbd47c7 --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewActions.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpression } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorContextKeys } from '../../../../../editor/common/editorContextKeys.js'; +import { ActiveEditorContext, AuxiliaryBarVisibleContext, IsSessionsWindowContext, MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; +import { Menus } from '../../../../browser/menus.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../../common/sessionConfig.js'; +import { ChangesContextKeys } from '../../common/changes.js'; +import { SessionChangesEditor } from '../../browser/sessionChangesEditor.js'; +import '../../browser/changesViewActions.js'; + +suite('Changes View Actions', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('collapse all diffs is contributed to the single-pane editor title bar', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.agentSessions.collapseAllDiffs'); + + assert.ok(item, 'expected collapse all diffs action on the single-pane editor title menu'); + const when = item.when?.serialize() ?? ''; + assert.deepStrictEqual({ + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasSinglePaneConfigGate: when.includes(`config.${DOCK_DETAIL_PANEL_SETTING}`), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + }, { + group: 'navigation', + order: 100, + icon: Codicon.collapseAll.id, + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + }); + }); + + test('expand all diffs is contributed to the single-pane editor title bar', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.agentSessions.expandAllDiffs'); + + assert.ok(item, 'expected expand all diffs action on the single-pane editor title menu'); + const when = item.when?.serialize() ?? ''; + assert.deepStrictEqual({ + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasSinglePaneConfigGate: when.includes(`config.${DOCK_DETAIL_PANEL_SETTING}`), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + hasAllCollapsedGate: when.includes(EditorContextKeys.multiDiffEditorAllCollapsed.key), + }, { + group: 'navigation', + order: 100, + icon: Codicon.expandAll.id, + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + hasAllCollapsedGate: true, + }); + }); + + test('toggle inline view command is contributed to the single-pane editor title bar', () => { + const item = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + .filter(isIMenuItem) + .find(item => item.command.id === 'workbench.action.agentSessions.toggleInlineView'); + + assert.ok(item, 'expected toggle inline view command on the single-pane editor title menu'); + const when = item.when?.serialize() ?? ''; + assert.deepStrictEqual({ + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + toggled: (item.command.toggled as ContextKeyExpression | undefined)?.serialize(), + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasSinglePaneConfigGate: when.includes(`config.${DOCK_DETAIL_PANEL_SETTING}`), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + }, { + group: 'navigation', + order: 99, + icon: Codicon.diffSidebyside.id, + toggled: EditorContextKeys.multiDiffEditorRenderSideBySide.negate().serialize(), + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + }); + }); + + test('view mode toggles are contributed to the single-pane editor title bar', () => { + const items = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle) + .filter(isIMenuItem) + .filter(item => item.command.id === 'workbench.action.agentSessions.setChangesListViewMode' || item.command.id === 'workbench.action.agentSessions.setChangesTreeViewMode'); + + const actual = items.map(item => { + const when = item.when?.serialize() ?? ''; + return { + id: item.command.id, + title: typeof item.command.title === 'string' ? item.command.title : item.command.title.value, + group: item.group, + order: item.order, + icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, + hasSessionsWindowGate: when.includes(IsSessionsWindowContext.key), + hasActiveEditorGate: when.includes(ActiveEditorContext.key) && when.includes(SessionChangesEditor.ID), + hasSinglePaneConfigGate: when.includes(`config.${DOCK_DETAIL_PANEL_SETTING}`), + hasEditorAreaVisibleGate: when.includes(MainEditorAreaVisibleContext.key), + hasAuxBarVisibleGate: when.includes(AuxiliaryBarVisibleContext.key), + hasViewModeGate: when.includes(ChangesContextKeys.ViewMode.key), + }; + }).sort((a, b) => a.id.localeCompare(b.id)); + + assert.deepStrictEqual(actual, [{ + id: 'workbench.action.agentSessions.setChangesListViewMode', + title: 'View as List', + group: '1_changesView', + order: 10, + icon: Codicon.listFlat.id, + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + hasAuxBarVisibleGate: true, + hasViewModeGate: true, + }, { + id: 'workbench.action.agentSessions.setChangesTreeViewMode', + title: 'View as Tree', + group: '1_changesView', + order: 10, + icon: Codicon.listTree.id, + hasSessionsWindowGate: true, + hasActiveEditorGate: true, + hasSinglePaneConfigGate: true, + hasEditorAreaVisibleGate: true, + hasAuxBarVisibleGate: true, + hasViewModeGate: true, + }]); + }); +}); diff --git a/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.ts b/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.ts new file mode 100644 index 00000000000000..48126c1805afb5 --- /dev/null +++ b/src/vs/sessions/contrib/changes/test/browser/sessionFilesWidget.fixture.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../../base/common/event.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IDecorationsService } from '../../../../../workbench/services/decorations/common/decorations.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { INotebookDocumentService } from '../../../../../workbench/services/notebook/common/notebookDocumentService.js'; +import { ITextFileService } from '../../../../../workbench/services/textfile/common/textfiles.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { ISessionFile, SessionFileOperation } from '../../../../services/sessions/common/session.js'; +import { ISessionFilesInput, SessionFilesWidget } from '../../browser/sessionFilesWidget.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; + +const SAMPLE_FILES: readonly ISessionFile[] = [ + { uri: URI.file('/home/user/.bashrc'), operation: SessionFileOperation.Modified, originalUri: URI.file('/home/user/.bashrc.orig') }, + { uri: URI.file('/home/user/.config/app/settings.json'), operation: SessionFileOperation.Created }, + { uri: URI.file('/home/user/.cache/tmp/scratch.log'), operation: SessionFileOperation.Deleted }, + { uri: URI.file('/tmp/agent-notes.md'), operation: SessionFileOperation.Created }, +]; + +function renderWidget(ctx: ComponentFixtureContext, options?: { files?: readonly ISessionFile[]; height?: number }): void { + ctx.container.style.width = '360px'; + ctx.container.style.height = `${options?.height ?? 160}px`; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Services required by ResourceLabels (file labels in the list). + reg.defineInstance(IDecorationsService, new class extends mock<IDecorationsService>() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock<ITextFileService>() { override readonly untitled = new class extends mock<ITextFileService['untitled']>() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: '', folders: [], configuration: undefined }; } }()); + reg.definePartialInstance(INotebookDocumentService, { getNotebook: () => undefined }); + reg.defineInstance(IFileService, new class extends mock<IFileService>() { + override async readFile(resource: URI): Promise<IFileContent> { + return new class extends mock<IFileContent>() { + override readonly resource = resource; + override readonly value = VSBuffer.fromString('original content'); + }(); + } + }()); + // Required by WorkbenchList (the files list). + reg.define(IListService, ListService); + registerWorkbenchServices(reg); + reg.defineInstance(IEditorService, new class extends mock<IEditorService>() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + override async openEditor(): Promise<undefined> { return undefined; } + }()); + }, + }); + + const files = options?.files ?? SAMPLE_FILES; + const input: ISessionFilesInput = { + sessionFilesObs: observableValue<readonly ISessionFile[]>('fixtureSessionFiles', files), + }; + + const widget = ctx.disposableStore.add(instantiationService.createInstance(SessionFilesWidget, ctx.container)); + ctx.disposableStore.add(widget.setInput(input)); + + // The widget normally lives inside a SplitView pane that drives its layout; + // the fixture sizes it directly so the list renders. + const totalHeight = options?.height ?? 160; + widget.element.style.height = `${totalHeight}px`; + widget.layout(Math.max(0, totalHeight - SessionFilesWidget.HEADER_HEIGHT)); +} + +export default defineThemedFixtureGroup({ path: 'changes/' }, { + + WithFiles: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx), + }), + + SingleCreatedFile: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { + files: [{ uri: URI.file('/home/user/.gitconfig'), operation: SessionFileOperation.Created }], + height: 96, + }), + }), + + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { files: [], height: 96 }), + }), + +}); diff --git a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts index 755637547f9c46..be52f709f4b74b 100644 --- a/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts +++ b/src/vs/sessions/contrib/chat/browser/agentHostInputCompletions.ts @@ -7,18 +7,22 @@ import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../editor/common/core/ranges/offsetRange.js'; -import { IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; +import { IDecorationOptions, IEditorDecorationsCollection } from '../../../../editor/common/editorCommon.js'; import { CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js'; import { IModelDeltaDecoration, ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; -import { AgentHostCompletionReferenceKind, IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, toAgentHostCompletionVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { getCommandArgumentHint } from '../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; +import { AgentHostCompletionReferenceKind, getAgentHostCompletionReferenceKind, IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, toAgentHostCompletionVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; import { IChatInputCompletionItem, IChatSessionsService, isAgentHostTarget } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { AgentHostInputCompletionsBase } from '../../../../workbench/contrib/chat/browser/widget/input/editor/agentHostInputCompletionsBase.js'; +import { getInputPlaceholderColor, getRangeForPlaceholder } from '../../../../workbench/contrib/chat/browser/widget/input/editor/chatInputPlaceholderDecoration.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { NewChatContextAttachments } from './newChatContextAttachments.js'; @@ -83,6 +87,43 @@ export function getAgentHostCompletionAttachmentRange( return new OffsetRange(start, endExclusive); } +/** + * Determines whether an inline argument-hint placeholder should be shown for an + * accepted agent-host slash command. Returns the hint text and the offset just + * after the command token when the command is the sole content of `value` + * followed by exactly one trailing space (i.e. no argument has been typed yet), + * or `undefined` otherwise. + */ +export function getCommandArgumentHintPlaceholder( + value: string, + attachments: readonly IChatRequestVariableEntry[], + insertedReferences: ReadonlyMap<string, { text: string; range: OffsetRange | undefined }>, +): { argumentHint: string; endOffset: number } | undefined { + for (const entry of attachments) { + if (getAgentHostCompletionReferenceKind(entry) !== AgentHostCompletionReferenceKind.Command) { + continue; + } + const argumentHint = getCommandArgumentHint(entry._meta); + if (!argumentHint) { + continue; + } + const reference = insertedReferences.get(entry.id); + if (!reference) { + continue; + } + const range = getAgentHostCompletionAttachmentRange(value, reference.text, reference.range, 0, value.length); + if (!range) { + continue; + } + // Only show the hint while the command is the sole content followed by exactly one trailing space. + if (value.slice(0, range.start).trim().length > 0 || value.slice(range.endExclusive) !== ' ') { + return undefined; + } + return { argumentHint, endOffset: range.endExclusive }; + } + return undefined; +} + /** * Bridges the new-chat input editor to the agent host's `completions` * command for the currently-selected session type. Mirrors @@ -99,6 +140,8 @@ export function getAgentHostCompletionAttachmentRange( export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBase<void, string> { private static readonly _className = 'sessions-agent-host-reference'; + private static readonly _argumentHintDecorationDescription = 'sessions-chat'; + private static readonly _argumentHintDecorationType = 'sessions-command-argument-hint'; private readonly _registration = this._register(new MutableDisposable()); @@ -117,9 +160,13 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa @ILanguageFeaturesService languageFeaturesService: ILanguageFeaturesService, @ISessionsService private readonly _sessionsService: ISessionsService, @IChatSessionsService chatSessionsService: IChatSessionsService, + @ICodeEditorService private readonly _codeEditorService: ICodeEditorService, + @IThemeService private readonly _themeService: IThemeService, ) { super(languageFeaturesService, chatSessionsService); + this._register(this._codeEditorService.registerDecorationType(AgentHostInputCompletionHandler._argumentHintDecorationDescription, AgentHostInputCompletionHandler._argumentHintDecorationType, {})); + this._decorations = this._editor.createDecorationsCollection(); this._registerDecorations(); @@ -367,6 +414,30 @@ export class AgentHostInputCompletionHandler extends AgentHostInputCompletionsBa } this._decorations.set(decos); + + this._editor.setDecorationsByType( + AgentHostInputCompletionHandler._argumentHintDecorationDescription, + AgentHostInputCompletionHandler._argumentHintDecorationType, + this._getArgumentHintDecorations(model, value), + ); + } + + /** + * Computes the inline placeholder (ghost text) shown after an accepted + * agent-host slash command whose `_meta` carries an argument hint. Shown + * only while the command is the sole content followed by a single trailing + * space (i.e. before any argument has been typed). + */ + private _getArgumentHintDecorations(model: ITextModel, value: string): IDecorationOptions[] { + const placeholder = getCommandArgumentHintPlaceholder(value, this._contextAttachments.attachments, this._insertedReferences); + if (!placeholder) { + return []; + } + const endPos = model.getPositionAt(placeholder.endOffset); + return [{ + range: getRangeForPlaceholder({ startLineNumber: endPos.lineNumber, endLineNumber: endPos.lineNumber, startColumn: endPos.column, endColumn: endPos.column }), + renderOptions: { after: { contentText: placeholder.argumentHint, color: getInputPlaceholderColor(this._themeService) } } + }]; } private _toOffsetRange(range: Range, insertText: string): OffsetRange | undefined { diff --git a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts index 713297bde54756..cd5320bf480e89 100644 --- a/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts +++ b/src/vs/sessions/contrib/chat/browser/aiCustomizationWorkspaceService.ts @@ -7,9 +7,8 @@ import { derived, IObservable, observableValue, ISettableObservable } from '../. import { relativePath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; -import { IAICustomizationWorkspaceService, AICustomizationManagementSection, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { CustomizationCreatorService } from '../../../../workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.js'; @@ -47,7 +46,6 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization @ISessionsService private readonly sessionsService: ISessionsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IPromptsService private readonly promptsService: IPromptsService, - @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @ICommandService private readonly commandService: ICommandService, @ILogService private readonly logService: ILogService, @IFileService private readonly fileService: IFileService, @@ -101,6 +99,7 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization AICustomizationManagementSection.Skills, AICustomizationManagementSection.Instructions, AICustomizationManagementSection.Hooks, + AICustomizationManagementSection.Automations, AICustomizationManagementSection.McpServers, AICustomizationManagementSection.Plugins, AICustomizationManagementSection.Tools, @@ -264,11 +263,7 @@ export class SessionsAICustomizationWorkspaceService implements IAICustomization } async getFilteredPromptSlashCommands(token: CancellationToken): Promise<readonly IChatPromptSlashCommand[]> { - const allCommands = await this.promptsService.getPromptSlashCommands(token); - return allCommands.filter(cmd => { - const filter = this.harnessService.getActiveDescriptor().getStorageSourceFilter(cmd.type); - return applyStorageSourceFilter([cmd], filter).length > 0; - }); + return await this.promptsService.getPromptSlashCommands(token); } private static readonly _skillUIIntegrations: ReadonlyMap<string, string> = new Map([ diff --git a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts index 8a39785dd68aa3..8d1fc08fd76e4b 100644 --- a/src/vs/sessions/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/chat.contribution.ts @@ -35,6 +35,7 @@ import { AccessibleViewRegistry } from '../../../../platform/accessibility/brows import { SessionsChatAccessibilityHelp } from './sessionsChatAccessibilityHelp.js'; import { SessionsOpenerParticipantContribution } from './sessionsOpenerParticipant.js'; import { WorktreeCreatedTaskDispatcher, AGENT_HOST_RUN_WORKTREE_CREATED_TASKS_SETTING } from './worktreeCreatedTaskDispatcher.js'; +import { LastTurnChangesMultiDiffSourceResolverContribution } from './lastTurnChangesMultiDiffSourceResolver.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import '../../sessions/browser/mobile/mobileOverlayContribution.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; @@ -49,7 +50,7 @@ class NewChatInSessionsWindowAction extends Action2 { constructor() { super({ id: NEW_SESSION_ACTION_ID, - title: localize2('chat.newEdits.label', "New Chat"), + title: localize2('sessions.newSession.label', "New Session"), category: CHAT_CATEGORY, f1: true, keybinding: { @@ -86,11 +87,13 @@ class NewChatInSessionsWindowAction extends Action2 { override run(accessor: ServicesAccessor): void { const sessionsService = accessor.get(ISessionsService); - // Inherit the active session's provider and session type so the new - // session defaults to the same kind the user is currently working in. const activeSession = sessionsService.activeSession.get(); + // A quick chat never contributes its folder — it is workspace-less by + // intent (any scratch working directory must not seed the workspace + // composer), so it always falls to the New Session composer's folder picker. + const isQuickChat = activeSession?.isQuickChat?.get() ?? false; sessionsService.openNewSession({ - folderUri: activeSession?.workspace.get()?.uri, + folderUri: isQuickChat ? undefined : activeSession?.workspace.get()?.uri, providerId: activeSession?.providerId, sessionTypeId: activeSession?.sessionType, }); @@ -108,6 +111,7 @@ registerWorkbenchContribution2(RunScriptContribution.ID, RunScriptContribution, registerWorkbenchContribution2(SessionsOpenerParticipantContribution.ID, SessionsOpenerParticipantContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(RegisterDefaultSessionTaskRunnersContribution.ID, RegisterDefaultSessionTaskRunnersContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(WorktreeCreatedTaskDispatcher.ID, WorktreeCreatedTaskDispatcher, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(LastTurnChangesMultiDiffSourceResolverContribution.ID, LastTurnChangesMultiDiffSourceResolverContribution, WorkbenchPhase.BlockRestore); // register services registerSingleton(IPromptsService, AgenticPromptsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/browser/chatView.ts b/src/vs/sessions/contrib/chat/browser/chatView.ts index 0c3f050e1599eb..d36beeb971f6d6 100644 --- a/src/vs/sessions/contrib/chat/browser/chatView.ts +++ b/src/vs/sessions/contrib/chat/browser/chatView.ts @@ -5,6 +5,7 @@ import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -18,11 +19,13 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/c import { getChatSessionType } from '../../../../workbench/contrib/chat/common/model/chatUri.js'; import { IChatSessionsService, localChatSessionType } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { AbstractChatView, ChatViewKind, IChatViewOptions } from '../../../browser/parts/chatView.js'; -import { IChat } from '../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat } from '../../../services/sessions/common/session.js'; import { IChatViewFactory } from '../../../services/chatView/browser/chatViewFactory.js'; import { NewChatWidget } from './newChatWidget.js'; import { NewChatInSessionWidget } from './newChatInSessionWidget.js'; import { SessionInputBanners } from '../../sessionInputBanners/browser/sessionInputBanners.js'; +import { SessionRunningSubagentsControl } from './sessionRunningSubagentsControl.js'; +import { SessionChatInputToolbar } from './sessionChatInputToolbar.js'; import { AGENT_SESSIONS_SCOPED_INPUT_HISTORY_SETTING } from './sessionsChatHistory.js'; import { activeSessionViewBackground, activeSessionViewForeground, agentsPanelBackground, inactiveSessionViewBackground, inactiveSessionViewForeground } from '../../../common/theme.js'; import { isEqual } from '../../../../base/common/resources.js'; @@ -104,6 +107,10 @@ export class ChatView extends AbstractChatView { /** Session banners (CI failures, created comments) shown above the chat input. */ private readonly _banners: SessionInputBanners; + /** Ephemeral chip above the input listing the active chat's running subagents. */ + private readonly _runningSubagents: SessionRunningSubagentsControl; + /** Floating status pills (changes, preview) above the input; agent host only. */ + private readonly _chatPills: SessionChatInputToolbar; /** Reference to the loaded chat model; disposing releases the model. */ private readonly _modelRef = this._register(new MutableDisposable<IChatModelReference>()); @@ -111,6 +118,9 @@ export class ChatView extends AbstractChatView { /** Cancels any in-flight model load when a new session is set or the view disposes. */ private readonly _loadCts = this._register(new MutableDisposable<CancellationTokenSource>()); + /** Tracks the current chat's interactivity and hides the input for read-only chats. */ + private readonly _interactiveDisposable = this._register(new MutableDisposable()); + /** Tracks the currently loaded chat resource to avoid redundant reloads. */ private _currentChatResource: URI | undefined; private _historyKey: string | undefined; @@ -161,6 +171,12 @@ export class ChatView extends AbstractChatView { // Mount the session banners directly above the chat input. this._banners = this._register(instantiationService.createInstance(SessionInputBanners)); this._banners.setActive(this._isActive); + + // Ephemeral running-subagents chip above the input (hidden while idle). + this._runningSubagents = this._register(instantiationService.createInstance(SessionRunningSubagentsControl)); + // Floating status pills above the input (hidden unless an agent host session + // has changes or a previewable file). + this._chatPills = this._register(instantiationService.createInstance(SessionChatInputToolbar)); this._ensureBannersMounted(); this._register(this.configurationService.onDidChangeConfiguration(e => { @@ -195,6 +211,20 @@ export class ChatView extends AbstractChatView { this._historyKey = historyKey; this._applyHistoryKey(); + // Monitor this chat's running subagents in the ephemeral chip. + this._runningSubagents.setChat(resource); + + // Reflect this chat's last-turn changes and status in the floating pills. + this._chatPills.setChat(chat); + + // Reflect read-only (non-interactive) chats: hide the composer and gate + // mutating actions (Start Over / Restore Checkpoint) via the widget. Any + // non-Full interactivity is treated as read-only here (hidden chats are + // filtered out of the visible model before they reach a ChatView). + this._interactiveDisposable.value = autorun(reader => { + this._widget.setReadOnly(chat.interactivity.read(reader) !== ChatInteractivity.Full); + }); + // Skip loading if we're already showing this chat if (isEqual(this._currentChatResource, resource)) { return; @@ -279,16 +309,26 @@ export class ChatView extends AbstractChatView { } /** - * Mounts the session banners as the first child of the chat input part, so - * they render above the input alongside the other above-input widgets - * (notifications, goal banner, etc.). Idempotent — re-runs cheaply on layout - * to recover if the chat widget rebuilds its input part DOM. + * Mounts the running-subagents chip and the session banners above the chat + * input, as the first children of the input part (the chip sits directly above + * the banners). Idempotent — re-runs cheaply on layout to recover if the chat + * widget rebuilds its input part DOM. */ private _ensureBannersMounted(): void { const inputPartElement = this._widget.inputPart.element; - const node = this._banners.domNode; - if (inputPartElement.firstChild !== node) { - inputPartElement.insertBefore(node, inputPartElement.firstChild); + const pillsNode = this._chatPills.element; + const subagentsNode = this._runningSubagents.element; + const bannersNode = this._banners.domNode; + // Desired order at the top of the input part: [pillsNode, subagentsNode, bannersNode, ...]. + // Keyed off the pills first so this is a true no-op once the DOM has settled. + if (inputPartElement.firstChild !== pillsNode) { + inputPartElement.insertBefore(pillsNode, inputPartElement.firstChild); + } + if (pillsNode.nextSibling !== subagentsNode) { + inputPartElement.insertBefore(subagentsNode, pillsNode.nextSibling); + } + if (subagentsNode.nextSibling !== bannersNode) { + inputPartElement.insertBefore(bannersNode, subagentsNode.nextSibling); } } diff --git a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts index 7a897b98f8bd64..b7bcb645d6dc5f 100644 --- a/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts +++ b/src/vs/sessions/contrib/chat/browser/customizationsDebugLog.contribution.ts @@ -9,12 +9,11 @@ import { autorun } from '../../../../base/common/observable.js'; import { ILogger, ILoggerService } from '../../../../platform/log/common/log.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; -import { IAICustomizationWorkspaceService, IStorageSourceFilter, applyStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; const PROMPT_SECTIONS: { section: AICustomizationManagementSection; type: PromptsType }[] = [ { section: AICustomizationManagementSection.Agents, type: PromptsType.agent }, @@ -34,8 +33,7 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc @IPromptsService private readonly _promptsService: IPromptsService, @IAICustomizationWorkspaceService private readonly _workspaceService: IAICustomizationWorkspaceService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, - @IMcpService private readonly _mcpService: IMcpService, - @ICustomizationHarnessService private readonly _harnessService: ICustomizationHarnessService + @IMcpService private readonly _mcpService: IMcpService ) { super(); this._logger = this._register(loggerService.createLogger('customizationsDebug', { name: 'Customizations Debug' })); @@ -72,7 +70,6 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc private async _doLogSnapshot(): Promise<void> { const root = this._workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'; - const descriptor = this._harnessService.getActiveDescriptor(); this._logger.info(''); this._logger.info('=== Customizations Snapshot ==='); @@ -85,16 +82,14 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc this._logger.info(` ${'--------'.padEnd(16)} ${'-----'.padStart(6)} ${'----'.padStart(6)} ${'---'.padStart(6)} ${'-----'.padStart(7)}`); for (const { section, type } of PROMPT_SECTIONS) { - const filter = descriptor.getStorageSourceFilter(type); - await this._logSectionRow(section, type, filter); + await this._logSectionRow(section, type); } this._logger.info(''); // Details per section for (const { section, type } of PROMPT_SECTIONS) { - const filter = descriptor.getStorageSourceFilter(type); - await this._logSectionDetails(section, type, filter); + await this._logSectionDetails(section, type); } // MCP Servers @@ -115,7 +110,7 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc this._logger.info(''); } - private async _logSectionRow(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise<void> { + private async _logSectionRow(section: AICustomizationManagementSection, type: PromptsType): Promise<void> { try { const [localFiles, userFiles, extensionFiles] = await Promise.all([ this._promptsService.listPromptFilesForStorage(type, PromptsStorage.local, CancellationToken.None), @@ -123,18 +118,17 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), ]); const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; - const filtered = applyStorageSourceFilter(all, filter); - const local = filtered.filter(f => f.storage === PromptsStorage.local).length; - const user = filtered.filter(f => f.storage === PromptsStorage.user).length; - const ext = filtered.filter(f => f.storage === PromptsStorage.extension).length; + const local = all.filter(f => f.storage === PromptsStorage.local).length; + const user = all.filter(f => f.storage === PromptsStorage.user).length; + const ext = all.filter(f => f.storage === PromptsStorage.extension).length; - this._logger.info(` ${section.padEnd(16)} ${String(local).padStart(6)} ${String(user).padStart(6)} ${String(ext).padStart(6)} ${String(filtered.length).padStart(7)}`); + this._logger.info(` ${section.padEnd(16)} ${String(local).padStart(6)} ${String(user).padStart(6)} ${String(ext).padStart(6)} ${String(all.length).padStart(7)}`); } catch { this._logger.info(` ${section.padEnd(16)} (error)`); } } - private async _logSectionDetails(section: AICustomizationManagementSection, type: PromptsType, filter: IStorageSourceFilter): Promise<void> { + private async _logSectionDetails(section: AICustomizationManagementSection, type: PromptsType): Promise<void> { try { // Source folders - where we look for files const sourceFolders = await this._promptsService.getSourceFolders(type); @@ -152,20 +146,18 @@ class CustomizationsDebugLogContribution extends Disposable implements IWorkbenc this._promptsService.listPromptFilesForStorage(type, PromptsStorage.extension, CancellationToken.None), ]); const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; - const filtered = applyStorageSourceFilter(all, filter); - if (filtered.length > 0) { + if (all.length > 0) { if (sourceFolders.length === 0) { this._logger.info(` -- ${section} --`); } - this._logger.info(` Filter: sources=[${filter.sources.join(', ')}]`); - this._logger.info(` Found ${filtered.length} item(s):`); - for (const f of filtered) { + this._logger.info(` Found ${all.length} item(s):`); + for (const f of all) { this._logger.info(` [${f.storage}] ${f.uri.fsPath}`); } } - if (sourceFolders.length > 0 || filtered.length > 0) { + if (sourceFolders.length > 0 || all.length > 0) { this._logger.info(''); } } catch { diff --git a/src/vs/sessions/contrib/chat/browser/lastTurnChangesMultiDiffSourceResolver.ts b/src/vs/sessions/contrib/chat/browser/lastTurnChangesMultiDiffSourceResolver.ts new file mode 100644 index 00000000000000..bb6cadc210a764 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/lastTurnChangesMultiDiffSourceResolver.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { constObservable, derivedObservableWithCache, ValueWithChangeEventFromObservable } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { IMultiDiffSourceResolver, IMultiDiffSourceResolverService, IResolvedMultiDiffSource, MultiDiffEditorItem } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; +import { ISessionFileChange } from '../../../services/sessions/common/session.js'; + +const LAST_TURN_CHANGES_MULTI_DIFF_SOURCE_SCHEME = 'chat-last-turn-changes-multi-diff-source'; + +interface ILastTurnChangesMultiDiffUriFields { + readonly chatResource: string; +} + +/** + * Resolves the multi-diff source that backs the chat input's changes pill. The + * pill is scoped to a single chat's **last turn**, so its editor is identified + * by a {@link LAST_TURN_CHANGES_MULTI_DIFF_SOURCE_SCHEME} URI carrying that + * chat's resource. Reusing the same URI reuses the same editor while the diff + * list updates reactively. + * + * The resolved resource list is derived live from the chat's + * `lastTurnChanges` observable. For every file changed in the last turn it + * shows the diff between: + * - the **first origin** resource found for that file (the snapshot of its + * before-content), used as the *original* side; and + * - the file **on disk** (its live resource), used as the *modified* side so the + * diff keeps updating as the agent writes further edits. + * + * Files are keyed by their on-disk resource so a file that appears in several + * edits is shown once, keeping the first origin it was seen with. The row for an + * already-seen file is reused as-is, so further edits to a known file don't + * rebuild the list — the diff source only changes when a new file appears (or an + * existing one drops out of the turn). + */ +export class LastTurnChangesMultiDiffSourceResolver extends Disposable implements IMultiDiffSourceResolver { + + /** + * Build the multi-diff source URI identifying the last-turn changes editor + * for a chat. + */ + static getMultiDiffSourceUri(chatResource: URI): URI { + return URI.from({ + scheme: LAST_TURN_CHANGES_MULTI_DIFF_SOURCE_SCHEME, + query: JSON.stringify({ chatResource: chatResource.toString() } satisfies ILastTurnChangesMultiDiffUriFields), + }); + } + + /** + * If the given URI identifies a last-turn changes editor (one built by + * {@link getMultiDiffSourceUri}), return the chat resource it belongs to; + * otherwise `undefined`. + */ + static parseUri(uri: URI): URI | undefined { + if (uri.scheme !== LAST_TURN_CHANGES_MULTI_DIFF_SOURCE_SCHEME) { + return undefined; + } + + let fields: ILastTurnChangesMultiDiffUriFields; + try { + fields = JSON.parse(uri.query) as ILastTurnChangesMultiDiffUriFields; + } catch { + return undefined; + } + + if (typeof fields !== 'object' || fields === null || typeof fields.chatResource !== 'string') { + return undefined; + } + + return URI.parse(fields.chatResource); + } + + constructor( + @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, + @IMultiDiffSourceResolverService multiDiffSourceResolverService: IMultiDiffSourceResolverService, + ) { + super(); + this._register(multiDiffSourceResolverService.registerResolver(this)); + } + + canHandleUri(uri: URI): boolean { + return LastTurnChangesMultiDiffSourceResolver.parseUri(uri) !== undefined; + } + + async resolveDiffSource(uri: URI): Promise<IResolvedMultiDiffSource> { + const chatResource = LastTurnChangesMultiDiffSourceResolver.parseUri(uri)!; + + // The chat's resource is fixed for this editor, so resolve the owning chat + // once here rather than re-finding it on every observable read. + const chat = this._sessionsManagementService.getSessionForChatResource(chatResource)?.chat; + const lastTurnChanges = chat?.lastTurnChanges ?? constObservable<readonly ISessionFileChange[]>([]); + + // Reuse the row for a file we've already seen (keeping its first origin) and + // keep the previous array reference when no new file appears, so the diff + // source only changes when the set of changed files does — not on every edit + // that streams in for an already-known file. + const resourcesObs = derivedObservableWithCache<readonly MultiDiffEditorItem[]>(this, (reader, lastValue) => { + const changes = lastTurnChanges.read(reader); + + const previousByKey = new Map<string, MultiDiffEditorItem>(); + for (const item of lastValue ?? []) { + previousByKey.set(item.modifiedUri!.toString(), item); + } + + const items: MultiDiffEditorItem[] = []; + const seen = new Set<string>(); + let addedNewFile = false; + for (const change of changes) { + // The on-disk resource of the file: the live file whose current + // content is shown as the modified side of the diff. + const onDiskUri = isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; + if (!onDiskUri) { + continue; + } + const key = onDiskUri.toString(); + if (seen.has(key)) { + continue; + } + seen.add(key); + + const existing = previousByKey.get(key); + if (existing) { + items.push(existing); + } else { + // First edit for this file: capture its origin (original side). + items.push(new MultiDiffEditorItem(change.originalUri, onDiskUri, onDiskUri)); + addedNewFile = true; + } + } + + // Same set of files as before → keep the reference so downstream (and the + // multi-diff editor) doesn't recompute. + if (!addedNewFile && lastValue && items.length === lastValue.length) { + return lastValue; + } + return items; + }); + + return { resources: new ValueWithChangeEventFromObservable(resourcesObs) }; + } +} + +/** + * Instantiates the {@link LastTurnChangesMultiDiffSourceResolver} so it registers + * with the multi-diff source resolver service. Registered at + * {@link WorkbenchPhase.BlockRestore} so a previously open last-turn changes diff + * editor can resolve its contents during workbench restore. + */ +export class LastTurnChangesMultiDiffSourceResolverContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.lastTurnChangesMultiDiffSourceResolver'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + this._register(instantiationService.createInstance(LastTurnChangesMultiDiffSourceResolver)); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css index 1d72f7fd105ced..e06bb9be4c0b03 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatInput.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -128,7 +128,6 @@ display: flex; align-items: center; padding: 4px 6px 6px 6px; - gap: 4px; color: var(--vscode-icon-foreground); } @@ -137,7 +136,7 @@ } /* Model picker - uses workbench ModelPickerActionItem */ -/* Session config toolbar (mode, model pickers via MenuWorkbenchToolBar) */ +/* Session config toolbar (model picker via MenuWorkbenchToolBar) */ .sessions-chat-config-toolbar { display: flex; align-items: center; @@ -174,7 +173,6 @@ display: flex; align-items: center; height: 16px; - padding: 3px 7px; background-color: transparent; border: none; border-radius: var(--vscode-cornerRadius-small); @@ -198,7 +196,7 @@ /* Allow long labels (e.g. the model picker name) to ellipsize when space is tight */ .sessions-chat-config-toolbar .action-label .chat-input-picker-label { - margin-left: 6px; + margin-left: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -230,9 +228,9 @@ height: 12px; } -/* Spacing between action items inside the chat input config toolbar (mode + model picker) */ +/* Spacing between action items inside the chat input config toolbar */ .sessions-chat-config-toolbar .monaco-action-bar .actions-container { - gap: 4px; + gap: 2px; } /* Send button - wraps a Button widget */ diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 9385480d7eeb74..7d021fe32e28ab 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -69,6 +69,22 @@ margin-bottom: 0; } +/* Quick chat composer: no workspace to pick, so hide the picker row entirely. */ +.new-chat-widget-content.quick-chat .new-session-workspace-picker-container { + display: none; +} + +/* Quick chat composer header: the "New Chat" + inline session-type picker row. + * Hidden unless the composer is in quick-chat mode. Scoped under the content so + * it outranks the base `.session-workspace-picker { display: flex }` rule. */ +.new-chat-widget-content .new-session-quick-chat-header { + display: none; +} + +.new-chat-widget-content.quick-chat .new-session-quick-chat-header { + display: flex; +} + .new-chat-widget-content { width: 100%; max-width: 800px; @@ -87,7 +103,7 @@ display: none; flex-direction: row; align-items: center; - gap: 4px; + gap: 2px; min-height: 28px; justify-content: space-between; } @@ -98,11 +114,16 @@ .new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container { display: flex; - gap: 4px; + gap: 2px; min-width: 0; overflow: hidden; } +/* Collapse the controls-row picker host when empty so it adds no flex gap. */ +.new-chat-widget-container .new-chat-bottom-container .new-chat-session-type-picker-host:empty { + display: none; +} + .new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container { display: flex; align-items: center; @@ -173,21 +194,23 @@ /* Spacing between action items inside the bottom-row toolbars (e.g. Worktree, branch) */ .new-chat-widget-container .new-chat-bottom-container .monaco-action-bar .actions-container { - gap: 4px; + gap: 2px; } /* Match VS Code chat-secondary-toolbar sizing for action items in the bottom row * (e.g. session control pickers and repo config pickers) so they align with the - * mode/model picker font in the input toolbar. */ + * model picker font in the input toolbar. */ .new-chat-widget-container .new-chat-bottom-container .action-label { height: 16px; - padding: 3px 6px; + padding: 3px 8px; font-size: var(--vscode-agents-fontSize-label2, 11px); color: var(--vscode-icon-foreground); } .new-chat-widget-container .new-chat-bottom-container .action-label > .codicon { font-size: var(--vscode-codiconFontSize-compact, 12px); + width: auto; + height: auto; } .new-chat-widget-container .new-chat-bottom-container .action-label > .codicon-chevron-down, @@ -200,18 +223,6 @@ display: none; } - -.new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container > * + *, -.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container .monaco-action-bar .actions-container > .action-item + .action-item, -.new-chat-widget-container .sessions-chat-config-toolbar .monaco-action-bar .actions-container > .action-item + .action-item, -.new-chat-widget-container .sessions-chat-attach-button + .sessions-chat-config-toolbar { - box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); -} - -.new-chat-widget-container .sessions-chat-attach-button + .sessions-chat-config-toolbar { - padding-left: 4px; -} - /* Icon-only action items in the bottom row (e.g. OpenTelemetry status pill) * lack the chevron that visually balances the 7px left padding, so use * symmetric horizontal padding and drop the picker min-width that would @@ -378,7 +389,7 @@ } } -.sessions-chat-picker-slot .action-label .codicon { +.interactive-input-part .sessions-chat-picker-slot .action-label .codicon { font-size: var(--vscode-codiconFontSize-compact, 12px); flex-shrink: 0; /* Override the base .monaco-action-bar .action-item .codicon { width:16px; height:16px } @@ -410,32 +421,20 @@ .sessions-chat-picker-slot .action-label.warning { color: var(--vscode-problemsWarningIcon-foreground); - opacity: 0.75; } .sessions-chat-picker-slot .action-label.warning .codicon { color: var(--vscode-problemsWarningIcon-foreground) !important; } -.sessions-chat-picker-slot .action-label.warning:hover { - color: var(--vscode-problemsWarningIcon-foreground); - opacity: 1; -} - .sessions-chat-picker-slot .action-label.info { color: var(--vscode-problemsInfoIcon-foreground); - opacity: 0.75; } .sessions-chat-picker-slot .action-label.info .codicon { color: var(--vscode-problemsInfoIcon-foreground) !important; } -.sessions-chat-picker-slot .action-label.info:hover { - color: var(--vscode-problemsInfoIcon-foreground); - opacity: 1; -} - /* Tab bar styles live with the platform widget * (`platform/actionWidget/browser/tabbedActionListWidget.css`). The * `sessions-workspace-picker-tabbar` class is applied alongside the diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css new file mode 100644 index 00000000000000..b54386a4968a9e --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/sessionChatInputToolbar.css @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Floating status pills centered above the chat input. The pills themselves are + the shared `.chat-turn-pills` widget (styled in chatTurnPills.css); this file + only positions and centers that widget above the input. */ + +.session-chat-input-toolbar { + display: flex; + justify-content: center; + padding: 2px 0 6px 0; +} + +.session-chat-input-toolbar.hidden { + display: none; +} + diff --git a/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css b/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css new file mode 100644 index 00000000000000..66a0d69a549f9a --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/sessionRunningSubagentsControl.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.session-running-subagents { + display: flex; + padding: 4px 0 2px 0; +} + +.session-running-subagents.hidden { + display: none; +} + +.session-running-subagents .session-running-subagents-button { + width: fit-content; + padding: 2px 8px; + font-size: var(--vscode-agents-fontSize-label2); +} + +.session-running-subagents .session-running-subagents-button .codicon { + font-size: var(--vscode-codiconFontSize-compact); +} diff --git a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts index 89fe9a440a6eab..91eb2f95c62bcb 100644 --- a/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts +++ b/src/vs/sessions/contrib/chat/browser/mobile/mobileWorkspacePickerSheet.ts @@ -6,7 +6,7 @@ import { ActionListItemKind, IActionListItem } from '../../../../../platform/actionWidget/browser/actionList.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { isPhoneLayout } from '../../../../browser/parts/mobile/mobileLayout.js'; -import { IMobilePickerSheetHeaderAction, IMobilePickerSheetItem, IMobilePickerSheetSearchSource, MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX, showMobilePickerSheet } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; +import { IMobilePickerSheetHeaderAction, IMobilePickerSheetItem, IMobilePickerSheetSearchSource, MOBILE_PICKER_SHEET_CONFIRM, MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX, showMobilePickerSheet } from '../../../../browser/parts/mobile/mobilePickerSheet.js'; import { localize } from '../../../../../nls.js'; import { IWorkspacePickerItem } from '../sessionWorkspacePicker.js'; import { SubmenuAction, IAction } from '../../../../../base/common/actions.js'; @@ -28,6 +28,12 @@ type MobilePickerRow = { readonly run: () => void; }; +type BrowsedFolder = { + readonly query: string; + readonly label: string; + readonly run: () => void; +}; + /** * Translates the action-widget items produced by * {@link WorkspacePicker._buildItems} (and its subclasses) into rows for @@ -192,6 +198,7 @@ export async function showMobileWorkspacePickerSheet( // the sheet can resolve folder taps back to a provider selection. const folderRunById = new Map<string, () => void>(); const folderLabelById = new Map<string, string>(); + let currentFolder: BrowsedFolder | undefined; // Track the current search query so drill-down can append to it. let currentSearchQuery = ''; const search: IMobilePickerSheetSearchSource | undefined = inlineFolderActions.length > 0 @@ -231,22 +238,30 @@ export async function showMobileWorkspacePickerSheet( label: entry.workspace.label, description: entry.workspace.description, icon: entry.workspace.icon, + navigates: true, }); }); return sheetItems; }, + // The pinned "Select this folder" action follows the folder + // we drilled into (when the live query still matches it), + // instead of mixing a confirm row into the navigable list. + getPrimaryAction: (query) => { + if (currentFolder?.query !== query) { + return undefined; + } + const folder = currentFolder; + return { + label: localize('mobileWorkspacePicker.selectCurrentFolder', "Select '{0}'", folder.label), + icon: Codicon.arrowRight, + run: folder.run, + }; + }, } : undefined; triggerElement.setAttribute('aria-expanded', 'true'); - // Track the last-tapped folder from search results so Done can - // dispatch it. In `stayOpenOnSelect` mode, row taps don't close - // the sheet — instead they apply the selection and let the user - // browse further. The workspace-picker-specific rows (recents) - // dispatch immediately on tap since those are confirmed choices. - let lastSearchFolderRun: (() => void) | undefined; - try { await showMobilePickerSheet( layoutService.mainContainer, @@ -257,6 +272,7 @@ export async function showMobileWorkspacePickerSheet( search, caption: localize('mobileWorkspacePicker.caption', "Search to browse folders on the host"), stayOpenOnSelect: true, + doneLabel: localize('mobileWorkspacePicker.cancel', "Cancel"), onDidSelect: (id) => { if (id.startsWith(MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX)) { const idx = Number(id.slice(MOBILE_PICKER_SHEET_HEADER_ACTION_PREFIX.length)); @@ -264,10 +280,12 @@ export async function showMobileWorkspacePickerSheet( return; } if (id.startsWith(SEARCH_RESULT_ID_PREFIX)) { - lastSearchFolderRun = folderRunById.get(id); - // Drill down: build a path query from the - // current query prefix + this folder's name, - // e.g. "projects/" → "projects/subfolder/". + // Drill down: build a path query from the current + // query prefix + this folder's name, e.g. + // "projects/" → "projects/subfolder/". Tapping a + // folder navigates into it; the pinned "Open this + // folder" action confirms the current level. + const run = folderRunById.get(id); const folderName = folderLabelById.get(id); if (folderName) { // Compute the prefix up to (and including) @@ -275,25 +293,26 @@ export async function showMobileWorkspacePickerSheet( // append the tapped folder name + `/`. const lastSlash = currentSearchQuery.lastIndexOf('/'); const prefix = lastSlash >= 0 ? currentSearchQuery.slice(0, lastSlash + 1) : ''; - return `${prefix}${folderName}/`; + const query = `${prefix}${folderName}/`; + if (run) { + currentFolder = { query, label: folderName, run }; + } + return query; } return; } - // Recent workspace row — dispatch immediately (it - // sets the workspace on the session). + // Recent workspace row — dispatch immediately and + // close (recents are confirmed choices). const row = rows.find(r => r.sheetItem.id === id); if (row) { row.run(); - lastSearchFolderRun = undefined; + currentFolder = undefined; + return MOBILE_PICKER_SHEET_CONFIRM; } return; }, }, ); - - // Done was tapped — if the last selection was a search folder, - // dispatch it now. Recent rows were already dispatched on tap. - lastSearchFolderRun?.(); } finally { triggerElement.setAttribute('aria-expanded', 'false'); triggerElement.focus(); diff --git a/src/vs/sessions/contrib/chat/browser/modelPicker.ts b/src/vs/sessions/contrib/chat/browser/modelPicker.ts index 67330314444340..ac5e300d14a956 100644 --- a/src/vs/sessions/contrib/chat/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/modelPicker.ts @@ -106,6 +106,7 @@ export class ModelPicker extends Disposable { constructor( private readonly _session: IObservable<IActiveSession | undefined>, + compact: IObservable<boolean>, @IInstantiationService instantiationService: IInstantiationService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, @@ -145,10 +146,17 @@ export class ModelPicker extends Disposable { showUnavailableFeatured: () => getModelPickerOptionsForSession(this._session.get(), this._sessionsProvidersService).showUnavailableFeatured, showFeatured: () => getModelPickerOptionsForSession(this._session.get(), this._sessionsProvidersService).showFeatured, showAutoModel: () => !!getModelPickerOptionsForSession(this._session.get(), this._sessionsProvidersService).showAutoModel, + isCacheWarm: () => { + const session = this._session.get(); + // The session's prompt cache is warm once its first request has + // been sent (status leaves Untitled), matching the main-window + // picker which warms as soon as the first request is added. + return session ? session.status.get() !== SessionStatus.Untitled : false; + }, }; const pickerOptions: IChatInputPickerOptions = { - compact: observableValue('compact', false), + compact, }; const action = { id: 'sessions.modelPicker', label: '', enabled: true, class: undefined, tooltip: '', run: () => { } }; this._modelPicker = this._register(instantiationService.createInstance(ModelPickerActionItem, action, this._delegate, pickerOptions)); diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts index 6262b53ed2e163..a2804e5c25dbba 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatInput.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -64,7 +64,7 @@ import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/c import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; -import { autorun, IObservable } from '../../../../base/common/observable.js'; +import { autorun, IObservable, observableValue } from '../../../../base/common/observable.js'; import { ChatInputNotificationWidget } from '../../../../workbench/contrib/chat/browser/widget/input/chatInputNotificationWidget.js'; import { INewChatModelPickerService, NewChatModelPickerService } from './newChatModelPicker.js'; import { ModelPicker, ModelPickerActionViewItem } from './modelPicker.js'; @@ -232,6 +232,7 @@ function getRandomChatInputPlaceholder(): string { // #region --- New Chat Widget --- export class NewChatInputWidget extends Disposable implements IHistoryNavigationWidget { + private static readonly compactModelPickerWidth = 280; readonly sessionTypePicker: SessionTypePicker; @@ -264,6 +265,7 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation private _slashCommandHandler: SlashCommandHandler | undefined; private _agentHostInputCompletionHandler: AgentHostInputCompletionHandler | undefined; private readonly _scopedInstantiationService: IInstantiationService; + private readonly _compactModelPicker = observableValue(this, false); // Input state private _draftState: IDraftState | undefined = { @@ -376,7 +378,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); const newChatControlsContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-controls-container')); if (this.options.renderSessionTypePickerInControls !== false) { - this.sessionTypePicker.render(newChatControlsContainer); + const sessionTypePickerHost = dom.append(newChatControlsContainer, dom.$('.new-chat-session-type-picker-host')); + this.sessionTypePicker.render(sessionTypePickerHost); } this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, dom.append(newChatControlsContainer, dom.$('')), Menus.NewSessionControl, { hiddenItemStrategy: HiddenItemStrategy.NoHide, @@ -614,14 +617,14 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation this._createAttachButton(toolbar); - // Session config pickers (mode, model) — rendered via MenuWorkbenchToolBar + // Session config pickers (such as model) — rendered via MenuWorkbenchToolBar // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); this._register(this._scopedInstantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { hiddenItemStrategy: HiddenItemStrategy.NoHide, actionViewItemProvider: (action) => { if (action.id === 'sessions.modelPicker') { - const picker = this._scopedInstantiationService.createInstance(ModelPicker, this.options.session); + const picker = this._scopedInstantiationService.createInstance(ModelPicker, this.options.session, this._compactModelPicker); return new ModelPickerActionViewItem(picker); } return undefined; @@ -808,7 +811,8 @@ export class NewChatInputWidget extends Disposable implements IHistoryNavigation } } - layout(_height: number, _width: number): void { + layout(_height: number, width: number): void { + this._compactModelPicker.set(width < NewChatInputWidget.compactModelPickerWidth, undefined); this._editor?.layout(); } diff --git a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts index 0fa67ad3cf466d..e5e35fe0c610af 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatWidget.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatWidget.ts @@ -6,7 +6,7 @@ import './media/chatWidget.css'; import * as dom from '../../../../base/browser/dom.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { constObservable, derived, derivedObservableWithCache, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, autorun, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -59,6 +59,15 @@ export class NewChatWidget extends Disposable { private readonly _session: IObservable<IActiveSession | undefined>; + /** Whether the active draft is a workspace-less quick chat (hides the workspace picker). */ + private readonly _isQuickChatComposer: IObservable<boolean>; + + /** The workspace-row container hosting the inline harness picker (desktop, non-quick-chat). */ + private _workspacePickerRow: HTMLElement | undefined; + + /** The quick-chat header row hosting the inline harness picker (desktop, quick chat). */ + private _quickChatHeaderPickerHost: HTMLElement | undefined; + /** * Tracks whether the workspace picker is currently rendered (vs replaced by * the no-agent-host empty state on web). Consumed by the new-session-view @@ -99,6 +108,13 @@ export class NewChatWidget extends Disposable { return activeSession; }); + // A quick chat is workspace-less; the composer hides the workspace picker + // (nothing to pick) and surfaces the session-type picker in the controls. + this._isQuickChatComposer = derived(this, reader => { + const session = this._session.read(reader); + return session?.isQuickChat?.read(reader) ?? false; + }); + const canSendRequest = derived(reader => { const session = this._session.read(reader); if (!session) { @@ -141,7 +157,14 @@ export class NewChatWidget extends Disposable { await this._onWorkspaceSelected(folderUri); this._newChatInput.focus(); })); - this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async () => { + this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async pick => { + // A quick chat has no folder: re-create the draft with the picked + // type via openQuickChat (mirrors the folder path's draft recreation). + if (this._isQuickChatComposer.get()) { + this.sessionsService.openQuickChat(pick ? { providerId: pick.providerId, sessionTypeId: pick.sessionTypeId } : undefined); + this._newChatInput.focus(); + return; + } await this._onWorkspaceSelected(this._workspacePicker.selectedFolderUri); this._newChatInput.focus(); })); @@ -167,19 +190,81 @@ export class NewChatWidget extends Disposable { ? this._renderEmptyStateGate(workspacePickerContainer, chatWidgetContent) : this._renderWorkspacePicker(workspacePickerContainer)); + // Quick-chat composer header (workspace-less): a top-of-input "New Chat" + // label plus the inline session-type picker. Shown only in quick-chat + // mode via the `.quick-chat` class on the content (see CSS). On web the + // composer is never a quick chat, so it stays empty/hidden there. + if (!isWeb && !this._renderHarnessPickerInControls) { + const quickChatHeaderRow = dom.append(chatWidgetContent, dom.$('.new-session-quick-chat-header.session-workspace-picker')); + const quickChatHeaderLabel = dom.append(quickChatHeaderRow, dom.$('.session-workspace-picker-label')); + quickChatHeaderLabel.textContent = localize('newChatHeader', "New Chat"); + const quickChatWithLabel = dom.append(quickChatHeaderRow, dom.$('.session-workspace-picker-label.session-workspace-picker-with-label')); + quickChatWithLabel.textContent = localize('newSessionWith', "with"); + this._quickChatHeaderPickerHost = dom.append(quickChatHeaderRow, dom.$('.new-chat-quick-chat-header-picker-host')); + } + this._newChatInput.render(chatWidgetContent, parent); + // Quick chat composer: hide the workspace picker for workspace-less + // drafts (there is nothing to pick) and reflect it in the picker-visible + // context key. Quick chats are only created on desktop (the local agent + // host), so leave the web empty-state gate's key management untouched. + this._register(autorun(reader => { + const isQuickChat = this._isQuickChatComposer.read(reader); + chatWidgetContent.classList.toggle('quick-chat', isQuickChat); + if (!isWeb) { + this._workspacePickerVisibleKey.set(!isQuickChat); + } + })); + + // Desktop harness-picker placement: a quick chat renders the session-type + // picker in its top-of-input header row; otherwise (including after a + // Cmd+N swap out of a quick chat) it re-parents into the workspace row. + if (!isWeb && !this._renderHarnessPickerInControls) { + this._register(autorun(reader => { + const isQuickChat = this._isQuickChatComposer.read(reader); + const target = isQuickChat ? this._quickChatHeaderPickerHost : this._workspacePickerRow; + if (target) { + this._newChatInput.sessionTypePicker.render(target, { className: 'sessions-chat-session-type-picker' }); + } + })); + } + // Create initial session for any workspace already selected at construct time. // If the selection arrives later (provider registers asynchronously), the // picker fires onDidSelectWorkspace and our listener handles it. // Skip if an active session already exists (restored by openNewSession // from a new-session draft when navigating back from another session). + this._seedWorkspaceDraft(); + + // Re-seed the workspace draft when the composer swaps out of quick-chat + // mode (e.g. Cmd+N discards a quick chat, leaving the reused composer + // session-less): without an active session the session-type picker has no + // folder types and hides itself, so restore the last folder to match a + // freshly-opened new-session composer. + if (!isWeb) { + let wasQuickChat = this._isQuickChatComposer.get(); + this._register(autorun(reader => { + const isQuickChat = this._isQuickChatComposer.read(reader); + if (wasQuickChat && !isQuickChat && !this._session.read(reader)) { + this._seedWorkspaceDraft(); + } + wasQuickChat = isQuickChat; + })); + } + + chatWidgetContainer.classList.add('revealed'); + } + + /** + * Seed the new-session draft from the workspace picker's restored folder, + * unless an active session already exists (then just sync the picker to it). + */ + private _seedWorkspaceDraft(): void { const restoredFolderUri = this._workspacePicker.selectedFolderUri; if (!this._syncWorkspacePickerFromActiveSession() && restoredFolderUri) { this._createNewSession(restoredFolderUri); } - - chatWidgetContainer.classList.add('revealed'); } /** @@ -299,7 +384,13 @@ export class NewChatWidget extends Disposable { if (!this._renderHarnessPickerInControls) { const withLabel = dom.append(pickersRow, dom.$('.session-workspace-picker-label.session-workspace-picker-with-label')); withLabel.textContent = localize('newSessionWith', "with"); - this._newChatInput.sessionTypePicker.render(pickersRow, { className: 'sessions-chat-session-type-picker' }); + this._workspacePickerRow = pickersRow; + // On web the composer is never a quick chat, so keep the harness + // picker inline in the workspace row. On desktop the placement is + // reactive (controls row for quick chats) — see the render() autorun. + if (isWeb) { + this._newChatInput.sessionTypePicker.render(pickersRow, { className: 'sessions-chat-session-type-picker' }); + } } return this._workspacePicker.onDidSelectWorkspace(() => { const folderUri = this._workspacePicker.selectedFolderUri; @@ -407,8 +498,10 @@ export class NewChatWidget extends Disposable { // Capture the composer's workspace selection before the send: a // background send consumes the in-flight new session and resets the // new-session view, so we re-seed a fresh pending session afterwards - // (see below) to keep the composer's pickers functional. - const reseedFolderUri = background ? this._workspacePicker.selectedFolderUri : undefined; + // (see below) to keep the composer's pickers functional. Quick chats + // have no workspace, so they re-seed via openQuickChat instead. + const wasQuickChat = this._isQuickChatComposer.get(); + const reseedFolderUri = background && !wasQuickChat ? this._workspacePicker.selectedFolderUri : undefined; try { await this.sessionsManagementService.sendNewChatRequest(session, { query, attachedContext, background }); @@ -422,8 +515,12 @@ export class NewChatWidget extends Disposable { // immediately — providers are multi-new-session aware, so the graduating // session and this new draft coexist. This restores the // session-type/model pickers for the next message. - if (background && reseedFolderUri) { - this._createNewSession(reseedFolderUri); + if (background) { + if (wasQuickChat) { + this.sessionsService.openQuickChat(); + } else if (reseedFolderUri) { + this._createNewSession(reseedFolderUri); + } } } diff --git a/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts new file mode 100644 index 00000000000000..d1f204704aab3b --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionChatInputToolbar.ts @@ -0,0 +1,201 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../base/browser/dom.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, derivedOpts, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { localize } from '../../../../nls.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { isIChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ChatTurnPillsWidget, diffStatsEqual, EMPTY_DIFF_STATS, IChatTurnPillsModel, IDiffStats, IPreviewFile, observeTurnStatusPillsConfig, openChatPreviewFile, previewFilesEqual, previewKind } from '../../../../workbench/contrib/chat/browser/widget/chatTurnPills.js'; +import { isAgentHostProviderId } from '../../../common/agentHostSessionsProvider.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IChat, SessionStatus } from '../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { LastTurnChangesMultiDiffSourceResolver } from './lastTurnChangesMultiDiffSourceResolver.js'; +import './media/sessionChatInputToolbar.css'; + +/** The per-turn data both pills reflect. */ +interface ITurnData { + readonly stats: IDiffStats; + /** Previewable files changed in the turn, primary (first) first. */ + readonly previewFiles: readonly IPreviewFile[]; +} + +const EMPTY_TURN_DATA: ITurnData = { stats: EMPTY_DIFF_STATS, previewFiles: [] }; + +/** + * Compute the current turn's diff stats and previewable files from the chat's + * last-turn changes ({@link IChat.lastTurnChanges}), which the provider derives + * from the live output stream. Files are classified as created vs. edited with + * the same rules as the Changes view (an addition has no original; a deletion + * has no modified resource). Created files are listed before edited ones so the + * primary (first) file is the first created one, falling back to the first + * edited one. Returns {@link EMPTY_TURN_DATA} when the chat exposes no last-turn + * changes (e.g. before its first turn, or a provider that can't determine them). + */ +function computeTurnData(chat: IChat, reader: IReader): ITurnData { + const changes = chat.lastTurnChanges?.read(reader) ?? []; + + let insertions = 0, deletions = 0; + const created: IPreviewFile[] = []; + const edited: IPreviewFile[] = []; + for (const change of changes) { + insertions += change.insertions; + deletions += change.deletions; + + if (change.modifiedUri === undefined) { + continue; // a deletion has nothing to preview + } + const uri = isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; + const kind = previewKind(uri); + if (!kind) { + continue; + } + const isCreated = change.originalUri === undefined; + (isCreated ? created : edited).push({ uri, kind, created: isCreated }); + } + + return { + stats: { files: changes.length, insertions, deletions }, + previewFiles: [...created, ...edited], + }; +} + +function turnDataEqual(a: ITurnData, b: ITurnData): boolean { + return diffStatsEqual(a.stats, b.stats) && previewFilesEqual(a.previewFiles, b.previewFiles); +} + +/** + * A floating toolbar shown above the chat input that surfaces the current turn's + * chat status as clickable pills (see {@link ChatTurnPillsWidget}). Only shown + * for agent host sessions while the viewed chat's turn is actively in progress; + * once the turn completes the pills disappear here and reappear inside the + * completed response. The pills are scoped to the viewed chat's last-turn changes + * so they reflect only what that chat's most recent request produced. + */ +export class SessionChatInputToolbar extends Disposable { + + readonly element: HTMLElement; + + /** Sentinel distinguishing "no override" from an explicit `undefined` session. */ + private readonly _sessionOverride = observableValue<IActiveSession | undefined | 'unset'>('sessionOverride', 'unset'); + /** The chat whose last-turn changes are reflected. */ + private readonly _chat = observableValue<IChat | undefined>('chat', undefined); + + /** The session that owns the reflected chat, from an explicit override or resolved from the chat. */ + private readonly _session: IObservable<IActiveSession | undefined> = derived(reader => { + const override = this._sessionOverride.read(reader); + if (override !== 'unset') { + return override; + } + const chat = this._chat.read(reader); + if (!chat) { + return undefined; + } + return this._findOwningSession(chat.resource, reader); + }); + + /** The current turn's diff stats and previewable files. */ + private readonly _turnData = derivedOpts<ITurnData>({ owner: this, equalsFn: turnDataEqual }, reader => { + const chat = this._chat.read(reader); + return chat ? computeTurnData(chat, reader) : EMPTY_TURN_DATA; + }); + + private readonly _diffStats = derivedOpts<IDiffStats>({ owner: this, equalsFn: diffStatsEqual }, reader => this._turnData.read(reader).stats); + private readonly _previewFiles = derivedOpts<readonly IPreviewFile[]>({ owner: this, equalsFn: previewFilesEqual }, reader => this._turnData.read(reader).previewFiles); + + /** Whether pills may show at all: an agent host session while the viewed chat's turn is streaming. */ + private readonly _active = derived(reader => { + const session = this._session.read(reader); + const chat = this._chat.read(reader); + return !!session && !!chat && isAgentHostProviderId(session.providerId) && chat.status.read(reader) === SessionStatus.InProgress; + }); + + constructor( + @ICommandService private readonly _commandService: ICommandService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IOpenerService private readonly _openerService: IOpenerService, + @ILogService private readonly _logService: ILogService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this.element = $('.session-chat-input-toolbar.hidden'); + + // Combine the active-turn gate with the per-pill visibility setting. + const pillsConfig = observeTurnStatusPillsConfig(this._configurationService); + const model: IChatTurnPillsModel = { + stats: this._diffStats, + previewFiles: this._previewFiles, + changesEnabled: derived(reader => this._active.read(reader) && pillsConfig.read(reader).changes), + previewEnabled: derived(reader => this._active.read(reader) && pillsConfig.read(reader).preview), + openChanges: () => this._openChanges(), + openPreviewFile: file => openChatPreviewFile(file, this._commandService, this._openerService, this._logService), + }; + + const pills = this._register(instantiationService.createInstance(ChatTurnPillsWidget, model)); + this.element.appendChild(pills.element); + + this._register(autorun(reader => { + this.element.classList.toggle('hidden', !pills.isVisible.read(reader)); + })); + } + + /** + * Track the currently-viewed chat; the toolbar reflects that chat's last-turn + * changes and status, resolving the owning session for provider gating and the + * open-changes action. Clears any explicit {@link setSession} override. + */ + setChat(chat: IChat | undefined): void { + this._sessionOverride.set('unset', undefined); + this._chat.set(chat, undefined); + } + + /** + * Explicitly set the session and chat to reflect, bypassing chat-to-session + * resolution. Intended for component fixtures and callers that already hold + * both. + */ + setSession(session: IActiveSession | undefined, chat: IChat | undefined): void { + this._sessionOverride.set(session, undefined); + this._chat.set(chat, undefined); + } + + private _findOwningSession(chatResource: URI, reader: IReader): IActiveSession | undefined { + for (const session of this._sessionsService.visibleSessions.read(reader)) { + if (session?.chats.read(reader).some(c => isEqual(c.resource, chatResource))) { + return session; + } + } + const active = this._sessionsService.activeSession.read(reader); + return active?.chats.read(reader).some(c => isEqual(c.resource, chatResource)) ? active : undefined; + } + + private async _openChanges(): Promise<void> { + const chat = this._chat.get(); + if (!chat) { + return; + } + // Open the multi-diff editor scoped to this chat's last turn. Its resource + // list is resolved reactively via the `LastTurnChangesMultiDiffSourceResolver` + // registered as a workbench contribution, so it live-updates as further + // edits stream in. + const multiDiffSource = LastTurnChangesMultiDiffSourceResolver.getMultiDiffSourceUri(chat.resource); + await this._editorService.openEditor({ + multiDiffSource, + label: localize('sessions.lastTurnChanges.title', "Last Turn Changes"), + }); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts b/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts new file mode 100644 index 00000000000000..9bd403e4da3eb7 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/sessionRunningSubagentsControl.ts @@ -0,0 +1,161 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../base/browser/dom.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { ChatOriginKind, IChat, SessionStatus } from '../../../services/sessions/common/session.js'; +import './media/sessionRunningSubagentsControl.css'; + +/** Max characters of a subagent's current-step text before it is ellipsized. */ +const STEP_MAX_LENGTH = 48; + +interface IRunningSubagent { + readonly chat: IChat; + /** The subagent's display title. */ + readonly title: string; + /** Short "current step" text (the subagent's live status description), if any. */ + readonly step: string | undefined; +} + +/** + * An ephemeral status chip shown above the chat input while the currently-viewed + * chat has **running** subagents. It gives an at-a-glance view of in-flight + * background workers. Activating it opens a menu of those subagents; selecting + * one reveals its read-only chat. The chip hides entirely when no subagent is + * running, so it adds no chrome while idle. + */ +export class SessionRunningSubagentsControl extends Disposable { + + readonly element: HTMLElement; + private readonly _button: Button; + + private readonly _disposables = this._register(new MutableDisposable<DisposableStore>()); + private _session: IActiveSession | undefined; + private _subagents: readonly IRunningSubagent[] = []; + + constructor( + @ISessionsService private readonly sessionsService: ISessionsService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + ) { + super(); + this.element = $('.session-running-subagents'); + this._button = this._register(new Button(this.element, { secondary: true, supportIcons: true, ...defaultButtonStyles })); + this._button.element.classList.add('session-running-subagents-button'); + this._register(this._button.onDidClick(() => this._onDidClick())); + this._setVisible(false); + } + + /** Track the currently-viewed chat; the chip monitors its running subagents. */ + setChat(chatResource: URI | undefined): void { + const store = new DisposableStore(); + this._disposables.value = store; + + if (!chatResource) { + this._update(undefined, []); + return; + } + + store.add(autorun(reader => { + const session = this._findOwningSession(chatResource, reader); + const subagents = session ? this._collectRunningSubagents(session, chatResource, reader) : []; + this._update(session, subagents); + })); + } + + private _findOwningSession(chatResource: URI, reader: IReader): IActiveSession | undefined { + for (const session of this.sessionsService.visibleSessions.read(reader)) { + if (session?.chats.read(reader).some(c => isEqual(c.resource, chatResource))) { + return session; + } + } + const active = this.sessionsService.activeSession.read(reader); + return active?.chats.read(reader).some(c => isEqual(c.resource, chatResource)) ? active : undefined; + } + + private _collectRunningSubagents(session: IActiveSession, chatResource: URI, reader: IReader): IRunningSubagent[] { + return session.chats.read(reader) + .filter(c => + c.origin?.kind === ChatOriginKind.Tool && + !!c.origin.parentChat && + isEqual(c.origin.parentChat, chatResource) && + c.status.read(reader) === SessionStatus.InProgress) + .map(chat => ({ + chat, + title: chat.title.read(reader) || localize('runningSubagents.untitled', "Subagent"), + step: this._stepText(chat, reader), + })); + } + + private _stepText(chat: IChat, reader: IReader): string | undefined { + const description = chat.description.read(reader)?.value.trim(); + if (!description) { + return undefined; + } + return description.length > STEP_MAX_LENGTH ? `${description.slice(0, STEP_MAX_LENGTH - 1)}\u2026` : description; + } + + private _update(session: IActiveSession | undefined, subagents: readonly IRunningSubagent[]): void { + this._session = session; + this._subagents = subagents; + + const count = subagents.length; + if (count === 1) { + // A single subagent: name it and surface its live progress inline. + const only = subagents[0]; + const label = only.step + ? localize('runningSubagents.singleWithStep', "Running subagent: {0} \u2014 {1}", only.title, only.step) + : localize('runningSubagents.single', "Running subagent: {0}", only.title); + this._button.label = `$(${Codicon.loading.id}~spin) ${label}`; + } else { + this._button.label = `$(${Codicon.loading.id}~spin) ${localize('runningSubagents.running', "{0} subagents running", count)}`; + } + this._setVisible(count > 0); + } + + private _onDidClick(): void { + const session = this._session; + if (!session || this._subagents.length === 0) { + return; + } + if (this._subagents.length === 1) { + this.sessionsService.openChat(session, this._subagents[0].chat.resource); + return; + } + this._showMenu(); + } + + private _showMenu(): void { + const session = this._session; + if (!session || this._subagents.length === 0) { + return; + } + const actions = this._subagents.map(({ chat, title, step }) => toAction({ + id: `runningSubagents.open.${chat.resource.toString()}`, + label: step ? `${title} \u2014 ${step}` : title, + class: ThemeIcon.asClassName(Codicon.loading), + run: () => this.sessionsService.openChat(session, chat.resource), + })); + this.contextMenuService.showContextMenu({ + getAnchor: () => this._button.element, + getActions: () => actions, + }); + } + + private _setVisible(visible: boolean): void { + this.element.classList.toggle('hidden', !visible); + } +} diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index fc7a8c14669ded..d85aa5021a4491 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -125,8 +125,7 @@ export class SessionTypePicker extends Disposable { const refresh = (session: ISession | undefined) => { if (session) { - const folderUri = session.workspace.get()?.folders[0]?.root; - this._folderSessionTypes = folderUri ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : []; + this._folderSessionTypes = this._sessionTypesForSession(session); // Reflect the active session's type in the trigger label, but do // not persist it: the stored preference must only change when the // user explicitly picks a type via the picker. @@ -155,6 +154,18 @@ export class SessionTypePicker extends Disposable { return this._picked; } + /** + * The session types to offer for a session: all quick-chat types when the + * session is a workspace-less quick chat, otherwise the folder's types. + */ + private _sessionTypesForSession(session: ISession): IProviderSessionType[] { + if (session.isQuickChat?.get() ?? false) { + return this.sessionsManagementService.getQuickChatSessionTypes(); + } + const folderUri = session.workspace.get()?.folders[0]?.root; + return folderUri ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) : []; + } + /** * The session type the user explicitly picked, read from the stored * preference. Unlike {@link selectedPick}, this is independent of any @@ -234,10 +245,7 @@ export class SessionTypePicker extends Disposable { // (e.g. Local Agent Host whose session types are populated only after // agent discovery) shows up without waiting for the refresh event to // land before the user clicks. - const folderUri = session.workspace.get()?.folders[0]?.root; - const folderTypes = folderUri - ? this.sessionsManagementService.getSessionTypesForFolder(folderUri) - : this._folderSessionTypes; + const folderTypes = this._sessionTypesForSession(session); this._folderSessionTypes = folderTypes; if (folderTypes.length <= 1) { diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index fc9d2b493b78e5..e8a09fc82f4915 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -10,7 +10,7 @@ import { IAction, toAction } from '../../../../base/common/actions.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { disposableTimeout } from '../../../../base/common/async.js'; -import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { basename } from '../../../../base/common/resources.js'; import { autorun } from '../../../../base/common/observable.js'; @@ -143,7 +143,14 @@ export class WorkspacePicker extends Disposable { */ private readonly _connectionStatusWatch = this._register(new MutableDisposable()); + /** + * "Primary" trigger. This is the most recently created entry. Preserved for subclass + * read access (e.g. {@link WebWorkspacePicker} anchors its mobile sheet here) and for + * {@link showPicker} calls that do not supply an anchor. + */ protected _triggerElement: HTMLElement | undefined; + /** All live trigger elements. Label updates fan out to every entry. */ + private readonly _triggerElements = new Set<HTMLElement>(); private readonly _renderDisposables = this._register(new DisposableStore()); private readonly _tabbedWidget: TabbedActionListWidget; private readonly _pickerGroupContext: IContextKey<string>; @@ -266,51 +273,103 @@ export class WorkspacePicker extends Disposable { /** * Renders the project picker trigger button into the given container. * Returns the container element. + * + * This is the single-trigger entry point. Calling it again replaces the + * trigger created by the previous {@link render} call. For multi-trigger + * use (e.g. mirroring the same picker into two surfaces) call + * {@link renderTrigger} instead. */ render(container: HTMLElement): HTMLElement { this._renderDisposables.clear(); const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); this._renderDisposables.add({ dispose: () => slot.remove() }); + this._renderDisposables.add(this._addTrigger(slot)); + + return slot; + } + + /** + * Adds an additional trigger anchored to {@link container}. Unlike + * {@link render}, calling this does NOT remove triggers from earlier + * calls. Each trigger is independent and disposed via its own returned + * disposable. All live triggers share this picker's selection state and + * receive label updates from {@link _updateTriggerLabel}. + * + * Clicking any trigger anchors the popup to that specific trigger. + */ + renderTrigger(container: HTMLElement): IDisposable { + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-workspace-picker')); + const triggerDisposables = new DisposableStore(); + triggerDisposables.add({ dispose: () => slot.remove() }); + triggerDisposables.add(this._addTrigger(slot)); + return triggerDisposables; + } + + /** + * Shared trigger-creation core for both {@link render} and + * {@link renderTrigger}. Wires up the click / keyboard / touch handlers + * and the per-trigger lifecycle. + */ + private _addTrigger(slot: HTMLElement): IDisposable { + const triggerDisposables = new DisposableStore(); + const trigger = dom.append(slot, dom.$('a.action-label')); trigger.tabIndex = 0; trigger.role = 'button'; trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); + + this._triggerElements.add(trigger); this._triggerElement = trigger; + this._renderTriggerLabel(trigger); // Onboarding spotlight target — id is referenced by the "new session" tour // in vs/sessions/contrib/onboardingTours. - this._renderDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); + triggerDisposables.add(markOnboardingTarget(trigger, 'sessions.newSession.workspacePicker')); - this._updateTriggerLabel(); - - this._renderDisposables.add(touch.Gesture.addTarget(trigger)); + triggerDisposables.add(touch.Gesture.addTarget(trigger)); [dom.EventType.CLICK, touch.EventType.Tap].forEach(eventType => { - this._renderDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { + triggerDisposables.add(dom.addDisposableListener(trigger, eventType, (e) => { dom.EventHelper.stop(e, true); - this.showPicker(); + this.showPicker(false, trigger); })); }); - - this._renderDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { + triggerDisposables.add(dom.addDisposableListener(trigger, dom.EventType.KEY_DOWN, (e) => { if (e.key === 'Enter' || e.key === ' ') { dom.EventHelper.stop(e, true); - this.showPicker(); + this.showPicker(false, trigger); } })); - return slot; + triggerDisposables.add({ + dispose: () => { + this._triggerElements.delete(trigger); + if (this._triggerElement === trigger) { + // Demote to any other live trigger so subclasses that read + // `_triggerElement` (e.g. WebWorkspacePicker's mobile sheet + // path) don't dereference a removed node. + this._triggerElement = this._triggerElements.values().next().value; + } + }, + }); + + return triggerDisposables; } /** - * Shows the workspace picker dropdown anchored to the trigger element. + * Shows the workspace picker dropdown anchored to a trigger element. * * @param force When true, re-show even if the picker is already visible. * Used internally when swapping items in place after a tab * change. + * @param anchor The specific trigger element to anchor the popup to. When + * omitted, defaults to the most-recently rendered trigger. + * Pass through when more than one trigger is live and the + * popup should align with the one the user actually clicked. */ - showPicker(force = false): void { - if (!this._triggerElement) { + showPicker(force = false, anchor?: HTMLElement): void { + const triggerElement = anchor ?? this._triggerElement; + if (!triggerElement) { return; } const alreadyVisible = this.actionWidgetService.isVisible || this._tabbedWidget.isVisible; @@ -336,10 +395,10 @@ export class WorkspacePicker extends Disposable { const tabbed = tabs.length > 1; if (tabbed) { - this._showTabbedPicker(tabs); + this._showTabbedPicker(tabs, triggerElement); } else { this._activeTab = undefined; - this._showFlatPicker(); + this._showFlatPicker(triggerElement); } } @@ -409,11 +468,10 @@ export class WorkspacePicker extends Disposable { * `IActionWidgetService` so we benefit from its keybindings, focus * tracking and submenu chrome. */ - private _showFlatPicker(): void { + private _showFlatPicker(triggerElement: HTMLElement): void { // Tear down any previous tabbed popup before delegating to the // shared service — the two presentations don't co-exist. this._tabbedWidget.hide(); - const triggerElement = this._triggerElement!; const items = this._buildItems(); const delegate = this._buildDelegate(triggerElement, () => this._hidePicker()); triggerElement.setAttribute('aria-expanded', 'true'); @@ -439,8 +497,7 @@ export class WorkspacePicker extends Disposable { * platform `TabbedActionListWidget`; this picker only owns the data * and selection logic. */ - private _showTabbedPicker(tabs: readonly ITabDescriptor[]): void { - const triggerElement = this._triggerElement!; + private _showTabbedPicker(tabs: readonly ITabDescriptor[], triggerElement: HTMLElement): void { // Hide the flat picker if it's visible — the two presentations // don't co-exist. if (this.actionWidgetService.isVisible) { @@ -864,23 +921,25 @@ export class WorkspacePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { - return; + for (const trigger of this._triggerElements) { + this._renderTriggerLabel(trigger); } + } - dom.clearNode(this._triggerElement); + private _renderTriggerLabel(trigger: HTMLElement): void { + dom.clearNode(trigger); const workspace = this._selectedResolved?.workspace; const label = workspace ? workspace.label : localize('pickWorkspace', "workspace"); const icon = workspace ? workspace.icon : Codicon.project; - this._triggerElement.setAttribute('aria-label', workspace + trigger.setAttribute('aria-label', workspace ? localize('workspacePicker.selectedAriaLabel', "New session in {0}", label) : localize('workspacePicker.pickAriaLabel', "Start by picking a workspace")); - dom.append(this._triggerElement, renderIcon(icon)); - const labelSpan = dom.append(this._triggerElement, dom.$('span.sessions-chat-dropdown-label')); + dom.append(trigger, renderIcon(icon)); + const labelSpan = dom.append(trigger, dom.$('span.sessions-chat-dropdown-label')); labelSpan.textContent = label; - dom.append(this._triggerElement, renderIcon(Codicon.chevronDownCompact)).classList.add('sessions-chat-dropdown-chevron'); + dom.append(trigger, renderIcon(Codicon.chevronDownCompact)).classList.add('sessions-chat-dropdown-chevron'); } /** diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index e43d0f70b73f6f..f584293e821a7a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -27,6 +27,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.input', "You are in the chat input. Type a message and press Enter to send it.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); + content.push(localize('sessionsChat.quickChat', "To start a workspace-less quick chat, use the New Quick Chat command{0} or the plus button on the Chats section in the sessions list. A quick chat has no workspace, so the workspace picker does not apply and the Toggle Side Panel command is disabled.", '<keybinding:sessionsView.newQuickChat>')); content.push(localize('sessionsChat.mobileConfig', "On mobile, the mode and model pickers appear as tappable chips below the input. Tap a chip to open a bottom sheet where you can change the selection.")); content.push(localize('sessionsChat.history', "Use up and down arrows to navigate your request history in the input box.")); content.push(localize('sessionsChat.contextReferences', "Type # in the chat input to attach context. Use #file to reference a file or folder, or #session to reference another agent session. Referencing a session together with the /troubleshoot command analyzes that session's logs instead of the current one. Accept a suggestion with Tab or Enter; the reference appears as a pill above the input that you can remove.")); diff --git a/src/vs/sessions/contrib/chat/browser/slashCommands.ts b/src/vs/sessions/contrib/chat/browser/slashCommands.ts index 8eb7b937446f58..ee0a4b8d3f97bc 100644 --- a/src/vs/sessions/contrib/chat/browser/slashCommands.ts +++ b/src/vs/sessions/contrib/chat/browser/slashCommands.ts @@ -187,7 +187,7 @@ export class SlashCommandHandler extends Disposable { // Show the command description as a placeholder after the command const restOfInput = value.slice(match[0].length).trim(); - const detail = slashCommand?.detail ?? promptCommand?.description; + const detail = slashCommand?.detail ?? promptCommand?.argumentHint; if (!restOfInput && detail) { const placeholderCol = match[0].length + 1; this._placeholderDecorations.set([{ diff --git a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts index 475aabd946e5cd..7d9352bbdcd2cd 100644 --- a/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts +++ b/src/vs/sessions/contrib/chat/browser/worktreeCreatedTaskDispatcher.ts @@ -65,7 +65,7 @@ export class WorktreeCreatedTaskDispatcher extends Disposable implements IWorkbe } private _trackSession(session: ISession): void { - if (session.capabilities.runsWorktreeCreatedTasks) { + if (session.capabilities.get().runsWorktreeCreatedTasks) { // The session's runtime already runs these tasks itself. return; } diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 31cb9ba0817cb4..a4f51d27c0c237 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -6,6 +6,9 @@ import { ipcRenderer } from '../../../../base/parts/sandbox/electron-browser/globals.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; +import { AgentHostByokLmHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -156,3 +159,12 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); + +// Renderer-side BYOK language-model handler that backs the node agent host's +// OpenAI proxy, mirroring the registration in the workbench's +// `contrib/chat/electron-browser/chat.contribution`. The Agents app runs a full +// extension host whose LM API holds the user's BYOK models, so registering the +// handler here lets the Agents window serve BYOK too — necessary when it is the +// only window connected to the node host. Lazily instantiated when the node host +// resolves it via `AgentHostClientByokLmChannel`. +registerSingleton(IAgentHostByokLmHandler, AgentHostByokLmHandler, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts index 368a25c2cf2cec..dd94d45604ed8a 100644 --- a/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/agentHostInputCompletions.test.ts @@ -6,7 +6,8 @@ import * as assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; -import { getAgentHostCompletionAttachmentRange } from '../../browser/agentHostInputCompletions.js'; +import { IChatRequestVariableEntry, toAgentHostCompletionVariableEntry, AgentHostCompletionReferenceKind } from '../../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { getAgentHostCompletionAttachmentRange, getCommandArgumentHintPlaceholder } from '../../browser/agentHostInputCompletions.js'; suite('AgentHostInputCompletions', () => { @@ -44,4 +45,30 @@ suite('AgentHostInputCompletions', () => { new OffsetRange(0, '/rename'.length) ); }); + + suite('getCommandArgumentHintPlaceholder', () => { + function commandEntry(argumentHint: string | undefined): IChatRequestVariableEntry { + return toAgentHostCompletionVariableEntry(AgentHostCompletionReferenceKind.Command, '/plan', 'plan', { command: 'plan', ...(argumentHint !== undefined ? { argumentHint } : {}) }); + } + + test('returns the hint and end offset when the command is the sole content with a trailing space', () => { + const entry = commandEntry('task'); + const references = new Map([[entry.id, { text: '/plan', range: new OffsetRange(0, 5) }]]); + assert.deepStrictEqual( + getCommandArgumentHintPlaceholder('/plan ', [entry], references), + { argumentHint: 'task', endOffset: 5 } + ); + }); + + test('returns undefined without a hint, once an argument is typed, or with leading text', () => { + const withHint = commandEntry('task'); + const withoutHint = commandEntry(undefined); + const refs = (entry: IChatRequestVariableEntry, start: number) => new Map([[entry.id, { text: '/plan', range: new OffsetRange(start, start + 5) }]]); + + assert.strictEqual(getCommandArgumentHintPlaceholder('/plan ', [withoutHint], refs(withoutHint, 0)), undefined); + assert.strictEqual(getCommandArgumentHintPlaceholder('/plan task', [withHint], refs(withHint, 0)), undefined); + assert.strictEqual(getCommandArgumentHintPlaceholder('hi /plan ', [withHint], refs(withHint, 3)), undefined); + assert.strictEqual(getCommandArgumentHintPlaceholder('/plan ', [withHint], new Map()), undefined); + }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts index 79b042693311e6..b1580c9e594634 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -35,15 +35,34 @@ class MockSessionsManagementService extends Disposable { readonly onDidChangeSessionTypes: Event<void> = this._onDidChangeSessionTypes.event; private _types: IProviderSessionType[] = []; + private _quickChatTypes: IProviderSessionType[] = []; setSessionTypes(types: IProviderSessionType[]): void { this._types = types; this._onDidChangeSessionTypes.fire(); } + setQuickChatSessionTypes(types: IProviderSessionType[]): void { + this._quickChatTypes = types; + this._onDidChangeSessionTypes.fire(); + } + getSessionTypesForFolder(_folderUri: URI): IProviderSessionType[] { return this._types; } + + getQuickChatSessionTypes(): IProviderSessionType[] { + return this._quickChatTypes; + } +} + +function createFakeQuickChatSession(providerId: string, sessionTypeId: string): ISession { + return { + providerId, + sessionType: sessionTypeId, + workspace: constObservable(undefined), + isQuickChat: constObservable(true), + } as unknown as ISession; } function sessionType(providerId: string, id: string, label: string): IProviderSessionType { @@ -226,4 +245,26 @@ suite('SessionTypePicker', () => { picker.pick({ providerId: 'local-1', sessionTypeId: 'local' }); assert.strictEqual(picker.getUserPickedSessionType(), undefined); }); + + test('a quick chat sources its types from the quick-chat list, not the folder list', () => { + // Folder list is empty (workspace-less); quick-chat list drives defaults. + management.setSessionTypes([]); + management.setQuickChatSessionTypes([ + sessionType('local-1', 'local', 'Local'), + sessionType('copilot', 'copilot-cli', 'Copilot CLI'), + ]); + const picker = createPicker(disposables, session, management, storage); + + session.set(createFakeQuickChatSession('local-1', 'local'), undefined); + + // Picking the first quick-chat type is "the default" → stored pick cleared. + // (Were the picker still folder-sourced, the empty folder list would make + // nothing the default and this would persist instead.) + picker.pick({ providerId: 'local-1', sessionTypeId: 'local' }); + assert.strictEqual(picker.getUserPickedSessionType(), undefined); + + // Picking a non-first quick-chat type is stored. + picker.pick({ providerId: 'copilot', sessionTypeId: 'copilot-cli' }); + assert.deepStrictEqual(picker.getUserPickedSessionType(), { providerId: 'copilot', sessionTypeId: 'copilot-cli' }); + }); }); diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts index f247ced18f5089..ac81cecfba88da 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionWorkspacePicker.test.ts @@ -98,6 +98,7 @@ function createMockProvider(id: string, opts?: { onDidChangeSessions: Event.None, getSessions: () => [], createNewSession: () => { throw new Error('Not implemented'); }, + createQuickChat: () => { throw new Error('Not implemented'); }, deleteNewSession: () => { }, getSessionTypes: () => [], renameChat: async () => { }, diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts index 614ef9bf5edf5e..dd8c6a7c4c729d 100644 --- a/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/sessionsTaskService.test.ts @@ -16,7 +16,7 @@ import { IPreferencesService } from '../../../../../workbench/services/preferenc import { INonSessionTaskEntry, ISessionsTasksService, SessionsTasksService, ITaskEntry } from '../../browser/sessionsTasksService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; import { constObservable, observableValue } from '../../../../../base/common/observable.js'; -import { IChat, ISession, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionFolder, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ISessionTaskRunner, ISessionTaskRunnerRegistry, SessionTaskRunnerRegistry } from '../../browser/sessionTaskRunner.js'; @@ -45,6 +45,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession mode: observableValue('mode', undefined), isArchived: observableValue('isArchived', false), isRead: observableValue('isRead', true), + interactivity: observableValue('interactivity', ChatInteractivity.Full), checkpoints: observableValue('checkpoints', undefined), lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), @@ -71,7 +72,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession description: chat.description, chats: observableValue('chats', [chat]), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), } satisfies ISession; return session; } diff --git a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts index ccabedbfbb9170..cec49d03fa1b93 100644 --- a/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/workbenchSessionTaskRunner.test.ts @@ -56,7 +56,7 @@ function makeSession(opts: { repository?: URI; worktree?: URI } = {}): ISession description: observableValue('description', undefined), chats: observableValue('chats', [chat]), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } diff --git a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts index 30c4f850b5e0e2..228acb6e67bdcb 100644 --- a/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts +++ b/src/vs/sessions/contrib/chat/test/browser/worktreeCreatedTaskDispatcher.test.ts @@ -75,7 +75,7 @@ function makeSession(opts: { id?: string; providerId?: string; runsWorktreeCreat description: observableValue('description', undefined), chats: observableValue('chats', [chat]), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }, + capabilities: constObservable({ supportsMultipleChats: false, runsWorktreeCreatedTasks: opts.runsWorktreeCreatedTasks }), }; return { session, loading, status, workspace, isArchived }; } diff --git a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts index f087b76e4c89c3..712e918e0df5c2 100644 --- a/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts +++ b/src/vs/sessions/contrib/codeReview/browser/codeReview.contributions.ts @@ -69,7 +69,7 @@ class RunSessionCodeReviewAction extends Action2 { return; } - if (session.capabilities.supportsMultipleChats) { + if (session.capabilities.get().supportsMultipleChats) { await sessionManagementService.sendNewChatRequest(session, { query: CODE_REVIEW_QUERY }); } else { chatWidgetService.getWidgetBySessionResource(session.resource)?.acceptInput(CODE_REVIEW_QUERY); diff --git a/src/vs/sessions/contrib/editor/browser/addTabActions.ts b/src/vs/sessions/contrib/editor/browser/addTabActions.ts new file mode 100644 index 00000000000000..6266cf4fe73bcb --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/addTabActions.ts @@ -0,0 +1,171 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './emptyFileEditor.contribution.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { KeyChord, KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { localize2 } from '../../../../nls.js'; +import { Action2, MenuId } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IBrowserViewWorkbenchService } from '../../../../workbench/contrib/browserView/common/browserView.js'; +import { openNewSearchEditor } from '../../../../workbench/contrib/searchEditor/browser/searchEditorActions.js'; +import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { SinglePaneChangesTabMissingContext, SinglePaneFilesTabMissingContext } from '../../../common/contextkeys.js'; +import { SessionsCategories } from '../../../common/categories.js'; +import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; + +export const NEW_FILE_TAB_COMMAND_ID = 'workbench.action.agentSessions.newFileTab'; +export const NEW_BROWSER_TAB_COMMAND_ID = 'workbench.action.agentSessions.newBrowserTab'; +export const NEW_SEARCH_TAB_COMMAND_ID = 'workbench.action.agentSessions.newSearchTab'; +export const NEW_CHANGES_TAB_COMMAND_ID = 'workbench.action.agentSessions.newChangesTab'; + +// The add-tab actions are only registered in the single-pane layout, so the +// `when` clauses don't need to gate on the setting. +const addTabActionWhen = ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated()); + +const addTabLayoutWhen = ContextKeyExpr.and( + addTabActionWhen, + IsTopRightEditorGroupContext, + MainEditorAreaVisibleContext); + +export class NewFileTabAction extends Action2 { + + constructor() { + super({ + id: NEW_FILE_TAB_COMMAND_ID, + title: localize2('newFileTab', "Files"), + category: SessionsCategories.Sessions, + icon: Codicon.newFile, + f1: true, + precondition: addTabActionWhen, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: addTabActionWhen, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyB), + }, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 1, + // Only offer when the Files tab is not already shown. + when: ContextKeyExpr.and(addTabLayoutWhen, SinglePaneFilesTabMissingContext) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const editorService = accessor.get(IEditorService); + const editorGroupsService = accessor.get(IEditorGroupsService); + const instantiationService = accessor.get(IInstantiationService); + const group = editorGroupsService.mainPart.activeGroup; + + await editorService.openEditor(instantiationService.createInstance(EmptyFileEditorInput), { pinned: true, index: group.count }, group); + } +} + +export class NewBrowserTabAction extends Action2 { + + constructor() { + super({ + id: NEW_BROWSER_TAB_COMMAND_ID, + title: localize2('newBrowserTab', "Browser"), + category: SessionsCategories.Sessions, + icon: Codicon.globe, + f1: true, + precondition: addTabActionWhen, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: addTabActionWhen, + primary: KeyChord(KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyK, KeyCode.KeyB), + }, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 2, + when: addTabLayoutWhen + } + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const browserViewWorkbenchService = accessor.get(IBrowserViewWorkbenchService); + const editorService = accessor.get(IEditorService); + const browserInput = browserViewWorkbenchService.getOrCreateLazy(generateUuid(), {}); + + await editorService.openEditor(browserInput); + } +} + +export class NewSearchTabAction extends Action2 { + + constructor() { + super({ + id: NEW_SEARCH_TAB_COMMAND_ID, + title: localize2('newSearchTab', "Search"), + category: SessionsCategories.Sessions, + icon: Codicon.search, + f1: true, + precondition: addTabActionWhen, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: addTabActionWhen, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyS), + }, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 3, + when: addTabLayoutWhen + } + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const instantiationService = accessor.get(IInstantiationService); + await instantiationService.invokeFunction(openNewSearchEditor, { location: 'new' }); + } +} + +export class NewChangesTabAction extends Action2 { + + constructor() { + super({ + id: NEW_CHANGES_TAB_COMMAND_ID, + title: localize2('newChangesTab', "Changes"), + category: SessionsCategories.Sessions, + icon: Codicon.gitCompare, + f1: false, + precondition: addTabActionWhen, + menu: { + id: MenuId.EditorTabsBarAddTab, + group: 'navigation', + order: 0, + // Only offer when the session has a Changes editor but its tab is closed. + when: ContextKeyExpr.and(addTabLayoutWhen, SinglePaneChangesTabMissingContext) + } + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const editorGroupsService = accessor.get(IEditorGroupsService); + const sessionsService = accessor.get(ISessionsService); + const sessionChangesService = accessor.get(ISessionChangesService); + + const sessionResource = sessionsService.activeSession.get()?.resource; + if (sessionResource) { + const group = editorGroupsService.mainPart.activeGroup; + await sessionChangesService.openChangesEditor(sessionResource, { index: group.count }, group); + } + } +} diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index dbadf0ce80ae8a..d6e16bf132d638 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -3,17 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { NewBrowserTabAction, NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from './addTabActions.js'; import { localize2 } from '../../../../nls.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ActiveEditorContext, EditorPartModalContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext } from '../../../../workbench/common/contextkeys.js'; +import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ActiveEditorContext, AuxiliaryBarVisibleContext, EditorPartModalContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Menus } from '../../../browser/menus.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; -import { EditorMaximizedContext } from '../../../common/contextkeys.js'; +import { EditorMaximizedContext, SinglePaneDetailChangesOrFilesActiveContext } from '../../../common/contextkeys.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IEditorGroupsService } from '../../../../workbench/services/editor/common/editorGroupsService.js'; @@ -32,10 +38,59 @@ import { TEXT_FILE_EDITOR_ID } from '../../../../workbench/contrib/files/common/ import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { SessionsCategories } from '../../../common/categories.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; import { IChangesViewService } from '../../changes/common/changesViewService.js'; const terminalPanelHiddenForMaximizedEditor = new WeakSet<IAgentWorkbenchLayoutService>(); +// The pop-out-to-modal and close-editor-area buttons do not apply to the single-pane +// redesign, so they are hidden when the setting is enabled (original layout keeps them). +const singlePaneDetailPanel = ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true); +const notSinglePaneDetailPanel = singlePaneDetailPanel.negate(); + +const editorTitleActionsWhen = ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext); +// Single-pane "layout" actions (maximize/restore, hide editor, toggle details) +// render in the editor-title *layout* cluster (MenuId.EditorTitleLayout), after +// the editor-title actions and their separator — mirroring the classic layout. +// Hide chevron first, then maximize/restore, then the detail-panel toggle. +const singlePaneLayoutHideEditorOrder = 10; +const singlePaneLayoutMaximizeOrder = 20; + +// Keybinding scope for the single-pane maximize/restore toggle: active in the +// main sessions window whenever the single-pane layout is on and the editor +// area is visible. Deliberately does not require the editor group to be focused +// so the toggle works while typing in the chat. +const singlePaneMaximizeKeybindingWhen = ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + singlePaneDetailPanel, + MainEditorAreaVisibleContext); + +class SinglePaneAddTabContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneAddTab'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(registerAction2(NewFileTabAction)); + this._register(registerAction2(NewBrowserTabAction)); + this._register(registerAction2(NewSearchTabAction)); + this._register(registerAction2(NewChangesTabAction)); + } +} + +registerWorkbenchContribution2(SinglePaneAddTabContribution.ID, SinglePaneAddTabContribution, WorkbenchPhase.BlockStartup); + class MaximizeMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; @@ -45,16 +100,25 @@ class MaximizeMainEditorPartAction extends Action2 { title: localize2('maximizeMainEditorPart', "Maximize Editor Area"), icon: Codicon.screenFull, f1: false, - menu: { - id: MenuId.EditorTitleLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext, - EditorMaximizedContext.negate()) - } + keybinding: { + weight: KeybindingWeight.SessionsContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyE, + when: ContextKeyExpr.and(singlePaneMaximizeKeybindingWhen, EditorMaximizedContext.negate()) + }, + menu: [ + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: singlePaneLayoutMaximizeOrder, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext.negate(), singlePaneDetailPanel, MainEditorAreaVisibleContext) + }, + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 99, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext.negate(), notSinglePaneDetailPanel) + } + ] }); } @@ -90,16 +154,25 @@ class RestoreMainEditorPartAction extends Action2 { icon: Codicon.screenNormal, f1: false, toggled: EditorMaximizedContext, - menu: { - id: MenuId.EditorTitleLayout, - group: 'navigation', - order: 99, - when: ContextKeyExpr.and( - IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext, - EditorMaximizedContext) - } + keybinding: { + weight: KeybindingWeight.SessionsContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyE, + when: ContextKeyExpr.and(singlePaneMaximizeKeybindingWhen, EditorMaximizedContext) + }, + menu: [ + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: singlePaneLayoutMaximizeOrder, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext, singlePaneDetailPanel, MainEditorAreaVisibleContext) + }, + { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: 99, + when: ContextKeyExpr.and(editorTitleActionsWhen, EditorMaximizedContext, notSinglePaneDetailPanel) + } + ] }); } @@ -119,6 +192,42 @@ class RestoreMainEditorPartAction extends Action2 { registerAction2(RestoreMainEditorPartAction); +class HideMainEditorPartAction extends Action2 { + static readonly ID = 'workbench.action.agentSessions.hideMainEditorPart'; + + constructor() { + super({ + id: HideMainEditorPartAction.ID, + title: localize2('hideMainEditorPart', "Hide Editor"), + icon: Codicon.chevronRight, + f1: false, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: singlePaneLayoutHideEditorOrder, + when: ContextKeyExpr.and( + editorTitleActionsWhen, + singlePaneDetailPanel, + EditorMaximizedContext.negate(), + AuxiliaryBarVisibleContext, + SinglePaneDetailChangesOrFilesActiveContext, + MainEditorAreaVisibleContext) + } + }); + } + + run(accessor: ServicesAccessor): void { + const layoutService = accessor.get(IAgentWorkbenchLayoutService); + layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + layoutService.setPartHidden(true, Parts.EDITOR_PART); + // Closing the editor area frees horizontal space, so bring the sessions + // list back (it may have been auto-collapsed when details was opened). + layoutService.setPartHidden(false, Parts.SIDEBAR_PART); + } +} + +registerAction2(HideMainEditorPartAction); + class CloseMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.closeMainEditorPart'; @@ -135,7 +244,8 @@ class CloseMainEditorPartAction extends Action2 { when: ContextKeyExpr.and( IsSessionsWindowContext, IsAuxiliaryWindowContext.toNegated(), - IsTopRightEditorGroupContext) + IsTopRightEditorGroupContext, + notSinglePaneDetailPanel) } }); } @@ -163,7 +273,8 @@ class OpenEditorInModalEditorAction extends Action2 { order: 1, when: ContextKeyExpr.and( IsSessionsWindowContext, - IsAuxiliaryWindowContext.toNegated() + IsAuxiliaryWindowContext.toNegated(), + notSinglePaneDetailPanel ) } }); @@ -304,12 +415,17 @@ class AddFileAsContextAction extends Action2 { icon: Codicon.attach, f1: true, precondition, - menu: { + menu: [{ + id: Menus.SessionsEditorTitle, + group: 'navigation', + order: 100000, + when: ContextKeyExpr.and(precondition, singlePaneDetailPanel) + }, { id: MenuId.EditorTitle, group: 'navigation', - order: 1, - when: precondition - } + order: 100000, // towards the far right, mirroring Split Editor Right in the regular window + when: ContextKeyExpr.and(precondition, notSinglePaneDetailPanel) + }] }); } diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts new file mode 100644 index 00000000000000..1d5cc8749541b9 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.contribution.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../../workbench/browser/editor.js'; +import { EditorExtensions, IEditorFactoryRegistry } from '../../../../workbench/common/editor.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; +import { EmptyFileEditor } from './emptyFileEditor.js'; +import { EmptyFileEditorInput, EmptyFileEditorSerializer } from './emptyFileEditorInput.js'; + +/** + * Registers the empty-file editor (the "Select a file or search with <kbd>" placeholder pane) and + * its serializer, but only in the single-pane layout where the "Files" add-tab flow uses it. + * Registered at startup (before editor restore) so persisted empty-file tabs can be deserialized. + * Opening it is owned by `addTabActions.ts`'s "Files" action. + */ +class SinglePaneEmptyFileEditorContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessions.singlePaneEmptyFileEditor'; + + constructor( + @IAgentWorkbenchLayoutService layoutService: IAgentWorkbenchLayoutService, + ) { + super(); + + if (!layoutService.isSinglePaneLayoutEnabled) { + return; + } + + this._register(Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane( + EditorPaneDescriptor.create( + EmptyFileEditor, + EmptyFileEditor.ID, + localize('emptyFileEditor.label', "File") + ), + [new SyncDescriptor(EmptyFileEditorInput)] + )); + + this._register(Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory).registerEditorSerializer( + EmptyFileEditorInput.ID, + EmptyFileEditorSerializer + )); + } +} + +registerWorkbenchContribution2(SinglePaneEmptyFileEditorContribution.ID, SinglePaneEmptyFileEditorContribution, WorkbenchPhase.BlockStartup); diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts new file mode 100644 index 00000000000000..7e2efa2e680bb2 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditor.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/emptyFileEditor.css'; +import { $, addDisposableListener, Dimension, EventType } from '../../../../base/browser/dom.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IThemeService } from '../../../../platform/theme/common/themeService.js'; +import { EditorPane } from '../../../../workbench/browser/parts/editor/editorPane.js'; +import { IEditorGroup } from '../../../../workbench/services/editor/common/editorGroupsService.js'; +import { EmptyFileEditorInput } from './emptyFileEditorInput.js'; + +const QUICK_OPEN_COMMAND_ID = 'workbench.action.quickOpen'; + +export class EmptyFileEditor extends EditorPane { + + static readonly ID = EmptyFileEditorInput.EDITOR_ID; + + private container: HTMLElement | undefined; + + constructor( + group: IEditorGroup, + @ITelemetryService telemetryService: ITelemetryService, + @IThemeService themeService: IThemeService, + @IStorageService storageService: IStorageService, + @ICommandService private readonly commandService: ICommandService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(EmptyFileEditor.ID, group, telemetryService, themeService, storageService); + } + + protected override createEditor(parent: HTMLElement): void { + const keybindingLabel = this.keybindingService.lookupKeybinding(QUICK_OPEN_COMMAND_ID)?.getLabel() + ?? localize('emptyFileEditor.quickOpenFallback', "Quick Open"); + const placeholder = localize('emptyFileEditor.placeholder', "Select a file or search with {0}", keybindingLabel); + + this.container = $('div.empty-file-editor', { + role: 'button', + tabindex: 0, + 'aria-label': placeholder + }); + + const message = $('span.empty-file-editor-placeholder'); + message.textContent = placeholder; + this.container.appendChild(message); + parent.appendChild(this.container); + + // Support touch (iOS): register a gesture target so `Tap` fires, and handle + // both click and tap to open the picker (see sessionTypePicker/sessionFilesWidget). + this._register(Gesture.addTarget(this.container)); + for (const eventType of [EventType.CLICK, TouchEventType.Tap]) { + this._register(addDisposableListener(this.container, eventType, () => this.openQuickOpen())); + } + this._register(addDisposableListener(this.container, EventType.KEY_DOWN, e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.openQuickOpen(); + } + })); + } + + override focus(): void { + this.container?.focus(); + } + + override layout(_dimension: Dimension): void { } + + private openQuickOpen(): void { + void this.commandService.executeCommand(QUICK_OPEN_COMMAND_ID, ''); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts new file mode 100644 index 00000000000000..5bf68e49484b68 --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/emptyFileEditorInput.ts @@ -0,0 +1,69 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { URI } from '../../../../base/common/uri.js'; +import { EditorInputCapabilities, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../workbench/common/editor.js'; +import { EditorInput } from '../../../../workbench/common/editor/editorInput.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; + +export class EmptyFileEditorInput extends EditorInput { + + static readonly ID = 'workbench.editors.agentSessions.emptyFile'; + static readonly EDITOR_ID = 'workbench.editor.agentSessions.emptyFile'; + + override get resource(): URI | undefined { + return undefined; + } + + override get typeId(): string { + return EmptyFileEditorInput.ID; + } + + override get editorId(): string { + return EmptyFileEditorInput.EDITOR_ID; + } + + override get capabilities(): EditorInputCapabilities { + return EditorInputCapabilities.Readonly | EditorInputCapabilities.Singleton | EditorInputCapabilities.ForceReveal; + } + + override getName(): string { + return localize('emptyFileEditor.name', "Files"); + } + + override getIcon(): ThemeIcon { + return Codicon.files; + } + + override getTitle(_verbosity?: Verbosity): string { + return this.getName(); + } + + override canReopen(): boolean { + return true; + } + + override matches(otherInput: EditorInput | IUntypedEditorInput): boolean { + return super.matches(otherInput) || otherInput instanceof EmptyFileEditorInput; + } +} + +export class EmptyFileEditorSerializer implements IEditorSerializer { + + canSerialize(editorInput: EditorInput): editorInput is EmptyFileEditorInput { + return editorInput instanceof EmptyFileEditorInput; + } + + serialize(editorInput: EditorInput): string | undefined { + return this.canSerialize(editorInput) ? '' : undefined; + } + + deserialize(instantiationService: IInstantiationService, _serializedEditor: string): EditorInput { + return instantiationService.createInstance(EmptyFileEditorInput); + } +} diff --git a/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css new file mode 100644 index 00000000000000..2873c930a719ef --- /dev/null +++ b/src/vs/sessions/contrib/editor/browser/media/emptyFileEditor.css @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.empty-file-editor { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-agentsPanel-foreground); + background: var(--vscode-editor-background); + cursor: pointer; + touch-action: manipulation; +} + +.empty-file-editor:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.empty-file-editor-placeholder { + color: var(--vscode-descriptionForeground); +} diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts index d0cfe129ff4c1d..fccc0b2d0c909d 100644 --- a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -4,14 +4,27 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { constObservable } from '../../../../../base/common/observable.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 { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; +import { openNewSearchEditor } from '../../../../../workbench/contrib/searchEditor/browser/searchEditorActions.js'; import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { NewChangesTabAction, NewFileTabAction, NewSearchTabAction } from '../../browser/addTabActions.js'; +import { EmptyFileEditorInput } from '../../browser/emptyFileEditorInput.js'; // Import editor contribution to trigger action registration. import '../../browser/editor.contribution.js'; @@ -19,6 +32,91 @@ import '../../browser/editor.contribution.js'; suite('Sessions - Editor Contribution', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); + function stubEditorGroupCount(instantiationService: TestInstantiationService, count: number): void { + instantiationService.stub(IEditorGroupsService, new class extends mock<IEditorGroupsService>() { + override get mainPart(): IEditorGroupsService['mainPart'] { + return { activeGroup: { count } as IEditorGroup } as IEditorGroupsService['mainPart']; + } + }); + } + + test('new file tab action opens pinned empty file editor', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const opened: { editor: EditorInput; options: IEditorOptions | undefined }[] = []; + stubEditorGroupCount(instantiationService, 7); + instantiationService.set(IEditorService, new class extends mock<IEditorService>() { + override async openEditor(...args: unknown[]): Promise<undefined> { + const editor = args[0]; + if (editor instanceof EditorInput) { + opened.push({ editor: store.add(editor), options: args[1] as IEditorOptions | undefined }); + } + return undefined; + } + }); + + await new NewFileTabAction().run(instantiationService); + + assert.deepStrictEqual(opened.map(({ editor, options }) => ({ + isEmptyFileEditor: editor instanceof EmptyFileEditorInput, + pinned: options?.pinned, + index: options?.index + })), [{ isEmptyFileEditor: true, pinned: true, index: 7 }]); + }); + + test('new search tab action opens a new search editor', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const invoked: unknown[] = []; + instantiationService.stub(IInstantiationService, new class extends mock<IInstantiationService>() { + override invokeFunction<R, TS extends any[] = []>(fn: (accessor: ServicesAccessor, ...args: TS) => R, ..._args: TS): R { + invoked.push(fn); + return undefined as R; + } + }); + + await new NewSearchTabAction().run(instantiationService); + + assert.deepStrictEqual(invoked, [openNewSearchEditor]); + }); + + test('new changes tab action opens the changes editor for the active session', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const resource = URI.parse('session:1'); + stubEditorGroupCount(instantiationService, 5); + instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() { + override readonly activeSession = constObservable({ resource } as IActiveSession); + }); + const opened: { resource: URI; index: number | undefined }[] = []; + instantiationService.stub(ISessionChangesService, new class extends mock<ISessionChangesService>() { + override async openChangesEditor(sessionResource: URI, options?: IEditorOptions): Promise<undefined> { + opened.push({ resource: sessionResource, index: options?.index }); + return undefined; + } + }); + + await new NewChangesTabAction().run(instantiationService); + + assert.deepStrictEqual(opened, [{ resource, index: 5 }]); + }); + + test('new changes tab action is a no-op when there is no active session', async () => { + const instantiationService = store.add(new TestInstantiationService()); + stubEditorGroupCount(instantiationService, 0); + instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() { + override readonly activeSession = constObservable(undefined); + }); + let opened = false; + instantiationService.stub(ISessionChangesService, new class extends mock<ISessionChangesService>() { + override async openChangesEditor(): Promise<undefined> { + opened = true; + return undefined; + } + }); + + await new NewChangesTabAction().run(instantiationService); + + assert.strictEqual(opened, false); + }); + test('maximize editor hides the terminal panel before maximizing', async () => { const instantiationService = store.add(new TestInstantiationService()); const layoutService = new class extends mock<IAgentWorkbenchLayoutService>() { diff --git a/src/vs/sessions/contrib/files/browser/files.contribution.ts b/src/vs/sessions/contrib/files/browser/files.contribution.ts index 990a7fe45e6994..40643c99f500c7 100644 --- a/src/vs/sessions/contrib/files/browser/files.contribution.ts +++ b/src/vs/sessions/contrib/files/browser/files.contribution.ts @@ -21,7 +21,7 @@ import { IsSessionsWindowContext, WorkspaceFolderCountContext } from '../../../. import { SESSIONS_FILES_EMPTY_VIEW_ID, SESSIONS_FILES_VIEW_ID, SessionsExplorerEmptyView, SessionsExplorerView } from './filesView.js'; import './workspaceFolderActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { SessionHasGitRepositoryContext, SessionHasGitSyncActionRunningContext, IsNewChatSessionContext, IsPhoneLayoutContext } from '../../../common/contextkeys.js'; +import { SessionHasGitRepositoryContext, SessionHasGitSyncActionRunningContext, IsNewChatSessionContext, IsPhoneLayoutContext, SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; export const SESSIONS_FILES_CONTAINER_ID = 'workbench.sessions.auxiliaryBar.filesContainer'; @@ -38,7 +38,7 @@ const filesViewContainer = viewContainerRegistry.registerViewContainer({ order: 11, ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [SESSIONS_FILES_CONTAINER_ID, { mergeViewWithContainerWhenSingleView: true }]), storageId: SESSIONS_FILES_CONTAINER_ID, - hideIfEmpty: false, + hideIfEmpty: true, openCommandActionDescriptor: { id: SESSIONS_FILES_CONTAINER_ID, title: localize2('explore', "Explorer"), @@ -64,7 +64,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerView), canToggleVisibility: false, canMoveView: false, - when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsPhoneLayoutContext.negate()), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.notEqualsTo('0'), IsPhoneLayoutContext.negate(), SessionHasWorkspaceContext), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); @@ -76,7 +76,7 @@ class RegisterFilesViewContribution implements IWorkbenchContribution { ctorDescriptor: new SyncDescriptor(SessionsExplorerEmptyView), canToggleVisibility: false, canMoveView: false, - when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsPhoneLayoutContext.negate()), + when: ContextKeyExpr.and(WorkspaceFolderCountContext.isEqualTo('0'), IsPhoneLayoutContext.negate(), SessionHasWorkspaceContext), windowEnablement: WindowEnablement.Sessions, }], filesViewContainer); } diff --git a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts index 2b188815ab74b2..3e0c6150dee9a0 100644 --- a/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts +++ b/src/vs/sessions/contrib/files/browser/workspaceFolderActions.ts @@ -16,12 +16,13 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { Menus } from '../../../browser/menus.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; -import { SessionHasWorkspaceContext } from '../../../common/contextkeys.js'; +import { SessionHasWorkspaceContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; @@ -45,7 +46,7 @@ class OpenFilesViewAction extends Action2 { id: Menus.SessionHeaderMeta, group: 'navigation', order: -10, - when: SessionHasWorkspaceContext + when: ContextKeyExpr.and(SessionHasWorkspaceContext, IsQuickChatSessionContext.negate()) }, }); } diff --git a/src/vs/sessions/contrib/github/browser/github.contribution.ts b/src/vs/sessions/contrib/github/browser/github.contribution.ts index 7f286376b3bfe1..a5a7609d27f2d9 100644 --- a/src/vs/sessions/contrib/github/browser/github.contribution.ts +++ b/src/vs/sessions/contrib/github/browser/github.contribution.ts @@ -22,6 +22,19 @@ import './pullRequestActions.js'; const TRACE_PREFIX = '[PR-ICON-TRACE]'; +/** + * Resolved PR identity for a session's poller, or the specific stage at which + * resolution bailed out. Only the `ok` state keeps a PR model warm/polling; the + * other kinds are logged so the trace pinpoints *why* a non-active session's PR + * icon never refreshes (its model was never kept warm). + */ +type PullRequestIdentityState = + | { readonly kind: 'ok'; readonly owner: string; readonly repo: string; readonly prNumber: number } + | { readonly kind: 'archived' } + | { readonly kind: 'no-workspace' } + | { readonly kind: 'no-git-repository' } + | { readonly kind: 'no-pull-request' }; + export class GitHubPullRequestPollingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'sessions.contrib.githubPullRequestPolling'; @@ -113,6 +126,12 @@ export class GitHubPullRequestPollingContribution extends Disposable implements this._sessionTrackers.deleteAndDispose(session.sessionId); } } + + // Aggregate visibility: if non-active session icons aren't refreshing, the + // first thing to check is whether this poller even sees the sessions. A low + // tracked count here (e.g. 0/1 while the list shows many) means the sessions + // never reach the poller, so their PR models are never kept warm. + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] onDidChangeSessions (added ${e.added.length}, changed ${e.changed.length}, removed ${e.removed.length}); now tracking ${this._sessionTrackers.size} session poller(s)`); } private _trackSession(session: ISession): void { @@ -138,26 +157,47 @@ export class GitHubPullRequestPollingContribution extends Disposable implements // stable while the PR's live data — and therefore its computed icon — // updates, so the poller doesn't churn (or feed back into itself) every // time `gitHubInfo` re-derives. - const pullRequestIdentityObs = derivedOpts<{ readonly owner: string; readonly repo: string; readonly prNumber: number } | undefined>( + // + // When there's no identity yet we carry the *reason* (archived / no + // workspace / no git repository / no pull request) so the outer autorun + // can log precisely which stage is missing. This is the key signal for + // diagnosing "non-active session icons don't refresh": the shared PR + // model is only kept warm (and polled) once this resolves to `ok`, so a + // session stuck at e.g. `no-workspace` or `no-pull-request` explains why + // its icon never refines. Structural equality still de-dupes these stable + // reason objects, so the poller doesn't churn. + const pullRequestIdentityObs = derivedOpts<PullRequestIdentityState>( { owner: this, equalsFn: structuralEquals }, reader => { if (session.isArchived.read(reader)) { - return undefined; + return { kind: 'archived' }; + } + + const workspace = session.workspace.read(reader); + if (!workspace) { + return { kind: 'no-workspace' }; + } + + const gitRepository = workspace.folders[0]?.gitRepository; + if (!gitRepository) { + return { kind: 'no-git-repository' }; } - const gitHubInfo = session.workspace.read(reader)?.folders[0]?.gitRepository?.gitHubInfo.read(reader); + const gitHubInfo = gitRepository.gitHubInfo.read(reader); if (!gitHubInfo?.pullRequest) { - return undefined; + return { kind: 'no-pull-request' }; } - return { owner: gitHubInfo.owner, repo: gitHubInfo.repo, prNumber: gitHubInfo.pullRequest.number }; + return { kind: 'ok', owner: gitHubInfo.owner, repo: gitHubInfo.repo, prNumber: gitHubInfo.pullRequest.number }; }); return autorun(reader => { const identity = pullRequestIdentityObs.read(reader); - if (!identity) { - // No PR number yet (or archived); this autorun re-runs once it resolves. - this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} has no PR identity yet (no PR number or archived); waiting`); + if (identity.kind !== 'ok') { + // Not kept warm. The `reason` disambiguates the four bail-out stages + // (previously logged as one generic message), so the log tells us + // exactly why a non-active session's PR model is never polled. + this._logService.trace(`${TRACE_PREFIX} [PollingContribution] Session ${session.sessionId} has no PR identity yet (reason: ${identity.kind}); NOT keeping a PR model warm. Will re-run reactively if this input changes.`); return; } diff --git a/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css b/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css index 7fe3d3146f1e2b..00cc9d73f4ca80 100644 --- a/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css +++ b/src/vs/sessions/contrib/github/browser/media/pullRequestHover.css @@ -8,7 +8,7 @@ display: flex; flex-direction: column; width: 520px; - max-width: calc(100vw - var(--vscode-spacing-size320)); + max-width: 100%; color: var(--vscode-editorHoverWidget-foreground); } @@ -39,6 +39,7 @@ font-size: var(--vscode-agents-fontSize-heading2, 18px); font-weight: var(--vscode-agents-fontWeight-semiBold, 600); line-height: 1.25; + overflow-wrap: anywhere; } .sessions-pr-hover-description { @@ -47,11 +48,12 @@ -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; - padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160) 0 var(--vscode-spacing-size160); border-top: var(--vscode-strokeThickness) solid var(--vscode-editorHoverWidget-border); font-size: var(--vscode-agents-fontSize-body1); line-height: 1.4; color: var(--vscode-descriptionForeground); + overflow-wrap: anywhere; } .sessions-pr-hover-branches { @@ -59,7 +61,7 @@ align-items: center; gap: var(--vscode-spacing-size80); min-width: 0; - padding: 0 var(--vscode-spacing-size160) var(--vscode-spacing-size120); + padding: var(--vscode-spacing-size120) var(--vscode-spacing-size160); } .sessions-pr-hover-branch { diff --git a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts index d37a9e6c9abc2e..602d6c1c2ce7dc 100644 --- a/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/githubContribution.test.ts @@ -19,7 +19,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/tes import { mock } from '../../../../../base/test/common/mock.js'; import { GitHubPullRequestPollingContribution } from '../../browser/github.contribution.js'; import { IGitHubService } from '../../browser/githubService.js'; -import { IChat, IGitHubInfo, ISession, ISessionCapabilities, ISessionChangeset, IChatCheckpoints, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, IGitHubInfo, ISession, ISessionCapabilities, ISessionChangeset, IChatCheckpoints, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; @@ -243,7 +243,7 @@ class TestSession implements ISession { readonly lastTurnEnd: ReturnType<typeof observableValue<Date | undefined>>; readonly chats: ReturnType<typeof observableValue<readonly IChat[]>>; readonly mainChat: IObservable<IChat>; - readonly capabilities: ISessionCapabilities = { supportsMultipleChats: false }; + readonly capabilities: IObservable<ISessionCapabilities> = constObservable({ supportsMultipleChats: false }); constructor(id: string, gitHubInfo: IGitHubInfo | undefined, archived: boolean) { this.sessionId = `test:${id}`; @@ -291,6 +291,7 @@ class TestSession implements ISession { mode: this.mode, isArchived: this.isArchived, isRead: this.isRead, + interactivity: constObservable(ChatInteractivity.Full), description: this.description, lastTurnEnd: this.lastTurnEnd, }; diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md index a8ed8bd34ae440..9a3480731ca936 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.md @@ -29,7 +29,8 @@ Each session restores its own set of open editors when you activate it. Switchin editors you had open and applies the target session's. New / untitled sessions, and sessions with no saved editors, never force the editor area open or wipe it. If you hid the editor part for a session (e.g. by closing the Side Panel while keeping editors open), restoring it keeps the editor part hidden -instead of forcing it back open. +instead of forcing it back open. When an active draft is replaced by its committed session, the draft's +editor-part hidden state follows the committed resource. ### Scenario: layout survives an app restart A session's remembered layout is preserved when the app is closed and restored when it reopens. @@ -59,14 +60,22 @@ default layout instead of stale state. Open editors are still preserved. - **Panel [B1]** — `_syncPanelVisibility(resource)` restores the record (default hidden); a live `onDidChangePartVisibility` listener for `PANEL_PART` updates it (suppressed while multiple sessions are visible). -- **Working sets [B2]** — active only when `workbench.editor.useModal !== 'all'` (`_useModalConfigObs`). - `activeSessionForWorkingSet` (`derivedObservableWithCache`) holds back the new session until the - workspace folders reflect its working directory. Save/apply on switch via a serializing `Sequencer`; - initial restore applies a saved set under `suppressEditorPartAutoVisibility()` only. `_saveWorkingSet` - also records the editor part's hidden state per session (`_editorPartHiddenBySession`, only while a - single session is visible — the editor area is shared in multi-session mode) so a switch-back - `_applyWorkingSet` skips the editor-part reveal for a session whose editor part was left hidden. - Cleanup on `onDidChangeSessions` (`_deleteWorkingSet` drops only the working set, never view state). +- **Working sets [B2]** — always active, regardless of `workbench.editor.useModal`: browser editors + dock in the shared grid editor part even when `useModal` is `'all'` (they except themselves from the + modal part), so their tabs still need per-session capture/restore in that mode. `_useModalConfigObs` + is only consulted inside `_applyWorkingSet` to decide whether to auto-reveal the editor part (skipped + in modal mode, since modal editors manage their own visibility). `activeSessionForWorkingSet` + (`derivedObservableWithCache`) holds back the new session until the workspace folders reflect its + working directory. Save/apply on switch via a serializing `Sequencer`; initial restore applies a saved + set under `suppressEditorPartAutoVisibility()` only. The editor part's hidden state is captured + **eagerly** per session by a part-visibility listener the moment the user changes it + (`_editorPartHiddenBySession`, only while a single session is visible — the editor area is shared in + multi-session mode; captured lazily at switch-away it would race the switch derive), so a switch-back + `_applyWorkingSet` skips the editor-part reveal — and in single-pane actively re-hides it + (`_shouldHideEditorPartOnApply`) — for a session whose editor part was left hidden. `onDidReplaceSession` + copies a replaced active draft's editor-part hidden state to the committed resource before that resource's + first working-set apply, avoiding a fall-through to the created-session default. Cleanup on + `onDidChangeSessions` (`_deleteWorkingSet` drops only the working set, never view state). - **Persistence & migration [B3]** — per-session state is keyed by session `URI` and persisted to the workspace-scoped storage key `sessions.layoutState` (`StorageTarget.MACHINE`). `_loadState` restores on construction and drops corrupt data defensively; if the key is absent it migrates once from the diff --git a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts index 2765a16381cdfd..0bf8af1812f6cf 100644 --- a/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/baseSessionLayoutController.ts @@ -17,8 +17,9 @@ import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILifecycleService } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; @@ -26,6 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, MainEditorAreaVisibleContext } from '../../../../workbench/common/contextkeys.js'; +import { IViewDescriptorService, ViewContainerLocation } from '../../../../workbench/common/views.js'; import { IEditorGroupsService, IEditorWorkingSet } from '../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -33,12 +35,13 @@ import { IPaneCompositePartService } from '../../../../workbench/services/paneco import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; import { Menus } from '../../../browser/menus.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { SessionsWelcomeVisibleContext, IsQuickChatSessionContext } from '../../../common/contextkeys.js'; import { logSidePanelToggle } from '../../../common/sessionsTelemetry.js'; import { ISessionChangesService } from '../../changes/browser/sessionChangesService.js'; +import { IChangesViewService } from '../../changes/common/changesViewService.js'; import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { SessionStatus } from '../../../services/sessions/common/session.js'; +import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; const secondarySidebarToggleClosedIcon = registerIcon('agent-secondary-sidebar-toggle-closed', Codicon.layoutSidebarRightOff, localize('agentSecondarySidebarToggleClosedIcon', "Icon for the sessions secondary sidebar when closed.")); const secondarySidebarToggleOpenIcon = registerIcon('agent-secondary-sidebar-toggle-open', Codicon.layoutSidebarRight, localize('agentSecondarySidebarToggleOpenIcon', "Icon for the sessions secondary sidebar when open.")); @@ -124,6 +127,24 @@ export abstract class BaseLayoutController extends Disposable { private _lastVisibleSidePaneParts: { readonly editor: boolean; readonly auxiliaryBar: boolean } | undefined; private readonly _useModalConfigObs; + + /** + * Storage key for this controller's per-session layout state. Overridable so a + * sibling controller (e.g. single-pane) persists to a fresh key instead of + * sharing the classic desktop state. + */ + protected get _layoutStateStorageKey(): string { + return SESSION_LAYOUT_STATE_KEY; + } + + /** + * Legacy key migrated on first load, or `undefined` to skip migration (a fresh + * sibling controller has no legacy state to migrate). + */ + protected get _legacyWorkingSetsStorageKey(): string | undefined { + return WORKING_SETS_STORAGE_KEY; + } + constructor( @IAgentWorkbenchLayoutService protected readonly _layoutService: IAgentWorkbenchLayoutService, @@ -134,9 +155,14 @@ export abstract class BaseLayoutController extends Disposable { @IStorageService protected readonly _storageService: IStorageService, @IConfigurationService protected readonly _configurationService: IConfigurationService, @IEditorService protected readonly _editorService: IEditorService, - @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IEditorGroupsService protected readonly _editorGroupsService: IEditorGroupsService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @ISessionChangesService protected readonly _sessionChangesService: ISessionChangesService, + @IChangesViewService protected readonly _changesViewService: IChangesViewService, + @IViewDescriptorService protected readonly _viewDescriptorService: IViewDescriptorService, + @IContextKeyService protected readonly _contextKeyService: IContextKeyService, + @IInstantiationService protected readonly _instantiationService: IInstantiationService, + @ILifecycleService protected readonly _lifecycleService: ILifecycleService, ) { super(); @@ -198,6 +224,28 @@ export abstract class BaseLayoutController extends Disposable { } })); + // [B2] Track editor-part (docked side-pane) visibility changes by the user + // so a session's closed/open editor state is captured at the moment it + // changes — not lazily re-read at switch-away time, which races with the + // incoming session's async layout restore (the switch derive lags behind + // the raw active-session change, so by the time the previous session is + // saved the editor part may already reflect the new session). Skipped + // while multiple sessions are visible (the editor area is shared) and + // during a session-switch restore (those changes are layout-driven, not + // user choices). + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId !== Parts.EDITOR_PART || this._isRestoringSessionLayout) { + return; + } + if (this.multipleSessionsVisibleObs.get()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession) { + this._editorPartHiddenBySession.set(activeSession.resource, !e.visible); + } + })); + // [B2] Editor working sets this._useModalConfigObs = observableConfigValue<'off' | 'some' | 'all'>('workbench.editor.useModal', 'all', this._configurationService); @@ -228,45 +276,69 @@ export abstract class BaseLayoutController extends Disposable { return activeSession; }); - this._register(autorun(reader => { - const useModalConfig = this._useModalConfigObs.read(reader); - if (useModalConfig === 'all') { - return; + // Working sets are always active: browser editors dock in the shared grid + // editor part even when `workbench.editor.useModal` is `'all'` (they + // deliberately except themselves from the modal part), so their tabs + // still need to be captured/restored per session in that mode. + + // [B2] Save the outgoing session's working set eagerly on the raw active + // session change, not on the workspace-gated `activeSessionForWorkingSet` + // derive below. The derive lags while the incoming session's workspace + // resolves, and autoruns driven by the raw active session (e.g. the + // single-pane managed-tabs sync) async-close the outgoing session's docked + // editors during that window. Saving here synchronously — before those + // closes run — captures which editor was active (e.g. the Changes tab) so it + // is restored active on return. + this._register(runOnChange(this._sessionsService.activeSession, (session, previousSession) => { + if ( + previousSession + && !isEqual(previousSession.resource, session?.resource) + && previousSession.status.read(undefined) !== SessionStatus.Untitled + && !this._isRestoringSessionLayout + ) { + this._saveWorkingSet(previousSession.resource); } + })); - // [B2] Session changed (save, apply) - reader.store.add(runOnChange(activeSessionForWorkingSet, (session, previousSession) => { - // Save working set for previous session (skip for untitled sessions) - if (previousSession && previousSession.status.read(undefined) !== SessionStatus.Untitled) { - this._saveWorkingSet(previousSession.resource); - } + // [B2] Session changed (apply) + this._register(runOnChange(activeSessionForWorkingSet, (session, previousSession) => { + // Apply working set for current session. + // On initial load (no previous session), only apply if we have a saved working set — + // skip applying 'empty' to avoid closing editors that are being restored. + if (previousSession || (session && this._workingSets.has(session.resource))) { + this._withSessionLayoutRestore(() => this._applyWorkingSet(session?.resource, { isInitialRestore: !previousSession })); + } + })); - // Apply working set for current session. - // On initial load (no previous session), only apply if we have a saved working set — - // skip applying 'empty' to avoid closing editors that are being restored. - if (previousSession || (session && this._workingSets.has(session.resource))) { - this._withSessionLayoutRestore(() => this._applyWorkingSet(session?.resource, { isInitialRestore: !previousSession })); - } - })); - - // [B2] Session state changed (archive, delete) - reader.store.add(this._sessionManagementService.onDidChangeSessions(e => { - const archivedSessions = e.changed.filter(session => session.isArchived.read(undefined)); - for (const session of [...e.removed, ...archivedSessions]) { - this._deleteWorkingSet(session.resource); - this._viewStateBySession.delete(session.resource); - this._editorPartHiddenBySession.delete(session.resource); - } - })); + // [B2] Session state changed (archive, delete) + this._register(this._sessionManagementService.onDidChangeSessions(e => { + const archivedSessions = e.changed.filter(session => session.isArchived.read(undefined)); + for (const session of [...e.removed, ...archivedSessions]) { + this._deleteWorkingSet(session.resource); + this._viewStateBySession.delete(session.resource); + this._editorPartHiddenBySession.delete(session.resource); + } })); + this._register(this._sessionManagementService.onDidReplaceSession(({ from, to }) => this._onSessionReplaced(from, to))); // Side-pane toggle UI (menu item, keybinding, command-palette entry). this._register(this._registerSidePaneToggleAction()); // Platform-specific auxiliary bar / view-state management. this._registerViewStateManagement(); + + // Layout-specific auxiliary controllers (e.g. single-pane detail/tab + // controllers), created and owned by the layout controller so they share + // its lifecycle and coordinate through it. + this._registerAuxiliaryControllers(); } + /** + * Hook for a layout controller to create and own its auxiliary controllers. + * The base implementation does nothing. + */ + protected _registerAuxiliaryControllers(): void { } + /** * Registers the `Toggle Side Panel` action (menu item, keybinding, * command-palette entry). The action delegates straight to `toggleSidePane()`, @@ -290,6 +362,9 @@ export abstract class BaseLayoutController extends Disposable { }, category: Categories.View, f1: true, + // A quick chat has no side pane (Round 20 hides the empty aux bar + // and the chat is full-width), so toggling it is meaningless. + precondition: IsQuickChatSessionContext.negate(), keybinding: { weight: KeybindingWeight.SessionsContrib, primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyB @@ -325,6 +400,34 @@ export abstract class BaseLayoutController extends Disposable { */ protected _registerViewStateManagement(): void { } + protected _onSessionReplaced(from: ISession, to: ISession): void { + // `onDidReplaceSession` fires only when an untitled draft is atomically + // replaced by its committed session on submit, so it always means "the + // committed session inherits the draft's on-screen side-pane layout". + // Persist the draft's live editor-part visibility onto the committed + // session so the delayed working-set apply restores it as-left (instead of + // the created-session default, which would reveal the docked editor) and it + // also survives a reload. + const activeSession = this._sessionsService.activeSession.get(); + const replacedSessionIsActive = isEqual(activeSession?.resource, from.resource) || isEqual(activeSession?.resource, to.resource); + const editorPartHidden = this._editorPartHiddenBySession.get(from.resource) + ?? (replacedSessionIsActive ? !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) : undefined); + if (editorPartHidden !== undefined) { + this._editorPartHiddenBySession.set(to.resource, editorPartHidden); + } + } + + /** + * Whether the auxiliary bar currently has at least one active view container + * (shown as a tab). Mirrors the workbench's own container-visibility rule + * (`!hideIfEmpty || isViewContainerActive`, folded into `isViewContainerActive`). + */ + protected _hasActiveAuxViewContainers(): boolean { + return this._viewDescriptorService + .getViewContainersByLocation(ViewContainerLocation.AuxiliaryBar) + .some(container => this._viewsService.isViewContainerActive(container.id)); + } + /** * Toggle the **side pane** — the editor area together with the auxiliary bar. * Closing it hides both; re-opening restores exactly the parts that were @@ -335,6 +438,7 @@ export abstract class BaseLayoutController extends Disposable { */ toggleSidePane(): boolean { this._togglingSidePane = true; + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); try { // Treat the side pane as visible when *either* part is visible so the // toggle always closes both, instead of just revealing the auxiliary @@ -351,20 +455,28 @@ export abstract class BaseLayoutController extends Disposable { this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); this._layoutService.setPartHidden(true, Parts.EDITOR_PART); } else { - // Restore only the parts that were visible before hiding (default to - // both when there is no remembered state, e.g. after a reload). - const restore = this._lastVisibleSidePaneParts ?? { editor: true, auxiliaryBar: true }; + // Restore only the parts that were visible before hiding (falling back + // to the layout's default parts when there is no remembered state, + // e.g. after a reload). + const restore = this._lastVisibleSidePaneParts ?? this._defaultReopenSidePaneParts(); const hasEditors = this._editorGroupsService.groups.some(group => !group.isEmpty); + const hasAuxViewContainers = this._hasActiveAuxViewContainers(); if (restore.editor && hasEditors) { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } - if (restore.auxiliaryBar) { + if (restore.auxiliaryBar && hasAuxViewContainers) { this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); } - // Ensure the toggle always has a visible effect (e.g. the remembered - // state was editor-only but there are no editors to show now). + // Ensure the toggle has a visible effect, but never reveal an empty + // aux bar: prefer the editor when it has content, else the aux bar + // only when it has active view containers (a quick chat with neither + // has nothing to reveal). if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) && !this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { - this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + if (hasEditors) { + this._layoutService.setPartHidden(false, Parts.EDITOR_PART); + } else if (hasAuxViewContainers) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } } } @@ -373,6 +485,7 @@ export abstract class BaseLayoutController extends Disposable { return !isCurrentlyVisible; } finally { + suppressEditorPartAutoVisibility.dispose(); this._togglingSidePane = false; } } @@ -387,6 +500,15 @@ export abstract class BaseLayoutController extends Disposable { */ protected _onSidePaneToggled(_collapsed: boolean, _previousAuxiliaryBarVisible: boolean): void { } + /** + * The parts to reveal when re-opening the side pane with no remembered state + * (e.g. after a reload). The base default shows both the editor and the + * auxiliary bar; subclasses can specialize per layout / session type. + */ + protected _defaultReopenSidePaneParts(): { readonly editor: boolean; readonly auxiliaryBar: boolean } { + return { editor: true, auxiliaryBar: true }; + } + /** * [B4] Hook that lets a subclass snapshot the active session's view state when * state is about to be persisted. The base implementation does nothing. @@ -400,20 +522,60 @@ export abstract class BaseLayoutController extends Disposable { */ protected _withSessionLayoutRestore(work: () => void | Promise<unknown>): void { this._restoringSessionLayoutDepth++; + const suppression = this._suppressEditorVisibilityDuringRestore(); let settledSync = true; try { const result = work(); if (isThenable(result)) { settledSync = false; - Promise.resolve(result).catch(() => undefined).finally(() => this._restoringSessionLayoutDepth--); + Promise.resolve(result).catch(() => undefined).finally(() => { + this._restoringSessionLayoutDepth--; + suppression?.dispose(); + }); } } finally { if (settledSync) { this._restoringSessionLayoutDepth--; + suppression?.dispose(); } } } + /** + * Hook to suppress editor-part auto-visibility for the whole session-switch + * restore. The base restore causes no layout-driven editor closes, so it + * returns `undefined`. + */ + protected _suppressEditorVisibilityDuringRestore(): IDisposable | undefined { + return undefined; + } + + /** + * Hook deciding whether {@link _applyWorkingSet} reveals the editor part when + * restoring a non-empty working set. + */ + protected _shouldRevealEditorPartOnApply(editorPartHidden: boolean, isModal: boolean): boolean { + return !editorPartHidden && !isModal; + } + + /** + * Hook deciding whether {@link _applyWorkingSet} reveals the editor part for an + * empty working set. The base never reveals in this case. + */ + protected _shouldRevealEditorPartForEmptyWorkingSet(_revealEditorPart: boolean): boolean { + return false; + } + + /** + * Hook deciding whether {@link _applyWorkingSet} actively hides the editor part + * when restoring a session that had it hidden. The base never hides (in the + * classic layout the editor part visibility is not a per-session choice); the + * single-pane layout restores its docked editor part both ways. + */ + protected _shouldHideEditorPartOnApply(_editorPartHidden: boolean): boolean { + return false; + } + // --- Editor part reveal --- /** @@ -424,11 +586,16 @@ export abstract class BaseLayoutController extends Disposable { this._layoutService.setPartHidden(false, Parts.EDITOR_PART); } + /** Hides the editor part to restore a session that had its docked editor closed. */ + private _hideEditorPartForWorkingSet(): void { + this._layoutService.setPartHidden(true, Parts.EDITOR_PART); + } + // --- Persistence [B3] --- private _loadState(): void { // Load from new key first - const raw = this._storageService.get(SESSION_LAYOUT_STATE_KEY, StorageScope.WORKSPACE); + const raw = this._storageService.get(this._layoutStateStorageKey, StorageScope.WORKSPACE); if (raw) { try { for (const entry of JSON.parse(raw) as ISessionLayoutEntry[]) { @@ -446,12 +613,16 @@ export abstract class BaseLayoutController extends Disposable { return; } catch { // Corrupted data — remove the bad key so we don't keep failing, then fall through to legacy migration - this._storageService.remove(SESSION_LAYOUT_STATE_KEY, StorageScope.WORKSPACE); + this._storageService.remove(this._layoutStateStorageKey, StorageScope.WORKSPACE); } } // Migrate from legacy key (sessions.workingSets) - const legacyRaw = this._storageService.get(WORKING_SETS_STORAGE_KEY, StorageScope.WORKSPACE); + const legacyKey = this._legacyWorkingSetsStorageKey; + if (!legacyKey) { + return; + } + const legacyRaw = this._storageService.get(legacyKey, StorageScope.WORKSPACE); if (legacyRaw) { try { type LegacyEntry = { sessionResource: string; editorWorkingSet?: IEditorWorkingSet; auxiliaryBarState?: { visible: boolean; activeViewContainerId: string | undefined } }; @@ -471,7 +642,7 @@ export abstract class BaseLayoutController extends Disposable { // ignore corrupted data } // Remove legacy key after migration - this._storageService.remove(WORKING_SETS_STORAGE_KEY, StorageScope.WORKSPACE); + this._storageService.remove(legacyKey, StorageScope.WORKSPACE); } } @@ -496,7 +667,7 @@ export abstract class BaseLayoutController extends Disposable { this._editorPartHiddenBySession.forEach((_, r) => allResources.set(r, true)); if (allResources.size === 0) { - this._storageService.remove(SESSION_LAYOUT_STATE_KEY, StorageScope.WORKSPACE); + this._storageService.remove(this._layoutStateStorageKey, StorageScope.WORKSPACE); return; } @@ -509,7 +680,7 @@ export abstract class BaseLayoutController extends Disposable { editorPartHidden: this._editorPartHiddenBySession.get(resource), }); }); - this._storageService.store(SESSION_LAYOUT_STATE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._storageService.store(this._layoutStateStorageKey, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } // --- Panel [B1] --- @@ -555,14 +726,39 @@ export abstract class BaseLayoutController extends Disposable { } const isModal = this._useModalConfigObs.get() === 'all'; + // The user may have hidden the editor part for this session (e.g. by + // closing the Side Panel while keeping editors open). Restore it as + // left instead of forcing the editor part back open on switch. A + // draft→committed submit records the draft's editor-part visibility onto + // the committed session (see `_onSessionReplaced`), so this restores the + // submitted layout too. + const editorPartHidden = sessionResource ? this._editorPartHiddenBySession.get(sessionResource) === true : false; + const revealEditorPart = !options?.isInitialRestore + && this._shouldRevealEditorPartOnApply(editorPartHidden, isModal); + // Restore a session that had its (docked) editor part closed by actively + // hiding it, so returning from a session that had it open does not leave + // it visible. Mutually exclusive with revealing. + const hideEditorPart = !options?.isInitialRestore + && !revealEditorPart + && this._shouldHideEditorPartOnApply(editorPartHidden); if (workingSet === 'empty') { await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); + if (this._shouldRevealEditorPartForEmptyWorkingSet(revealEditorPart) && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._revealEditorPartForWorkingSet(); + } else if (hideEditorPart && this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._hideEditorPartForWorkingSet(); + } return; } // On the initial restore after a reload, preserve the editor part - // visibility that the workbench already restored. + // visibility that the workbench already restored. Single-pane is the + // exception: its per-session editor-part visibility is authoritative and + // persisted, so a Detail-only (or whole-side-pane-closed) session must be + // restored with its editor hidden rather than left visible if the + // workbench (or an init-time width sync) revealed it. `_shouldHideEditorPartOnApply` + // returns `false` for the classic layout, so this is a no-op there. if (options?.isInitialRestore) { const suppression = this._layoutService.suppressEditorPartAutoVisibility(); try { @@ -570,21 +766,23 @@ export abstract class BaseLayoutController extends Disposable { } finally { suppression.dispose(); } + if (this._shouldHideEditorPartOnApply(editorPartHidden) && this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._hideEditorPartForWorkingSet(); + } return; } - // The user may have hidden the editor part for this session (e.g. by - // closing the Side Panel while keeping editors open). Restore it as - // left instead of forcing the editor part back open on switch. - const editorPartHidden = sessionResource ? this._editorPartHiddenBySession.get(sessionResource) === true : false; - - if (!isModal && !editorPartHidden && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (revealEditorPart && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._revealEditorPartForWorkingSet(); + } else if (hideEditorPart && this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._hideEditorPartForWorkingSet(); } const result = await this._editorGroupsService.applyWorkingSet(workingSet, { preserveFocus }); - if (!isModal && !editorPartHidden && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + if (revealEditorPart && result && !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { this._revealEditorPartForWorkingSet(); + } else if (hideEditorPart && this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + this._hideEditorPartForWorkingSet(); } }); } @@ -592,13 +790,10 @@ export abstract class BaseLayoutController extends Disposable { private _saveWorkingSet(sessionResource: URI): void { this._deleteWorkingSet(sessionResource); - // Remember the editor part's hidden state so restoring the session does - // not force it back open (see _applyWorkingSet). Skipped while multiple - // sessions are visible: the editor area is shared across them, so its - // visibility is not a per-session choice. - if (this._sessionsService.visibleSessions.get().length <= 1) { - this._editorPartHiddenBySession.set(sessionResource, !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); - } + // Note: the editor part's hidden state is captured eagerly by the [B2] + // part-visibility listener at the moment the user changes it, not here — + // re-reading it lazily at switch-away time races with the incoming + // session's async layout restore and could record the wrong value. if (this._editorService.visibleEditors.length > 0) { const workingSetName = `session-working-set:${sessionResource.toString()}`; diff --git a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md index 1e0e418b560188..0e485afa68e900 100644 --- a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md +++ b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.md @@ -40,8 +40,8 @@ the **first** time (no remembered choice yet) and again after the whole side pan including after a reload (the pane is restored closed, but opening Changes re-reveals it). If you explicitly closed just the side pane (while keeping the editor open), it stays closed on later opens (and across reloads). An already-open side pane is left on whatever view you chose. Skipped while -the editor is maximized (D5) or multiple sessions are visible, where the side pane is managed by other -rules. +the editor is maximized (D5), multiple sessions are visible, or single-pane detail-panel mode is enabled, +where the side pane is managed by other rules. #### D9 — Closing the whole side pane is not an aux-bar choice The "side pane" is the editor area together with the auxiliary bar. Closing it (e.g. from the side @@ -77,9 +77,12 @@ The side pane is restored in this order: A few transitions intentionally ignore the remembered state above. #### D4 — Submitting a new session -When a new session becomes created (`isCreated` changes from false to true) while staying active, the -side pane stays as you left it: if it was open it stays open and switches to **Changes**; if it was -closed it stays closed, but opening it later shows **Changes**. +When a new session becomes created while staying active — either `isCreated` changes from false to true +on the same resource, or the provider replaces the draft with a committed resource — the side pane stays +as you left it: if it was open it stays open and switches to **Changes**; if it was closed it stays +closed, but opening it later shows **Changes**. In single-pane detail-panel mode, the submit transition +also keeps the editor content closed because managed Changes/File tab opens are suppressed; the open side +pane shows the Changes detail. #### D5 — Maximizing the editor While the editor area is maximized, the side pane always shows **Changes**, regardless of the session's @@ -95,6 +98,46 @@ When a chat turn produces new file changes, the side pane is **not** opened auto active view is not switched automatically — it stays as you left it. Only a new session opens it (D3b), and only the created transition switches it to Changes (D4). +### Scenario: the side pane has nothing to show +Some sessions gate off every auxiliary-bar view container — e.g. a workspace-less **quick chat**, +where the Changes and Files containers are hidden — so the aux bar would otherwise be an empty column. + +#### D10 — An empty auxiliary bar is hidden +When the auxiliary bar has **no active view container** (nothing to show), its part is kept **hidden** +instead of showing an empty column, and the chat takes the space. This updates reactively as the active +session flips (a container being gated off hides the part; a container becoming active again lets the +normal restore rules D3/D8 reveal it) and also when the **part itself becomes visible** without any +container-/descriptor-change signal firing — a bare detail toggle that shows the column before a container +opens, or a restore that shows it while its containers are gated off — so the detail toggle can never read +"on" over a blank panel. Reconciling away an empty column runs under `suppressEditorPartAutoVisibility()` +so it never resurrects the editor as a side effect (editor visibility stays with D3/D8). The controller +only ever *hides* an empty aux bar — it never reveals one. Correspondingly, the docked host +(`setAuxiliaryBarHidden`) never force-opens a `hideIfEmpty` container with no active views when the aux bar +is shown, so a show can never present a blank docked panel; and **Toggle Side Panel** only affects the part +that has content: it reveals the editor when it has editors and the aux bar is empty, and never reveals an +empty aux bar (when neither side has content the toggle-open is a no-op). Together these guarantee the +invariant that `partVisibility.auxiliaryBar` (⇒ `AuxiliaryBarVisibleContext` ⇒ the detail toggle) is true +iff the docked detail panel is rendered with an active view container. For a **quick chat** — which has no +side pane at all (workspace-less, so the aux bar stays hidden and the chat is full-width) — the **Toggle +Side Panel** command is **disabled** outright (`precondition: IsQuickChatSessionContext.negate()`), so its +menu item, keybinding, and command-palette entry are inert. + +#### D11 — Single-pane new-session views are Files-only +In single-pane detail-panel mode only, while a new (uncreated) workspace session view is active, the +editor content is hidden by default so the editor tab bar and Files detail panel remain without showing +editor content. The hide is **transition-triggered**: `_registerNewSessionRules` (a no-op base hook +overridden by `SinglePaneLayoutController`) hides the editor part when it **just became +visible**, or when the new-session view was **just entered** with the editor already visible (an +inherited-visible editor from the previous session), and only while the active editor is not real content +(the managed empty tab or none). It leaves the editor alone once a real file/browser editor is active. +Switching to a managed tab (e.g. the Files placeholder) while the editor is **already** visible does not +hide it — only a visibility transition or entering the view does. An explicit reveal sticks — opening a +file reveals the editor before the file becomes the active editor (Task 4), and toggling the detail panel +off reveals the empty editor so the side pane does not vanish (Task 2); entering the view always resets to +editor-closed, so a stale cross-session explicit reveal cannot carry over. A momentary width-based reveal +(e.g. a sash drag) is re-hidden by the same rule. The shared D3b/D9b new-session side-pane visibility state +is unchanged: once the user hides the side pane for a new session, it stays hidden for later new sessions. + ### Scenario: a cramped (small) window On a small window there isn't room for the sessions sidebar, the editor, and the side pane all at once. @@ -109,6 +152,13 @@ responsive state instead of reacting to it, so only in-session layout changes dr whole behaviour is gated by the experimental setting `sessions.layout.autoCollapseSessionsSidebar`, which defaults **on** in non-stable builds (Insiders / exploration) and **off** in stable. +**Single-pane override.** In single-pane detail-panel mode `_registerResponsiveSidebar` is replaced with an +explicit-details rule: the sessions sidebar is auto-hidden **only** when the user opens the details pane via +the **Toggle Details** action (`workbench.action.toggleAuxiliaryBar`), freeing horizontal space for the +editor area, and restored when the details pane is closed via the same action. It is not window-size driven, +ignores automatic details opens (submit, session restore), is suspended while multiple sessions are visible, +and hands control back once the user reopens the sessions sidebar manually. + --- ## Implementation notes @@ -134,7 +184,10 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta `_isAuxiliaryBarContainerPinned` implement D3c/D3d. - **New-session submit [D4]** — `_onNewSessionSubmitted` keeps the aux bar as left, switches an open one to Changes, and records Changes as the active container even when hidden so opening the side pane - later starts on Changes. + later starts on Changes. `_onSessionReplaced` applies the same state transfer when a provider commits + the draft as a new resource. In single-pane mode, the tab opens that accompany this transition are + managed by `SinglePaneLayoutController` under editor-auto-visibility suppression, so they + do not reveal editor content. - **Editor maximized [D5]** — driven from the sync autorun via `editorMaximizedObs` (`IAgentWorkbenchLayoutService.isEditorMaximized()`); forced visibility is never captured (D2). The sessions workbench (`setEditorMaximized` in `browser/workbench.ts`) snapshots the editor part size + @@ -142,6 +195,26 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta its previous width. - **No auto-reveal [D6]** — the sync logic never opens the side pane or switches the active container in response to file changes; only D3b opens it, and D4 switches it to Changes. +- **Empty aux bar [D10]** — `_registerAuxiliaryBarPartVisibility` re-checks `_hasActiveAuxViewContainers()` + (base; `IViewDescriptorService.getViewContainersByLocation(AuxiliaryBar)` filtered by + `IViewsService.isViewContainerActive`) on container add/remove + (`onDidChangeViewContainers`/`onDidChangeContainerLocation`), each aux container model's + `onDidChangeActiveViewDescriptors` (the `when`-gating signal), aux-bar + `onDidChangeViewContainerVisibility`, and the aux-bar part becoming visible + (`onDidChangePartVisibility` with `partId === AUXILIARYBAR_PART && visible`). + `_syncAuxiliaryBarPartVisibility` hides `AUXILIARYBAR_PART` (via + `_hideAuxiliaryBarForRestore`, so [D2] doesn't record it) when there are no active containers, wrapped in + `suppressEditorPartAutoVisibility()` so the hide never triggers the docked swap-reveal of the editor, and + never reveals it — reveals stay with D3/D8. Symmetrically, `Workbench.setAuxiliaryBarHidden` gates its + docked "show last/default composite" fallback on `_isAuxViewContainerActive(id)` (mirroring + `IViewsService.isViewContainerActive`) so it never force-opens an empty `hideIfEmpty` container. The base + `toggleSidePane` re-open branch guards the aux-bar + un-hide with `_hasActiveAuxViewContainers()` symmetric to the editor's `hasEditors` guard, and the + "ensure a visible effect" fallback prefers the editor and only falls back to the aux bar when it has + active containers. The `Toggle Side Panel` command itself (`workbench.action.agentToggleSidePanel`, + registered by the base controller) carries `precondition: IsQuickChatSessionContext.negate()`, so it is + disabled (menu item, keybinding, palette) whenever the active session is a quick chat, which has no side + pane to toggle. - **First Changes open [D8]** — `_revealChangesViewOnFirstOpen`, registered on `IEditorService.onDidActiveEditorChange` **and** on `onDidChangePartVisibility` for `EDITOR_PART` becoming visible. The latter covers re-clicking the **Changes** button after the whole side pane was @@ -157,6 +230,11 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta visible. A hide that came from collapsing the whole side pane (D9) sets `auxiliaryBarHiddenByCollapse: true`, so opening a Changes editor re-reveals the aux bar even across a reload (the pane is still restored closed). The reveal flows through the [D2] listener, which records `{ auxiliaryBarVisible: true }`. + In single-pane detail-panel mode these listeners are not registered; the DetailPanelController maps the + active editor to a container and reveals the matching Files/Explorer or Changes detail panel when a File + or Changes editor becomes active. That reveal is edge-triggered on editor activation, so explicitly + hiding the detail panel is respected while the same editor remains active; Browser tabs still hide the + panel transiently and switching back re-opens it. - **Side-pane close [D9]** — the side-pane toggle lives on the controller (`toggleSidePane`). Its UI entry point (the `Toggle Side Panel` action: menu item, keybinding, command-palette entry, toggled icon) is registered by the base controller in its constructor and calls `toggleSidePane` directly. @@ -170,6 +248,9 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta an existing marker while the aux bar stays hidden and never fabricates one, so an explicit aux-bar hide on another session is never mistaken for a collapse. On reload the side pane is restored closed, yet opening Changes (D8) re-reveals it because the marker is present. + In single-pane mode, hiding the docked detail panel via `workbench.action.toggleAuxiliaryBar` reveals + editor content first when it was hidden, so the detail toggle swaps detail → editor instead of closing + the whole side pane; `Toggle Side Panel` remains the action that can hide both parts. - **New-session / side-pane close [D9b]** — the base `toggleSidePane` calls the `_onSidePaneToggled(collapsed, previousAuxiliaryBarVisible)` hook at the end (still inside the `_togglingSidePane` window). The desktop controller overrides it (skipped while multi-session / @@ -180,6 +261,14 @@ defaults **on** in non-stable builds (Insiders / exploration) and **off** in sta collapsing an already editor-only state) just captures the resulting state, so an explicit aux-bar hide — including one whose editor-only state is restored when the pane re-opens — is never turned into a collapse. +- **Single-pane new-session rule [D11]** — `LayoutController` exposes `_registerNewSessionRules` as a + no-op hook. `SinglePaneLayoutController` overrides it with an `autorun` over the active + session, multi-session state, editor maximized state, the active editor, and **the editor-part + visibility**; on an uncreated workspace session that is not a quick chat, it hides `EDITOR_PART` (under + `suppressEditorPartAutoVisibility()`) when the editor just became visible or the view was just entered + with the editor visible, tracking the previous editor-visible / in-new-session-view values across runs so + a mere active-editor change (e.g. switching to the Files placeholder) does not retrigger the hide. D3b + continues to open the Files container unless the shared new-session side-pane state says it is hidden. - **Responsive sidebar [D7]** — `_registerResponsiveSidebar` derives `spaceConstrained = enabled && small && editor visible && aux-bar visible && !multipleSessionsVisible` from the experimental setting `sessions.layout.autoCollapseSessionsSidebar` (`observableConfigValue`, default `product.quality !== diff --git a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts index 146575bee1d04a..3ff337600eae9b 100644 --- a/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/desktopSessionLayoutController.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { mainWindow } from '../../../../base/browser/window.js'; +import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { autorun, derived, observableFromEvent } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -12,6 +13,7 @@ import product from '../../../../platform/product/common/product.js'; import { StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ViewContainerLocation } from '../../../../workbench/common/views.js'; import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { ISession } from '../../../services/sessions/common/session.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../../changes/common/changes.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../files/browser/files.contribution.js'; import { BaseLayoutController } from './baseSessionLayoutController.js'; @@ -44,7 +46,7 @@ export const RESPONSIVE_SIDEBAR_SETTING = 'sessions.layout.autoCollapseSessionsS * {@link BaseLayoutController}, it manages the per-session auxiliary bar * visibility and active view container. * - * Its behaviour is enumerated as rules **D1-D9** in + * Its behaviour is enumerated as rules **D1-D11** in * [desktopSessionLayoutController.md](./desktopSessionLayoutController.md). */ export class LayoutController extends BaseLayoutController { @@ -57,10 +59,10 @@ export class LayoutController extends BaseLayoutController { */ private _newSessionViewState: INewSessionViewState | undefined; - /** [D7] `true` while the sidebar is hidden because the controller auto-hid it (space constrained); only such hides are auto-reverted. */ - private _sidebarAutoHidden = false; + /** [D7] `true` while the sidebar is hidden because the controller auto-hid it; only such hides are auto-reverted. */ + protected _sidebarAutoHidden = false; /** [D7] Guards the manual-toggle listener while the controller itself toggles the sidebar. */ - private _applyingAutoSidebar = false; + protected _applyingAutoSidebar = false; /** [D7] Last computed space-constrained state, so the autorun only acts on real transitions. */ private _previousSpaceConstrained = false; @@ -158,6 +160,12 @@ export class LayoutController extends BaseLayoutController { if (this._hidingAuxiliaryBarForRestore) { return; } + // While restoring a session's layout (e.g., working-set apply in progress), + // visibility changes triggered by the single-pane detail-panel logic must + // not overwrite the session's intended state. + if (this._isRestoringSessionLayout) { + return; + } if (this.multipleSessionsVisibleObs.get()) { return; } @@ -180,6 +188,14 @@ export class LayoutController extends BaseLayoutController { } })); + this._registerChangesAutoReveal(); + + this._registerResponsiveSidebar(); + this._registerAuxiliaryBarPartVisibility(); + this._registerNewSessionRules(); + } + + protected _registerChangesAutoReveal(): void { // [D8] Reveal the Changes view in the side pane the first time a Changes // editor is opened for an existing session; afterwards respect the // remembered per-session choice (D1/D2/D3). @@ -194,8 +210,99 @@ export class LayoutController extends BaseLayoutController { this._revealChangesViewOnFirstOpen(); } })); + } - this._registerResponsiveSidebar(); + protected _registerNewSessionRules(): void { } + + protected override _onSessionReplaced(from: ISession, to: ISession): void { + super._onSessionReplaced(from, to); + + const activeSession = this._sessionsService.activeSession.get(); + const replacedSessionIsActive = isEqual(activeSession?.resource, from.resource) || isEqual(activeSession?.resource, to.resource); + const auxiliaryBarVisible = replacedSessionIsActive + ? this._layoutService.isVisible(Parts.AUXILIARYBAR_PART) + : this._newSessionViewState?.auxiliaryBarVisible; + if (auxiliaryBarVisible === undefined) { + return; + } + + this._viewStateBySession.set(to.resource, { + auxiliaryBarVisible, + auxiliaryBarActiveViewContainerId: CHANGES_VIEW_CONTAINER_ID, + }); + + if (replacedSessionIsActive && auxiliaryBarVisible) { + void this._viewsService.openView(CHANGES_VIEW_ID, false); + } + } + + /** + * [D10] Keep the auxiliary-bar part hidden when it has no active view + * containers (e.g. a workspace-less quick chat where Changes+Files are gated + * off), so an empty column is never shown. Re-checks on container add/remove, + * location moves, active-view-descriptor changes (the gating signal), and + * aux-bar visibility changes. Only ever hides — reveals stay with [D3]/[D8]. + */ + private _registerAuxiliaryBarPartVisibility(): void { + const modelListeners = this._register(new DisposableStore()); + const rewire = (): void => { + modelListeners.clear(); + for (const container of this._viewDescriptorService.getViewContainersByLocation(ViewContainerLocation.AuxiliaryBar)) { + modelListeners.add(this._viewDescriptorService.getViewContainerModel(container) + .onDidChangeActiveViewDescriptors(() => this._syncAuxiliaryBarPartVisibility())); + } + this._syncAuxiliaryBarPartVisibility(); + }; + this._register(this._viewDescriptorService.onDidChangeViewContainers(rewire)); + this._register(this._viewDescriptorService.onDidChangeContainerLocation(rewire)); + this._register(this._viewsService.onDidChangeViewContainerVisibility(e => { + if (e.location === ViewContainerLocation.AuxiliaryBar) { + this._syncAuxiliaryBarPartVisibility(); + } + })); + // The aux part can become visible without any container-/descriptor-change + // signal firing (e.g. a bare detail toggle that shows the part before any + // container is opened, or a restore that shows it while its containers are + // gated off). React to the part itself becoming visible so an empty column + // is reconciled away and the toggle never reads "on" over a blank panel. + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId === Parts.AUXILIARYBAR_PART && e.visible) { + this._syncAuxiliaryBarPartVisibility(); + } + })); + rewire(); + } + + /** [D10] Hide the aux-bar part when it has no active view containers; never reveals it. */ + private _syncAuxiliaryBarPartVisibility(): void { + if (this._hasActiveAuxViewContainers()) { + return; + } + // No active aux view containers. This is only a genuine "empty column" for a + // workspace-less quick chat (Changes+Files permanently gated off). For a + // workspace-backed session it is a transient startup/activation state (the + // Files/Changes views gate on `SessionHasWorkspaceContext`, set async after + // the session activates), and during early reload there is no active session + // yet at all. Hiding in those transient cases collapses the restored-visible + // side pane and, since this method only ever hides, it stays closed — the + // reload flicker (opens then closes) and "Files not shown". So hide ONLY for + // an actual quick chat; a real quick-chat switch still fires + // `onDidChangeActiveViewDescriptors`, which re-runs this and hides then. + const activeSession = this._sessionsService.activeSession.get(); + if (activeSession?.isQuickChat?.get() !== true) { + return; + } + if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + // Removing an empty column must not, as a side effect, pop the editor + // open: the editor's visibility is governed by its own rules ([D3]/[D8]), + // not by this cleanup. Suppress the docked swap-reveal for the hide. + const suppression = this._layoutService.suppressEditorPartAutoVisibility(); + try { + this._hideAuxiliaryBarForRestore(); + } finally { + suppression.dispose(); + } + } } /** @@ -261,7 +368,7 @@ export class LayoutController extends BaseLayoutController { * visible and never triggered by session navigation. Gated by the experimental * `sessions.layout.autoCollapseSessionsSidebar` setting. */ - private _registerResponsiveSidebar(): void { + protected _registerResponsiveSidebar(): void { const enabledObs = observableConfigValue<boolean>(RESPONSIVE_SIDEBAR_SETTING, product.quality !== 'stable', this._configurationService); const smallWindowObs = observableFromEvent(this, @@ -338,7 +445,7 @@ export class LayoutController extends BaseLayoutController { } /** Returns `true` when the sidebar visibility was actually changed. */ - private _setSidebarAutoHidden(hidden: boolean): boolean { + protected _setSidebarAutoHidden(hidden: boolean): boolean { if (this._layoutService.isVisible(Parts.SIDEBAR_PART) === !hidden) { return false; } @@ -433,7 +540,11 @@ export class LayoutController extends BaseLayoutController { } // [D3] Restore the auxiliary bar in strict priority order. - private _syncAuxiliaryBarVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isCreated: boolean): void | Promise<unknown> { + // Note: This method is intentionally synchronous (void return). View-opening calls are + // fire-and-forget so that _isRestoringSessionLayout ends immediately after sync operations. + // This allows D2 to capture user actions that happen after the sync restore but before + // working-set apply, while still skipping single-pane detail-panel reveals during working-set apply. + private _syncAuxiliaryBarVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isCreated: boolean): void { // [D3a] No resource / no workspace → do nothing. if (!sessionResource || !hasWorkspace) { return; @@ -445,7 +556,8 @@ export class LayoutController extends BaseLayoutController { this._hideAuxiliaryBarForRestore(); return; } - return this._openDefaultAuxiliaryBarContainer(false); + void this._openDefaultAuxiliaryBarContainer(false); + return; } const savedState = this._viewStateBySession.get(sessionResource); @@ -459,10 +571,11 @@ export class LayoutController extends BaseLayoutController { // [D3c] Restore the user's last explicit choice, but only if that pane is still pinned. const savedContainerId = savedState.auxiliaryBarActiveViewContainerId; if (savedContainerId && this._isAuxiliaryBarContainerPinned(savedContainerId)) { - return this._viewsService.openViewContainer(savedContainerId, false); + void this._viewsService.openViewContainer(savedContainerId, false); + return; } - return this._openDefaultAuxiliaryBarContainer(true); + void this._openDefaultAuxiliaryBarContainer(true); } /** [D3d] Prefer Changes for created sessions and Files for new sessions. */ diff --git a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts index e1c2a86dc94b95..0bc86deba9bc3f 100644 --- a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts +++ b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts @@ -6,23 +6,43 @@ import { isMobile, isWeb } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import product from '../../../../platform/product/common/product.js'; import { Registry } from '../../../../platform/registry/common/platform.js'; -import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { LayoutController, RESPONSIVE_SIDEBAR_SETTING } from './desktopSessionLayoutController.js'; import { MobileLayoutController } from './mobileSessionLayoutController.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../common/sessionConfig.js'; +import { SinglePaneLayoutController } from './singlePaneLayoutController.js'; -// Contribute the layout controller for the current platform. The web bundle -// serves both the web desktop and the web phone layouts, so the choice is made -// at runtime; the native desktop bundle always uses the desktop controller. -// Registered at `BlockRestore` so the controller is in place before the window -// restores its UI, getting the side-pane layout right without a visible flash. -if (isWeb && isMobile) { - registerWorkbenchContribution2(MobileLayoutController.ID, MobileLayoutController, WorkbenchPhase.BlockRestore); -} else { - registerWorkbenchContribution2(LayoutController.ID, LayoutController, WorkbenchPhase.BlockRestore); +class SessionsLayoutContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.sessionsLayoutContribution'; + + constructor( + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super(); + + if (isWeb && isMobile) { + this._register(instantiationService.createInstance(MobileLayoutController)); + return; + } + + if (configurationService.getValue<boolean>(DOCK_DETAIL_PANEL_SETTING)) { + this._register(instantiationService.createInstance(SinglePaneLayoutController)); + return; + } + + this._register(instantiationService.createInstance(LayoutController)); + } } +registerWorkbenchContribution2(SessionsLayoutContribution.ID, SessionsLayoutContribution, WorkbenchPhase.BlockRestore); + Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ id: 'sessions', properties: { @@ -31,6 +51,14 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis markdownDescription: localize('sessions.layout.autoCollapseSessionsSidebar', "Controls whether the sessions sidebar is automatically collapsed in a narrow Agents window while both the editor and the side panel are open, and shown again once either of them closes."), default: product.quality !== 'stable', tags: ['experimental'], + experiment: { mode: 'auto' } + }, + [DOCK_DETAIL_PANEL_SETTING]: { + type: 'boolean', + markdownDescription: localize('sessions.layout.singlePaneDetailPanel', "Controls whether the Agents window docks the detail panel inside the editor so a single editor tab bar spans across the editor and the detail panel. Requires a window reload to take effect."), + default: false, + tags: ['experimental'], + experiment: { mode: 'startup' } }, }, }); diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts new file mode 100644 index 00000000000000..1fe88df126499d --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailPanelStrategy.ts @@ -0,0 +1,226 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Sequencer } from '../../../../../base/common/async.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Event } from '../../../../../base/common/event.js'; +import { autorun, IObservable, IReader, observableFromEvent } from '../../../../../base/common/observable.js'; +import { isEqualOrParent } from '../../../../../base/common/resources.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { SinglePaneDetailChangesOrFilesActiveContext } from '../../../../common/contextkeys.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import type { ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { CHANGES_VIEW_CONTAINER_ID } from '../../../changes/common/changes.js'; +import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; +import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; +import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +const enum DetailPanelTarget { + Hidden, + BrowserHidden, + Changes, + ChangesForced, + Files, + FilesForced, + Preserve +} + +/** + * Maps the active editor to its detail container (Changes / Files) and + * reveals/hides the auxiliary bar accordingly. A created single-pane session + * defaults to the Changes editor with the detail closed; a Changes/file editor + * becoming active never force-reveals a hidden detail (except restoring it after + * a transient browser-tab hide). + */ +export class SinglePaneDetailPanelStrategy extends SinglePaneLayoutStrategy { + + private _changesOrFilesActiveContext: IContextKey<boolean> | undefined; + private readonly _detailSequencer = new Sequencer(); + private _detailGeneration = 0; + private _hiddenByBrowser = false; + + constructor( + ctx: ISinglePaneLayoutContext, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @IViewsService private readonly _viewsService: IViewsService, + @ISessionChangesService private readonly _sessionChangesService: ISessionChangesService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + ) { + super(ctx); + + this._changesOrFilesActiveContext = SinglePaneDetailChangesOrFilesActiveContext.bindTo(this._contextKeyService); + const activeEditorObs = observableFromEvent(this, this._editorService.onDidActiveEditorChange, () => this._editorService.activeEditor); + const mainPartEmptyObs = observableFromEvent(this, Event.any(this._editorService.onDidActiveEditorChange, this._editorService.onDidEditorsChange, this._editorService.onDidCloseEditor), () => this._isMainPartEmpty()); + const auxBarVisibleObs = observableFromEvent(this, this._layoutService.onDidChangePartVisibility, () => this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + const editorMaximizedObs = observableFromEvent(this, this._layoutService.onDidChangeEditorMaximized, () => this._layoutService.isEditorMaximized()); + + this._register(autorun(reader => { + const activeEditor = activeEditorObs.read(reader); + const target = this._computeDetailTarget(reader, activeEditor, mainPartEmptyObs, editorMaximizedObs); + const isChangesOrFilesTarget = target === DetailPanelTarget.Changes || target === DetailPanelTarget.ChangesForced || target === DetailPanelTarget.Files || target === DetailPanelTarget.FilesForced; + this._changesOrFilesActiveContext!.set(isChangesOrFilesTarget); + auxBarVisibleObs.read(reader); + const generation = ++this._detailGeneration; + void this._detailSequencer.queue(() => this._syncDetailTarget(target, generation)).catch(onUnexpectedError); + })); + } + + private _computeDetailTarget(reader: IReader, activeEditor: EditorInput | undefined, mainPartEmptyObs: IObservable<boolean>, editorMaximizedObs: IObservable<boolean>): DetailPanelTarget { + const activeSession = this._sessionsService.activeSession.read(reader); + const isQuickChat = activeSession?.isQuickChat?.read(reader) ?? false; + const workspace = activeSession?.workspace.read(reader); + if (isQuickChat || !workspace) { + return DetailPanelTarget.Hidden; + } + + // For a created session an empty editor group means the whole side pane was + // closed, so hide the detail. Two transient-empty windows must be excluded, + // or the detail the user had open gets wrongly hidden: + // - the new-session (uncreated) view, whose Files detail is owned by the + // layout controller (D3b) while its Files tab is (re)ensured; and + // - a session-switch / submit restore, during which the working-set apply + // clears the group before the managed Changes/Files tabs are re-ensured. + // On submit the committed session flips to created with a momentarily + // empty group, so without this guard the just-opened detail is hidden. + // Leaving it as-is (Preserve) lets the managed tabs settle; the detail + // then follows the active editor. + if (mainPartEmptyObs.read(reader) && (activeSession?.isCreated.read(reader) ?? true)) { + return this._ctx.isRestoringSessionLayout ? DetailPanelTarget.Preserve : DetailPanelTarget.Hidden; + } + + if (editorMaximizedObs.read(reader)) { + return DetailPanelTarget.Changes; + } + + if (!activeEditor) { + return activeSession?.isCreated.read(reader) ? DetailPanelTarget.Changes : DetailPanelTarget.Files; + } + + if (activeEditor instanceof BrowserEditorInput) { + return DetailPanelTarget.BrowserHidden; + } + + if (this._isChangesEditor(activeEditor)) { + return DetailPanelTarget.ChangesForced; + } + + if (this._isFileEditor(activeEditor, workspace)) { + return DetailPanelTarget.FilesForced; + } + + return DetailPanelTarget.Preserve; + } + + private _isMainPartEmpty(): boolean { + for (const group of this._editorGroupsService.mainPart.groups) { + if (!group.isEmpty) { + return false; + } + } + return true; + } + + private async _syncDetailTarget(target: DetailPanelTarget, generation: number): Promise<void> { + if (generation !== this._detailGeneration) { + return; + } + + let auxBarVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + switch (target) { + case DetailPanelTarget.Hidden: + if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.BrowserHidden: + if (this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)) { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } + this._hiddenByBrowser = true; + return; + case DetailPanelTarget.Changes: + if (!auxBarVisible && this._hiddenByBrowser) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + auxBarVisible = true; + } + // Only switch the active container while the detail panel is visible so the + // user can hide it; toggling it back on then shows the contextual container. + if (!auxBarVisible) { + return; + } + await this._viewsService.openViewContainer(CHANGES_VIEW_CONTAINER_ID, false); + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.ChangesForced: + await this._syncForcedDetailTarget(CHANGES_VIEW_CONTAINER_ID, auxBarVisible); + return; + case DetailPanelTarget.Files: + if (!auxBarVisible && this._hiddenByBrowser) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + auxBarVisible = true; + } + if (!auxBarVisible) { + return; + } + await this._viewsService.openViewContainer(SESSIONS_FILES_CONTAINER_ID, false); + this._hiddenByBrowser = false; + return; + case DetailPanelTarget.FilesForced: + await this._syncForcedDetailTarget(SESSIONS_FILES_CONTAINER_ID, auxBarVisible); + return; + case DetailPanelTarget.Preserve: + this._hiddenByBrowser = false; + return; + } + } + + private async _syncForcedDetailTarget(viewContainerId: string, auxBarVisible: boolean): Promise<void> { + if (!auxBarVisible) { + // The detail panel is hidden. A created session defaults to the Changes + // editor with the detail closed, and an explicit / per-session hide is + // respected — so a Changes/file editor becoming active never + // force-reveals the detail. The one exception is restoring the detail + // after a *transient* browser-tab hide (`_hiddenByBrowser`). Never reveal + // while the whole side pane is closed (the editor content is also hidden) + // or during a session-switch layout restore. + if (!this._hiddenByBrowser + || !this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) + || this._ctx.isRestoringSessionLayout) { + return; + } + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + await this._viewsService.openViewContainer(viewContainerId, false); + this._hiddenByBrowser = false; + } + + private _isChangesEditor(editor: EditorInput): boolean { + const resource = editor.resource; + return !!resource && this._sessionChangesService.getSessionResource(resource) !== undefined; + } + + private _isFileEditor(editor: EditorInput, workspace: ISessionWorkspace): boolean { + if (editor instanceof EmptyFileEditorInput) { + return true; + } + const resource = editor instanceof FileEditorInput ? editor.resource : undefined; + return !!resource && workspace.folders.some(folder => + isEqualOrParent(resource, folder.root) || isEqualOrParent(resource, folder.workingDirectory)); + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailVisibilityStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailVisibilityStrategy.ts new file mode 100644 index 00000000000000..e8cc42eeab17bb --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneDetailVisibilityStrategy.ts @@ -0,0 +1,308 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { autorun, derived, observableFromEvent } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { StorageScope, StorageTarget, IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { CHANGES_VIEW_ID } from '../../../changes/common/changes.js'; +import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +/** Shared layout state for the new-session (untitled) view. */ +interface INewSessionViewState { + readonly auxiliaryBarVisible: boolean; +} + +/** Fresh single-pane key for the new-session view state (not shared with the classic desktop controller). */ +const SINGLE_PANE_NEW_SESSION_VIEW_STATE_KEY = 'sessions.singlePane.newSessionViewState'; + +/** + * Owns **only** the single-pane detail panel's *per-session visibility* (shown / + * hidden) — capturing the user's choice ([D1]/[D2]), restoring it on session + * switch ([D3]) by revealing/hiding the auxiliary-bar **part**, and the + * new-session submit transition ([D4]). It deliberately does **not** choose which + * container (Changes / Files) is shown, nor react to editor maximize, nor hide + * for quick chats: that is the sole responsibility of + * {@link import('./singlePaneDetailPanelStrategy.js').SinglePaneDetailPanelStrategy}, + * which maps the active editor to its container and hides the detail when there + * is nothing to show. The one exception is the submit transition, which reveals + * the Changes view as the committed session's initial content. + */ +export class SinglePaneDetailVisibilityStrategy extends SinglePaneLayoutStrategy { + + private _newSessionViewState: INewSessionViewState | undefined; + + constructor( + ctx: ISinglePaneLayoutContext, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IViewsService private readonly _viewsService: IViewsService, + @IStorageService private readonly _storageService: IStorageService, + ) { + super(ctx); + + this._loadNewSessionViewState(); + + const activeSessionIsCreatedObs = derived<boolean>(reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + return activeSession?.isCreated.read(reader) ?? false; + }); + + const activeSessionHasWorkspaceObs = derived<boolean>(reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + return activeSession?.workspace.read(reader)?.folders?.[0]?.root !== undefined; + }); + + const editorMaximizedObs = observableFromEvent(this, + this._layoutService.onDidChangeEditorMaximized, + () => this._layoutService.isEditorMaximized()); + + // Switch between sessions — restore per-session detail visibility. + let previousSessionResource: URI | undefined; + let previousIsCreated = false; + this._register(autorun(reader => { + const editorMaximized = editorMaximizedObs.read(reader); + const activeSessionResource = this._ctx.activeSessionResourceObs.read(reader); + const isCreated = activeSessionIsCreatedObs.read(reader); + + // [D5] While maximized, the detail's visibility/container is owned by the + // detail-panel strategy (it forces Changes). Skip capture/restore so the + // forced state is never recorded as the session's per-session preference; + // un-maximizing re-runs this autorun and restores the real state. + if (editorMaximized) { + previousSessionResource = activeSessionResource; + previousIsCreated = isCreated; + return; + } + + const activeSessionHasWorkspace = activeSessionHasWorkspaceObs.read(reader); + const multipleVisible = this._ctx.multipleSessionsVisibleObs.read(reader); + + if (multipleVisible) { + previousSessionResource = activeSessionResource; + previousIsCreated = isCreated; + return; + } + + // [D1] Save detail visibility for the session we're switching away from. + const isSessionSwitch = previousSessionResource !== undefined && !isEqual(previousSessionResource, activeSessionResource); + if (isSessionSwitch) { + this._captureViewState(previousSessionResource!); + } + + // [D4] Submit: a new (uncreated) session transitions to a created one. + // The classic provider commits in place (same resource); the agent-host / + // Copilot provider commits by *replacing* the draft with a new resource + // (`onDidReplaceSession`). Detect both intrinsically from the transition + // — `!previousIsCreated && isCreated` — instead of relying on the + // controller's `_onSessionReplaced`, which (being a later-registered + // listener on the same event) runs *after* this autorun has already + // hidden the detail. The `!viewStateBySession.has` guard keeps a genuine + // navigation from a draft to an *existing* created session (which has + // saved state) on the D3 restore path, so only a brand-new committed + // session (no saved state yet) is treated as a submit. + const isSubmit = previousSessionResource !== undefined + && !previousIsCreated + && isCreated + && activeSessionResource !== undefined + && !this._ctx.viewStateBySession.has(activeSessionResource); + + previousSessionResource = activeSessionResource; + previousIsCreated = isCreated; + + if (isSubmit) { + this._ctx.withSessionLayoutRestore(() => this._onNewSessionSubmitted(activeSessionResource!)); + return; + } + + // [D3] Restore the session's detail visibility. + this._ctx.withSessionLayoutRestore(() => + this._syncDetailVisibility(activeSessionResource, activeSessionHasWorkspace, isCreated) + ); + })); + + // [D2] Track detail (aux-bar) visibility changes by the user so that hiding + // the detail for a session is remembered immediately (not only on switch). + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId !== Parts.AUXILIARYBAR_PART) { + return; + } + // [D9] Toggling the whole side pane hides/shows the aux bar as a side + // effect, not as a per-session choice, so don't record it. + if (this._ctx.togglingSidePane) { + return; + } + // A restore-driven hide replays remembered state, not a user action. + if (this._ctx.hidingAuxiliaryBarForRestore) { + return; + } + // While restoring a session's layout, visibility changes triggered by the + // detail-panel strategy must not overwrite the session's intended state. + if (this._ctx.isRestoringSessionLayout) { + return; + } + if (this._ctx.multipleSessionsVisibleObs.get()) { + return; + } + // [D5] While maximized the detail is forced visible; not a user choice. + if (this._layoutService.isEditorMaximized()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession) { + return; + } + if (!activeSession.isCreated.get()) { + this._setNewSessionViewState({ auxiliaryBarVisible: e.visible }); + } else { + this._captureViewState(activeSession.resource); + } + })); + } + + // [B4] Snapshot the active session's detail visibility when persisting. + captureActiveSessionViewState(sessionResource: URI): void { + this._captureViewState(sessionResource); + } + + /** The shared new-session view's detail visibility, or `undefined` if never chosen. */ + get newSessionAuxiliaryBarVisible(): boolean | undefined { + return this._newSessionViewState?.auxiliaryBarVisible; + } + + /** + * [D9b] Records a whole-side-pane toggle for the active session. For an + * uncreated session it updates the shared new-session choice. For a created + * session, only a full collapse of a previously-visible detail is marked as a + * collapse-driven hide (so opening it later re-reveals it); any other outcome + * just captures the resulting state, preserving an explicit hide. + */ + onSidePaneToggled(collapsed: boolean, previousAuxiliaryBarVisible: boolean): void { + if (this._ctx.multipleSessionsVisibleObs.get()) { + return; + } + if (this._layoutService.isEditorMaximized()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession) { + return; + } + if (!activeSession.isCreated.get()) { + this._setNewSessionViewState({ auxiliaryBarVisible: this._layoutService.isVisible(Parts.AUXILIARYBAR_PART) }); + return; + } + if (collapsed && previousAuxiliaryBarVisible) { + this._ctx.viewStateBySession.set(activeSession.resource, { + auxiliaryBarVisible: false, + auxiliaryBarActiveViewContainerId: undefined, + auxiliaryBarHiddenByCollapse: true, + }); + return; + } + this._captureViewState(activeSession.resource); + } + + private _captureViewState(sessionResource: URI): void { + const auxiliaryBarVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + // [D9] Preserve a collapse marker while the detail stays hidden; the marker + // is only set by `onSidePaneToggled` for the session that was collapsed, so + // an explicit hide is never mistaken for a collapse. + const previous = this._ctx.viewStateBySession.get(sessionResource); + const auxiliaryBarHiddenByCollapse = !auxiliaryBarVisible && previous?.auxiliaryBarHiddenByCollapse === true; + this._ctx.viewStateBySession.set(sessionResource, { + auxiliaryBarVisible, + auxiliaryBarActiveViewContainerId: undefined, + ...(auxiliaryBarHiddenByCollapse ? { auxiliaryBarHiddenByCollapse: true } : {}), + }); + } + + private _setNewSessionViewState(state: INewSessionViewState): void { + this._newSessionViewState = state; + this._storageService.store(SINGLE_PANE_NEW_SESSION_VIEW_STATE_KEY, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + /** + * [D4] When a new (uncreated) session is submitted it becomes a real session + * while staying active. Keep the detail as the user left it (visible/hidden) + * and, when visible, reveal the Changes view as the committed session's + * initial content. The resulting visibility is persisted so later restores + * don't fall back to hidden. + */ + private _onNewSessionSubmitted(sessionResource: URI): void | Promise<unknown> { + const auxiliaryBarVisible = this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this._ctx.viewStateBySession.set(sessionResource, { + auxiliaryBarVisible, + auxiliaryBarActiveViewContainerId: undefined, + }); + if (auxiliaryBarVisible) { + return this._viewsService.openView(CHANGES_VIEW_ID, false); + } + } + + // [D3] Restore the detail panel's visibility (shown/hidden) for the session. + // The container shown when visible is chosen by the detail-panel strategy. + // Synchronous (void): the reveal is fire-and-forget so the restore epoch ends + // immediately. + private _syncDetailVisibility(sessionResource: URI | undefined, hasWorkspace: boolean, isCreated: boolean): void { + // [D3a] No resource / no workspace → do nothing. + if (!sessionResource || !hasWorkspace) { + return; + } + + // [D3b] New-session view: all uncreated sessions share one state. Default to + // the detail shown (the detail-panel strategy opens Files) unless the user + // hid it. + if (!isCreated) { + if (this._newSessionViewState && !this._newSessionViewState.auxiliaryBarVisible) { + this._ctx.hideAuxiliaryBarForRestore(); + } else { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + return; + } + + // [D3c] Existing sessions: restore the user's last explicit visibility. + // A session with NO saved state yet (a just-submitted committed session, or + // a created session seen for the first time) must be left in its current + // on-screen state — never force-hidden. Forcing a hide here re-closes the + // detail the user had open in the new-session view: on submit the committed + // session's resource can change again after the initial transition, so a + // later restore run lands here with no saved state, and the intrinsic + // submit detection ([D4]) no longer applies. The detail-panel strategy keeps + // the container in sync either way; the state is captured on the next + // switch-away or user toggle. + const savedState = this._ctx.viewStateBySession.get(sessionResource); + if (!savedState) { + return; + } + if (!savedState.auxiliaryBarVisible) { + this._ctx.hideAuxiliaryBarForRestore(); + } else { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + } + + private _loadNewSessionViewState(): void { + const newSessionRaw = this._storageService.get(SINGLE_PANE_NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + if (!newSessionRaw) { + return; + } + try { + const parsed = JSON.parse(newSessionRaw); + if (parsed && typeof parsed.auxiliaryBarVisible === 'boolean') { + this._newSessionViewState = { auxiliaryBarVisible: parsed.auxiliaryBarVisible }; + } else { + this._storageService.remove(SINGLE_PANE_NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + } + } catch { + this._storageService.remove(SINGLE_PANE_NEW_SESSION_VIEW_STATE_KEY, StorageScope.WORKSPACE); + } + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneEditorAreaCollapseStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneEditorAreaCollapseStrategy.ts new file mode 100644 index 00000000000000..638fd140b57e44 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneEditorAreaCollapseStrategy.ts @@ -0,0 +1,115 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IUntypedEditorInput } from '../../../../../workbench/common/editor.js'; +import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISinglePaneLayoutContext, SinglePaneDockedTabsCoordinator, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +/** + * When the editor area is hidden (detail-only), closes the non-managed (real + * file) editors so only the managed Changes and Files tabs remain, capturing + * them so they are reopened when the editor area is shown again. Serializes on + * the shared docked-tab sequencer so it never races the managed-tab sync. + */ +export class SinglePaneEditorAreaCollapseStrategy extends SinglePaneLayoutStrategy { + + /** Last observed editor-area visibility, to act only on transitions. */ + private _editorAreaVisible: boolean | undefined; + + constructor( + ctx: ISinglePaneLayoutContext, + private readonly _coordinator: SinglePaneDockedTabsCoordinator, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + ) { + super(ctx); + + const editorAreaVisibleObs = observableFromEvent(this, this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); + + this._register(autorun(reader => { + const visible = editorAreaVisibleObs.read(reader); + if (this._editorAreaVisible === undefined) { + this._editorAreaVisible = visible; + return; + } + if (visible === this._editorAreaVisible) { + return; + } + this._editorAreaVisible = visible; + + // Session-switch restores toggle editor-area visibility as a side effect; + // those are layout-driven, not a user hide/show, so skip them. + if (this._ctx.isRestoringSessionLayout) { + return; + } + + void this._coordinator.sequencer.queue(() => visible ? this._restoreCollapsedTabs() : this._collapseNonManagedTabs()).catch(onUnexpectedError); + })); + } + + private async _collapseNonManagedTabs(): Promise<void> { + if (this._coordinator.collapsedEditors) { + return; // already collapsed + } + + const group = this._editorGroupsService.mainPart.activeGroup; + const captured: { editor: IUntypedEditorInput; index: number }[] = []; + const toClose: EditorInput[] = []; + group.editors.forEach((editor, index) => { + if (this._coordinator.isManagedEditor(editor) || editor.isDirty()) { + return; + } + const untyped = editor.toUntyped(); + if (untyped) { + captured.push({ editor: untyped, index }); + toClose.push(editor); + } + }); + if (toClose.length === 0) { + return; + } + + this._coordinator.collapsedEditors = captured; + toClose.forEach(editor => this._coordinator.internallyClosingEditors.add(editor)); + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + await this._editorService.closeEditors(toClose.map(editor => ({ groupId: group.id, editor })), { preserveFocus: true }); + } finally { + toClose.forEach(editor => this._coordinator.internallyClosingEditors.delete(editor)); + suppressEditorPartAutoVisibility.dispose(); + } + } + + private async _restoreCollapsedTabs(): Promise<void> { + const captured = this._coordinator.collapsedEditors; + this._coordinator.collapsedEditors = undefined; + if (!captured || captured.length === 0) { + return; + } + + const group = this._editorGroupsService.mainPart.activeGroup; + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + // Reopen in ascending index order, each at its original tab position, so + // the tabs return to where they were before the editor area was hidden. + await this._editorService.openEditors( + [...captured] + .sort((a, b) => a.index - b.index) + .map(({ editor, index }) => ({ ...editor, options: { ...editor.options, index, inactive: true, preserveFocus: true, pinned: true } })), + group); + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneLayoutStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneLayoutStrategy.ts new file mode 100644 index 00000000000000..186465710ed469 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneLayoutStrategy.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Sequencer } from '../../../../../base/common/async.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IUntypedEditorInput } from '../../../../../workbench/common/editor.js'; +import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; +import { ISessionViewState } from '../baseSessionLayoutController.js'; + +/** + * Shared controller state that single-pane layout strategies read/coordinate + * through. Implemented by the single-pane layout controller; the concrete + * services each strategy needs are injected into the strategy directly via DI. + */ +export interface ISinglePaneLayoutContext { + /** `> 0` while a session-switch layout restore is in progress. */ + readonly isRestoringSessionLayout: boolean; + /** Runs `work` while a session-switch layout restore is held. */ + withSessionLayoutRestore(work: () => void | Promise<unknown>): void; + /** `true` while the whole side pane (editor + aux bar) is being toggled together. */ + readonly togglingSidePane: boolean; + readonly multipleSessionsVisibleObs: IObservable<boolean>; + readonly activeSessionResourceObs: IObservable<URI | undefined>; + /** [B3] Per-session aux-bar view state, persisted by the base controller. */ + readonly viewStateBySession: ResourceMap<ISessionViewState>; + /** `true` while a restore-driven aux-bar hide is in progress, so the [D2] capture ignores it. */ + readonly hidingAuxiliaryBarForRestore: boolean; + /** Hides the aux bar as part of restoring remembered state; the [D2] capture ignores the resulting change. */ + hideAuxiliaryBarForRestore(): void; +} + +/** Base class for a single-pane layout behaviour, owning its own disposables. */ +export abstract class SinglePaneLayoutStrategy extends Disposable { + constructor(protected readonly _ctx: ISinglePaneLayoutContext) { + super(); + } +} + +/** + * Shared state for the two docked-tab strategies (managed Changes/Files tabs and + * editor-area tab collapse): they serialize on one sequencer, share the set of + * editors the controller itself is closing (so those closes aren't mistaken for + * user dismissals), and share the captured collapsed editors. + */ +export class SinglePaneDockedTabsCoordinator extends Disposable { + + readonly sequencer = new Sequencer(); + + /** Editors the controller itself is closing, so their close is not a user dismissal. */ + readonly internallyClosingEditors = new Set<EditorInput>(); + + /** Non-managed editors closed (as reopenable inputs + tab index) while the editor area is hidden. */ + collapsedEditors: { readonly editor: IUntypedEditorInput; readonly index: number }[] | undefined; + + constructor(private readonly _sessionChangesService: ISessionChangesService) { + super(); + } + + isManagedEditor(editor: EditorInput): boolean { + return editor instanceof EmptyFileEditorInput || this.getChangesEditorResource(editor) !== undefined; + } + + getChangesEditorResource(editor: EditorInput): URI | undefined { + const resource = editor.resource; + return resource && this._sessionChangesService.getSessionResource(resource) ? resource : undefined; + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts new file mode 100644 index 00000000000000..1be29b9f07e70c --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneManagedTabsStrategy.ts @@ -0,0 +1,311 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { autorun, IReader, observableFromEvent, observableSignalFromEvent } from '../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { EditorActivation, IEditorOptions } from '../../../../../platform/editor/common/editor.js'; +import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { EditorResourceAccessor, SideBySideEditor } from '../../../../../workbench/common/editor.js'; +import { IEditorGroup, IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { SinglePaneChangesTabMissingContext, SinglePaneFilesTabMissingContext } from '../../../../common/contextkeys.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISessionChangesService } from '../../../changes/browser/sessionChangesService.js'; +import { IChangesViewService } from '../../../changes/common/changesViewService.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; +import { ISinglePaneLayoutContext, SinglePaneDockedTabsCoordinator, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +const changesEditorOptions: IEditorOptions = { + pinned: true, + index: 0, + inactive: true, + preserveFocus: true, + activation: EditorActivation.PRESERVE, + isExplicit: false, +}; + +const fileTabOptions: IEditorOptions = { + pinned: true, + inactive: true, + preserveFocus: true, + activation: EditorActivation.PRESERVE, + isExplicit: false, +}; + +interface IManagedTabTargetState { + changesSessionResource: URI | undefined; + ensureFileTab: boolean; +} + +interface IManagedFilesTabState { + readonly placeholder: EmptyFileEditorInput | undefined; + readonly shouldShow: boolean; +} + +/** + * Owns the two managed docked tabs — the pinned Changes multi-diff tab and the + * empty Files placeholder tab — keeping them in sync with the active session's + * changes. Auto-managed but user-closable: a user close is remembered so the + * sync does not immediately re-create the tab. + */ +export class SinglePaneManagedTabsStrategy extends SinglePaneLayoutStrategy { + + /** Managed tab kinds the user explicitly closed; not re-ensured until the session changes or the side pane is reopened. */ + private readonly _dismissedManagedTabs = new Set<'changes' | 'files'>(); + private _lastSyncedSessionKey: string | undefined; + private _sidePaneWasVisible = false; + private _tabSyncGeneration = 0; + /** True when the session supports a Changes editor but its tab is not currently open (drives the `+` "Changes" entry). */ + private _changesTabMissingContext: IContextKey<boolean> | undefined; + /** True when the session supports a Files tab but its tab is not currently open (drives the `+` "Files" entry). */ + private _filesTabMissingContext: IContextKey<boolean> | undefined; + + constructor( + ctx: ISinglePaneLayoutContext, + private readonly _coordinator: SinglePaneDockedTabsCoordinator, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + @ISessionChangesService private readonly _sessionChangesService: ISessionChangesService, + @IChangesViewService private readonly _changesViewService: IChangesViewService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(ctx); + + this._changesTabMissingContext = SinglePaneChangesTabMissingContext.bindTo(this._contextKeyService); + this._filesTabMissingContext = SinglePaneFilesTabMissingContext.bindTo(this._contextKeyService); + + // Re-sync the managed tabs when the session state changes, and also when the + // side pane (editor part or aux bar) visibility or the group's editors + // change. Tracking the aux bar too is essential: reopening the side pane in + // the new-session view only reveals the aux bar (the editor part stays + // hidden by R1), so without it the managed Files tab would never be + // re-ensured after the side pane was closed. + const sidePaneVisibleObs = observableFromEvent(this, this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + const editorsChangedSignal = observableSignalFromEvent(this, Event.any(this._editorService.onDidActiveEditorChange, this._editorService.onDidEditorsChange)); + + this._register(autorun(reader => { + const targetState = this._readManagedTabTargetState(reader); + sidePaneVisibleObs.read(reader); + editorsChangedSignal.read(reader); + const generation = ++this._tabSyncGeneration; + void this._coordinator.sequencer.queue(() => this._syncManagedTabs(targetState, generation)).catch(onUnexpectedError); + })); + + // A user-initiated close of a managed tab is remembered so the sync does not + // immediately re-create it. + this._register(this._editorService.onDidCloseEditor(e => this._handleManagedTabClosed(e.editor))); + } + + /** Queue work on the shared docked-tab sequencer (used by the editor-area collapse strategy). */ + queue<T>(work: () => Promise<T>): Promise<T> { + return this._coordinator.sequencer.queue(work); + } + + private _readManagedTabTargetState(reader: IReader): IManagedTabTargetState { + const session = this._sessionsService.activeSession.read(reader); + if (!session) { + return { changesSessionResource: undefined, ensureFileTab: false }; + } + + const isCreated = session.isCreated.read(reader); + const isQuickChat = session.isQuickChat?.read(reader) ?? false; + const workspace = session.workspace.read(reader); + if (isQuickChat || !workspace) { + return { changesSessionResource: undefined, ensureFileTab: false }; + } + + return { changesSessionResource: isCreated ? session.resource : undefined, ensureFileTab: true }; + } + + private async _syncManagedTabs(state: IManagedTabTargetState, generation: number): Promise<void> { + if (generation !== this._tabSyncGeneration) { + return; + } + + // Clear user-dismissed managed tabs on a session change or when the side + // pane is reopened from fully closed, so the tabs re-populate then while an + // in-session close stays respected. + const sessionKey = this._sessionsService.activeSession.get()?.resource.toString(); + const sidePaneVisible = this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + if (sessionKey !== this._lastSyncedSessionKey || (sidePaneVisible && !this._sidePaneWasVisible)) { + this._dismissedManagedTabs.clear(); + } + if (sessionKey !== this._lastSyncedSessionKey) { + // A new session has its own editors; drop any tabs captured while the + // previous session's editor area was hidden so they are not reopened here. + this._coordinator.collapsedEditors = undefined; + } + this._lastSyncedSessionKey = sessionKey; + this._sidePaneWasVisible = sidePaneVisible; + + const group = this._editorGroupsService.mainPart.activeGroup; + const changesResource = state.changesSessionResource ? this._sessionChangesService.getChangesEditorResource(state.changesSessionResource) : undefined; + + // Reconciling the managed tabs can transiently empty the group (e.g. + // closing a stale Changes tab before the Files tab is ensured, or before + // the workspace resolves on reload). Suppress editor-part auto-visibility + // across the whole reconciliation so a transient empty group is never + // mistaken for the user closing all tabs (which would close the side pane). + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + await this._closeInactiveChangesEditors(group, changesResource); + if (generation !== this._tabSyncGeneration) { + return; + } + + if (state.changesSessionResource && changesResource && !this._dismissedManagedTabs.has('changes')) { + this._changesViewService.setChangesetId(undefined); + + let changesEditor = this._findChangesEditor(group, changesResource); + if (!changesEditor) { + await this._sessionChangesService.openChangesEditor(state.changesSessionResource, changesEditorOptions, group); + if (generation !== this._tabSyncGeneration) { + return; + } + changesEditor = this._findChangesEditor(group, changesResource); + } + + if (changesEditor) { + this._ensureFirst(group, changesEditor); + } + } else if (this._dismissedManagedTabs.has('changes') && changesResource && this._findChangesEditor(group, changesResource)) { + // The Changes tab was reopened (e.g. via the `+` "Changes" entry) + // after a user dismissal; resume managing it. + this._dismissedManagedTabs.delete('changes'); + } + + if (generation !== this._tabSyncGeneration || !state.ensureFileTab) { + return; + } + + const filesTabState = this._getManagedFilesTabState(group); + if (!filesTabState.shouldShow) { + await this._removeDefaultFileTab(group, filesTabState.placeholder); + } else if (!this._dismissedManagedTabs.has('files')) { + await this._ensureDefaultFileTab(group); + } + } finally { + suppressEditorPartAutoVisibility.dispose(); + // Recompute the `+` add-tab contexts against the final group state, so + // they reflect the ensured/closed tabs rather than the pre-sync state. + if (generation === this._tabSyncGeneration) { + this._updateAddTabContexts(state); + } + } + } + + private _getManagedFilesTabState(group: IEditorGroup): IManagedFilesTabState { + const placeholder = group.editors.find((editor): editor is EmptyFileEditorInput => editor instanceof EmptyFileEditorInput); + const editorVisible = this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow); + // Only a workspace file collapses the Files placeholder; other editors + // (e.g. the integrated browser) keep it shown. + const hasWorkspaceFile = group.editors.some(editor => this._isWorkspaceFileEditor(editor)); + return { placeholder, shouldShow: !editorVisible || !hasWorkspaceFile }; + } + + /** Whether the editor shows a workspace file (a file-system resource), excluding managed placeholders. */ + private _isWorkspaceFileEditor(editor: EditorInput): boolean { + if (this._coordinator.isManagedEditor(editor)) { + return false; + } + const resource = EditorResourceAccessor.getCanonicalUri(editor, { supportSideBySide: SideBySideEditor.PRIMARY }); + return resource?.scheme === Schemas.file || resource?.scheme === Schemas.vscodeRemote; + } + + private async _removeDefaultFileTab(group: IEditorGroup, editor: EmptyFileEditorInput | undefined): Promise<void> { + if (!editor) { + return; + } + + this._coordinator.internallyClosingEditors.add(editor); + try { + await this._editorService.closeEditors([{ groupId: group.id, editor }], { preserveFocus: true }); + } finally { + this._coordinator.internallyClosingEditors.delete(editor); + } + } + + private async _ensureDefaultFileTab(group: IEditorGroup): Promise<void> { + if (group.editors.some(editor => editor instanceof EmptyFileEditorInput)) { + return; + } + + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + await this._editorService.openEditor(this._instantiationService.createInstance(EmptyFileEditorInput), fileTabOptions, group); + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } + + private async _closeInactiveChangesEditors(group: IEditorGroup, activeChangesResource: URI | undefined): Promise<void> { + const editorsToClose = group.editors.filter(editor => { + const resource = this._coordinator.getChangesEditorResource(editor); + return resource && (!activeChangesResource || !isEqual(resource, activeChangesResource)); + }); + + if (editorsToClose.length > 0) { + editorsToClose.forEach(editor => this._coordinator.internallyClosingEditors.add(editor)); + try { + await this._editorService.closeEditors(editorsToClose.map(editor => ({ groupId: group.id, editor })), { preserveFocus: true }); + } finally { + editorsToClose.forEach(editor => this._coordinator.internallyClosingEditors.delete(editor)); + } + } + } + + private _findChangesEditor(group: IEditorGroup, changesResource: URI): EditorInput | undefined { + return group.editors.find(editor => { + const resource = this._coordinator.getChangesEditorResource(editor); + return !!resource && isEqual(resource, changesResource); + }); + } + + private _ensureFirst(group: IEditorGroup, editor: EditorInput): void { + if (!group.isPinned(editor)) { + group.pinEditor(editor); + } + + if (group.getIndexOfEditor(editor) !== 0) { + group.moveEditor(editor, group, changesEditorOptions); + } + } + + /** Offer the `+` "Changes"/"Files" entries when the session supports them but their tabs are closed. */ + private _updateAddTabContexts(state: IManagedTabTargetState): void { + const group = this._editorGroupsService.mainPart.activeGroup; + const changesPresent = group.editors.some(editor => this._coordinator.getChangesEditorResource(editor) !== undefined); + this._changesTabMissingContext?.set(!!state.changesSessionResource && !changesPresent); + const filesPresent = group.editors.some(editor => editor instanceof EmptyFileEditorInput); + this._filesTabMissingContext?.set(state.ensureFileTab && !filesPresent); + } + + private _handleManagedTabClosed(editor: EditorInput): void { + // Ignore layout-driven closes (working-set apply on session switch): only a + // genuine user close should dismiss a managed tab. The controller's own + // reconciliation closes are tracked via `internallyClosingEditors`. + if (this._coordinator.internallyClosingEditors.has(editor) || this._ctx.isRestoringSessionLayout) { + return; + } + if (editor instanceof EmptyFileEditorInput) { + this._dismissedManagedTabs.add('files'); + } else if (this._coordinator.getChangesEditorResource(editor) !== undefined) { + this._dismissedManagedTabs.add('changes'); + } + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionRulesStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionRulesStrategy.ts new file mode 100644 index 00000000000000..a15c74eddc1ad0 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneNewSessionRulesStrategy.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +/** + * [R1] Keep the editor content closed by default in the new-session view. Hides + * the editor when it is revealed (or when the view is entered with the editor + * visible) from a non-explicit source with no real content. Explicit reveals + * (opening a file, toggling details off) are recorded by the workbench and + * stick; automatic reveals (working-set restore, layout races, an + * inherited-visible editor from a previous session) are re-hidden. Switching to + * a managed tab (e.g. the Files placeholder) while the editor is *already* + * visible does not hide it — only a visibility transition or entering the view + * does. + */ +export class SinglePaneNewSessionRulesStrategy extends SinglePaneLayoutStrategy { + + constructor( + ctx: ISinglePaneLayoutContext, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + ) { + super(ctx); + + const editorMaximizedObs = observableFromEvent(this, + this._layoutService.onDidChangeEditorMaximized, + () => this._layoutService.isEditorMaximized()); + const editorVisibleObs = observableFromEvent(this, + this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)); + const activeEditorObs = observableFromEvent(this, + this._editorService.onDidActiveEditorChange, + () => this._editorService.activeEditor); + + let previousEditorVisible = false; + let previousInNewSessionView = false; + this._register(autorun(reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + const inNewSessionView = !!activeSession + && !this._ctx.multipleSessionsVisibleObs.read(reader) + && !editorMaximizedObs.read(reader) + && !activeSession.isCreated.read(reader) + && activeSession.isQuickChat?.read(reader) !== true + && activeSession.workspace.read(reader)?.folders?.[0]?.root !== undefined; + + // A real user-opened editor: an actual file or the integrated browser. + // The managed empty landing tab (EmptyFileEditorInput) and "no active + // editor" are not real content, so the editor content stays hidden. + const activeEditor = activeEditorObs.read(reader); + const hasRealContent = activeEditor instanceof FileEditorInput || activeEditor instanceof BrowserEditorInput; + + const editorVisible = editorVisibleObs.read(reader); + // Hide only when the editor just *became* visible, or when the + // new-session view was just entered with the editor already visible + // (an inherited-visible editor from the previous session). Switching to + // a managed tab while the editor is already visible must not hide it. + const editorJustRevealed = editorVisible && !previousEditorVisible; + const justEnteredNewSessionView = inNewSessionView && !previousInNewSessionView; + previousEditorVisible = editorVisible; + previousInNewSessionView = inNewSessionView; + + if (!inNewSessionView || hasRealContent || !editorVisible) { + return; + } + + // Re-hide the editor from a non-explicit reveal. Entering the new-session + // view always resets to editor-closed (a stale explicit reveal from a + // previous session must not carry over). An in-session reveal is re-hidden + // only when it was automatic — an explicit reveal (opening a file, + // toggling details off, which reveals the empty editor so the side pane + // does not vanish) is respected. + const shouldHide = justEnteredNewSessionView || (editorJustRevealed && !this._layoutService.isEditorRevealedExplicitly()); + if (shouldHide) { + const suppressEditorPartAutoVisibility = this._layoutService.suppressEditorPartAutoVisibility(); + try { + this._layoutService.setPartHidden(true, Parts.EDITOR_PART); + } finally { + suppressEditorPartAutoVisibility.dispose(); + } + } + })); + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneQuickChatEditorHideStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneQuickChatEditorHideStrategy.ts new file mode 100644 index 00000000000000..45425957425e80 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneQuickChatEditorHideStrategy.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { Event } from '../../../../../base/common/event.js'; +import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; +import { IEditorGroupsService } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +/** + * A quick chat has no side pane (no workspace, Changes/Files gated off). The + * detail panel target is `Hidden` (aux bar hidden), but the docked editor part + * can still be left visible when switching in from a session that had it open. + * Hide the editor part while a quick chat's editor group is empty so the whole + * side pane collapses and the chat is full-width. Gated on emptiness so a real + * editor (e.g. the integrated browser) opened in a quick chat is never hidden. + */ +export class SinglePaneQuickChatEditorHideStrategy extends SinglePaneLayoutStrategy { + + constructor( + ctx: ISinglePaneLayoutContext, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + @IEditorGroupsService private readonly _editorGroupsService: IEditorGroupsService, + ) { + super(ctx); + + const mainPartEmptyObs = observableFromEvent(this, + Event.any(this._editorService.onDidActiveEditorChange, this._editorService.onDidEditorsChange, this._editorService.onDidCloseEditor), + () => this._isMainPartEmpty()); + + this._register(autorun(reader => { + const activeSession = this._sessionsService.activeSession.read(reader); + const isQuickChat = activeSession?.isQuickChat?.read(reader) ?? false; + if (!isQuickChat || !mainPartEmptyObs.read(reader)) { + return; + } + if (!this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + return; + } + const suppression = this._layoutService.suppressEditorPartAutoVisibility(); + try { + this._layoutService.setPartHidden(true, Parts.EDITOR_PART); + } finally { + suppression.dispose(); + } + })); + } + + private _isMainPartEmpty(): boolean { + for (const group of this._editorGroupsService.mainPart.groups) { + if (!group.isEmpty) { + return false; + } + } + return true; + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts new file mode 100644 index 00000000000000..9cfdb5769a49e3 --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePane/singlePaneResponsiveSidebarStrategy.ts @@ -0,0 +1,181 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mainWindow } from '../../../../../base/browser/window.js'; +import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { autorun, observableFromEvent } from '../../../../../base/common/observable.js'; +import { localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { AuxiliaryBarVisibleContext, IsAuxiliaryWindowContext, IsSessionsWindowContext, IsTopRightEditorGroupContext, MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; +import { DOCK_DETAIL_PANEL_SETTING } from '../../../../common/sessionConfig.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { ISinglePaneLayoutContext, SinglePaneLayoutStrategy } from './singlePaneLayoutStrategy.js'; + +/** Command that toggles the single-pane detail panel (auxiliary bar) from the editor title bar. */ +export const TOGGLE_DETAILS_COMMAND_ID = 'workbench.action.agentSessions.toggleDetails'; +// Toggle Details renders in the editor-title layout cluster, after maximize/restore. +const singlePaneLayoutToggleDetailsOrder = 30; + +/** + * [D7 single-pane] Auto-hide the sessions list when the user needs more room for + * the side pane: opening the details pane via the Toggle Details action, or + * opening a real file/diff into the editor area (Scenario 8). The list is + * restored when details is explicitly closed or the side pane is fully hidden. + * Unlike the base responsive rule this is not window-size driven and never + * reacts to automatic details opens (submit, session restore). Also owns the + * Toggle Details action itself. + */ +export class SinglePaneResponsiveSidebarStrategy extends SinglePaneLayoutStrategy { + + /** `true` while the sessions list is hidden because this strategy auto-hid it; only such hides are auto-reverted. */ + private _sidebarAutoHidden = false; + /** Guards the manual-toggle listener while this strategy itself toggles the sidebar. */ + private _applyingAutoSidebar = false; + + constructor( + ctx: ISinglePaneLayoutContext, + @IAgentWorkbenchLayoutService private readonly _layoutService: IAgentWorkbenchLayoutService, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IEditorService private readonly _editorService: IEditorService, + ) { + super(ctx); + + // The Toggle Details action toggles the detail panel and, as part of the + // same gesture, auto-hides / restores the sessions list. It is a dedicated + // command owned by this strategy rather than a listener on the core aux-bar + // toggle command. + this._register(this._registerToggleDetailsAction()); + + // [Scenario 8] Opening a real file/browser editor from the Files or Changes + // view needs editor-area room, so auto-hide the sessions list — but only in + // an existing (created) session and only when the editor area is currently + // closed (this open will reveal it). Managed tabs (the Changes multi-diff + // and the empty Files placeholder) are not FileEditorInput/BrowserEditorInput + // so they never trigger this; a session-switch restore is excluded too. + this._register(this._editorService.onWillOpenEditor(e => { + if (this._ctx.isRestoringSessionLayout || this._ctx.multipleSessionsVisibleObs.get() || this._layoutService.isEditorMaximized()) { + return; + } + const activeSession = this._sessionsService.activeSession.get(); + if (!activeSession?.isCreated.get() || this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow)) { + return; + } + if (!(e.editor instanceof FileEditorInput || e.editor instanceof BrowserEditorInput)) { + return; + } + if (this._setSidebarAutoHidden(true)) { + this._sidebarAutoHidden = true; + } + })); + + // A manual sessions-sidebar toggle hands control back to the user. + this._register(this._layoutService.onDidChangePartVisibility(e => { + if (e.partId !== Parts.SIDEBAR_PART || this._applyingAutoSidebar) { + return; + } + this._sidebarAutoHidden = false; + })); + + // Restore an auto-collapsed sessions list once the side pane is fully + // hidden — there is no side pane to make room for anymore. This covers + // closing the whole side pane and switching to a session with no side pane + // (a quick chat), so the list is never left collapsed while the side pane + // is hidden. `observableFromEvent` dedupes on the computed value, so hiding + // the sidebar itself (a different part) never re-triggers this, and the + // pre-reveal auto-hide from opening an editor is not undone. + const sidePaneVisibleObs = observableFromEvent(this, + this._layoutService.onDidChangePartVisibility, + () => this._layoutService.isVisible(Parts.EDITOR_PART, mainWindow) || this._layoutService.isVisible(Parts.AUXILIARYBAR_PART)); + this._register(autorun(reader => { + if (sidePaneVisibleObs.read(reader) || !this._sidebarAutoHidden) { + return; + } + this._setSidebarAutoHidden(false); + this._sidebarAutoHidden = false; + })); + } + + /** + * Toggle the detail panel (auxiliary bar) and, in the same gesture, auto-hide + * the sessions list to free room when opening it (restoring the list when + * closing). Returns whether the detail panel is now visible. + */ + toggleDetails(): boolean { + const nowVisible = !this._layoutService.isVisible(Parts.AUXILIARYBAR_PART); + this._layoutService.setPartHidden(!nowVisible, Parts.AUXILIARYBAR_PART); + + if (!this._ctx.multipleSessionsVisibleObs.get()) { + if (nowVisible) { + if (this._setSidebarAutoHidden(true)) { + this._sidebarAutoHidden = true; + } + } else if (this._sidebarAutoHidden) { + this._setSidebarAutoHidden(false); + this._sidebarAutoHidden = false; + } + } + return nowVisible; + } + + private _setSidebarAutoHidden(hidden: boolean): boolean { + if (this._layoutService.isVisible(Parts.SIDEBAR_PART) === !hidden) { + return false; + } + this._applyingAutoSidebar = true; + try { + this._layoutService.setPartHidden(hidden, Parts.SIDEBAR_PART); + } finally { + this._applyingAutoSidebar = false; + } + return true; + } + + private _registerToggleDetailsAction(): IDisposable { + const that = this; + return registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_DETAILS_COMMAND_ID, + title: localize2('toggleDetails', "Toggle Details"), + icon: Codicon.listSelection, + f1: false, + toggled: AuxiliaryBarVisibleContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + primary: KeyMod.CtrlCmd | KeyMod.Alt | KeyCode.KeyL, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true)) + }, + menu: { + id: MenuId.EditorTitleLayout, + group: 'navigation', + order: singlePaneLayoutToggleDetailsOrder, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + IsAuxiliaryWindowContext.toNegated(), + IsTopRightEditorGroupContext, + ContextKeyExpr.equals(`config.${DOCK_DETAIL_PANEL_SETTING}`, true), + MainEditorAreaVisibleContext) + } + }); + } + + run(): void { + that.toggleDetails(); + } + }); + } +} diff --git a/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts b/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts new file mode 100644 index 00000000000000..0fd646b320641c --- /dev/null +++ b/src/vs/sessions/contrib/layout/browser/singlePaneLayoutController.ts @@ -0,0 +1,223 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { LifecyclePhase } from '../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { BaseLayoutController } from './baseSessionLayoutController.js'; +import { SinglePaneDetailPanelStrategy } from './singlePane/singlePaneDetailPanelStrategy.js'; +import { SinglePaneDetailVisibilityStrategy } from './singlePane/singlePaneDetailVisibilityStrategy.js'; +import { SinglePaneEditorAreaCollapseStrategy } from './singlePane/singlePaneEditorAreaCollapseStrategy.js'; +import { ISinglePaneLayoutContext, SinglePaneDockedTabsCoordinator } from './singlePane/singlePaneLayoutStrategy.js'; +import { SinglePaneManagedTabsStrategy } from './singlePane/singlePaneManagedTabsStrategy.js'; +import { SinglePaneNewSessionRulesStrategy } from './singlePane/singlePaneNewSessionRulesStrategy.js'; +import { SinglePaneQuickChatEditorHideStrategy } from './singlePane/singlePaneQuickChatEditorHideStrategy.js'; +import { SinglePaneResponsiveSidebarStrategy } from './singlePane/singlePaneResponsiveSidebarStrategy.js'; + +export { TOGGLE_DETAILS_COMMAND_ID } from './singlePane/singlePaneResponsiveSidebarStrategy.js'; + +/** Fresh single-pane key for the per-session layout state (not shared with the classic desktop controller). */ +const SINGLE_PANE_LAYOUT_STATE_KEY = 'sessions.singlePane.layoutState'; + +/** + * Layout controller for the single-pane detail-panel layout. A sibling of the + * classic {@link import('./desktopSessionLayoutController.js').LayoutController} + * (both extend {@link BaseLayoutController}), it owns its behaviour through + * composed strategy objects rather than desktop inheritance: + * - auxiliary-bar per-session state ([D1]-[D5]) and empty-aux cleanup ([D10]); + * - managed docked tabs (pinned Changes multi-diff + empty Files placeholder) + * and editor-area tab collapse; + * - the detail panel mapping (active editor → Changes/Files container); + * - the responsive sessions-list auto-hide + Toggle Details action; + * - the new-session editor-hide rule ([R1]) and quick-chat editor hide. + * + * Strategies coordinate through this controller (the {@link ISinglePaneLayoutContext}): + * a session-switch restore is signalled by {@link _isRestoringSessionLayout}, so + * a restore-driven editor change is never mistaken for a user action. + */ +export class SinglePaneLayoutController extends BaseLayoutController { + + private _context: ISinglePaneLayoutContext | undefined; + private _detailVisibility: SinglePaneDetailVisibilityStrategy | undefined; + private _responsiveSidebar: SinglePaneResponsiveSidebarStrategy | undefined; + + /** `true` while a restore-driven aux-bar hide is in progress, so the [D2] capture ignores it. */ + private _hidingAuxiliaryBarForRestore = false; + + protected override get _layoutStateStorageKey(): string { + return SINGLE_PANE_LAYOUT_STATE_KEY; + } + + protected override get _legacyWorkingSetsStorageKey(): string | undefined { + return undefined; + } + + private get _ctx(): ISinglePaneLayoutContext { + if (!this._context) { + const that = this; + this._context = { + get isRestoringSessionLayout() { return that._isRestoringSessionLayout; }, + withSessionLayoutRestore: work => that._withSessionLayoutRestore(work), + get togglingSidePane() { return that._togglingSidePane; }, + get multipleSessionsVisibleObs() { return that.multipleSessionsVisibleObs; }, + get activeSessionResourceObs() { return that.activeSessionResourceObs; }, + get viewStateBySession() { return that._viewStateBySession; }, + get hidingAuxiliaryBarForRestore() { return that._hidingAuxiliaryBarForRestore; }, + hideAuxiliaryBarForRestore: () => that._hideAuxiliaryBarForRestore(), + }; + } + return this._context; + } + + // --- Auxiliary bar state + empty-aux cleanup + responsive sidebar + R1 --- + + protected override _registerViewStateManagement(): void { + this._detailVisibility = this._register(this._instantiationService.createInstance(SinglePaneDetailVisibilityStrategy, this._ctx)); + // The detail-panel strategy owns which container (Changes/Files) is shown + // and the "nothing to show" hide. It only reads the active editor and opens + // containers, so it registers immediately (not deferred like the managed + // tabs) — the detail-visibility strategy reveals the part and this strategy + // fills it with the right container in the same turn. + this._register(this._instantiationService.createInstance(SinglePaneDetailPanelStrategy, this._ctx)); + this._responsiveSidebar = this._register(this._instantiationService.createInstance(SinglePaneResponsiveSidebarStrategy, this._ctx)); + this._register(this._instantiationService.createInstance(SinglePaneNewSessionRulesStrategy, this._ctx)); + } + + // --- Managed tabs + detail panel (deferred to Restored so they reconcile on top of the restored group) --- + + protected override _registerAuxiliaryControllers(): void { + this._lifecycleService.when(LifecyclePhase.Restored).then(() => { + if (this._store.isDisposed) { + return; + } + const coordinator = this._register(new SinglePaneDockedTabsCoordinator(this._sessionChangesService)); + + // Managed tabs (Changes multi-diff, Files placeholder) surface their + // content in the detail panel, so opening them must not reveal the editor + // area. Own that policy here rather than hardcoding editor ids in the core + // workbench. + this._register(this._layoutService.setEditorRevealOnOpenExclusion(editor => coordinator.isManagedEditor(editor))); + + this._register(this._instantiationService.createInstance(SinglePaneManagedTabsStrategy, this._ctx, coordinator)); + this._register(this._instantiationService.createInstance(SinglePaneEditorAreaCollapseStrategy, this._ctx, coordinator)); + this._register(this._instantiationService.createInstance(SinglePaneQuickChatEditorHideStrategy, this._ctx)); + }); + } + + /** + * Toggle the detail panel (auxiliary bar) and, in the same gesture, auto-hide + * the sessions list to free room. Returns whether the detail panel is now visible. + */ + toggleDetails(): boolean { + return this._responsiveSidebar?.toggleDetails() ?? false; + } + + // --- Base hooks --- + + /** + * With no remembered state, a created session re-opens to the Changes editor + * with the detail panel closed; a new-session view re-opens to the Files detail + * (its editor content stays hidden by R1). + */ + protected override _defaultReopenSidePaneParts(): { readonly editor: boolean; readonly auxiliaryBar: boolean } { + if (this._sessionsService.activeSession.get()?.isCreated.get() === false) { + return { editor: false, auxiliaryBar: true }; + } + return { editor: true, auxiliaryBar: false }; + } + + /** + * A session-switch restore closes/opens the docked editors (empty working-set + * apply, managed-tab reconciliation), so suppress editor-part auto-visibility + * for the whole restore to avoid closing the side pane or mistaking a + * layout-driven close for a user dismissing a managed tab. + */ + protected override _suppressEditorVisibilityDuringRestore(): IDisposable | undefined { + return this._layoutService.suppressEditorPartAutoVisibility(); + } + + /** + * The docked editor lives in the grid even when `useModal` is `'all'`, and a + * created session shows the docked Changes editor by default (Editor-only), so + * reveal the editor part for a created session unless it was explicitly hidden. + * New-session views keep their editor closed (R1), so they are excluded. Quick + * chats have no side pane at all, so their editor part is never auto-revealed. + */ + protected override _shouldRevealEditorPartOnApply(editorPartHidden: boolean, _isModal: boolean): boolean { + const activeSession = this._sessionsService.activeSession.get(); + const isCreatedSession = activeSession?.isCreated.get() ?? false; + const isQuickChat = activeSession?.isQuickChat?.get() ?? false; + return !editorPartHidden && isCreatedSession && !isQuickChat; + } + + /** A created single-pane session with no saved editors still shows its managed Changes editor. */ + protected override _shouldRevealEditorPartForEmptyWorkingSet(revealEditorPart: boolean): boolean { + return revealEditorPart; + } + + /** + * A created single-pane session that had its docked editor closed (Detail-only + * or whole side pane closed) must be restored to that state on switch — the + * editor part is actively hidden rather than left visible from the previous + * session. New-session views (R1) and quick chats are handled separately. + */ + protected override _shouldHideEditorPartOnApply(editorPartHidden: boolean): boolean { + const activeSession = this._sessionsService.activeSession.get(); + const isCreatedSession = activeSession?.isCreated.get() ?? false; + const isQuickChat = activeSession?.isQuickChat?.get() ?? false; + return editorPartHidden && isCreatedSession && !isQuickChat; + } + + // [B4] Snapshot the active session's aux-bar state when persisting. + protected override _captureActiveSessionViewState(sessionResource: URI): void { + this._detailVisibility?.captureActiveSessionViewState(sessionResource); + } + + // [D9b] Record a whole-side-pane toggle for the active session. + protected override _onSidePaneToggled(collapsed: boolean, previousAuxiliaryBarVisible: boolean): void { + this._detailVisibility?.onSidePaneToggled(collapsed, previousAuxiliaryBarVisible); + } + + /** + * On new-session submit the base transfers the draft's editor-part visibility + * to the committed session. The **active** submit's detail (aux-bar) state is + * handled reactively by {@link SinglePaneDetailVisibilityStrategy} (it detects + * the transition intrinsically, before this later-firing listener runs). Here + * we only need to cover a **background** submit — a new session committed while + * a *different* session is active — by seeding the committed session's detail + * state from the shared new-session choice so it restores correctly on switch. + */ + protected override _onSessionReplaced(from: ISession, to: ISession): void { + super._onSessionReplaced(from, to); + + const activeSession = this._sessionsService.activeSession.get(); + const replacedSessionIsActive = isEqual(activeSession?.resource, from.resource) || isEqual(activeSession?.resource, to.resource); + if (replacedSessionIsActive) { + return; + } + + const auxiliaryBarVisible = this._detailVisibility?.newSessionAuxiliaryBarVisible; + if (auxiliaryBarVisible === undefined) { + return; + } + + this._viewStateBySession.set(to.resource, { + auxiliaryBarVisible, + auxiliaryBarActiveViewContainerId: undefined, + }); + } + + private _hideAuxiliaryBarForRestore(): void { + this._hidingAuxiliaryBarForRestore = true; + try { + this._layoutService.setPartHidden(true, Parts.AUXILIARYBAR_PART); + } finally { + this._hidingAuxiliaryBarForRestore = false; + } + } +} diff --git a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts index 3c712a56632891..12e806f513e814 100644 --- a/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/baseSessionLayoutController.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -106,11 +107,12 @@ suite('BaseLayoutController', () => { const session2 = makeSession(URI.parse('session:2')); // Session 1 keeps editors open but the user hid the editor part (e.g. by - // closing the Side Panel). + // closing the Side Panel). The [B2] listener captures this eagerly. harness.visibleEditorsList = [{}]; harness.activeSessionObs.set(session1, undefined); await timeout(0); harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); // Switch away (captures session 1's working set + hidden editor part)… harness.activeSessionObs.set(session2, undefined); @@ -135,11 +137,13 @@ suite('BaseLayoutController', () => { const session2 = makeSession(URI.parse('session:2')); // Two sessions visible at once: the editor area is shared, so its - // visibility is not a per-session choice. + // visibility is not a per-session choice — the [B2] listener must skip + // capturing it even when the editor part visibility changes. harness.visibleEditorsList = [{}]; harness.visibleSessionsObs.set([session1, session2], undefined); harness.activeSessionObs.set(session1, undefined); harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); // Persist on shutdown. harness.storageService.testEmitWillSaveState(WillSaveStateReason.SHUTDOWN); @@ -151,6 +155,83 @@ suite('BaseLayoutController', () => { assert.strictEqual(entry.editorPartHidden, undefined, 'editor part hidden state must not be captured while multiple sessions are visible'); }); + test('[B2] restores the working set on switch without forcing the editor part visible in modal mode', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + + // `useModal: 'all'` — editors are otherwise forced modal, but browser + // tabs still dock in the shared grid editor part, so working sets must + // still be captured/restored per session on switch. + createController({ useModal: 'all', workspaceFolders }); + + const session1 = makeSession(URI.parse('session:1')); + const session2 = makeSession(URI.parse('session:2')); + + harness.visibleEditorsList = [{}]; + harness.activeSessionObs.set(session1, undefined); + await timeout(0); + + // Switch away (captures session 1's working set)… + harness.activeSessionObs.set(session2, undefined); + await timeout(0); + + // …and back: the working set is restored, but the editor part is never + // force-revealed in modal mode (modal editors manage their own visibility). + harness.applyWorkingSetCalls = []; + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(session1, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.applyWorkingSetCalls, + [{ id: `session-working-set:${session1.resource.toString()}`, name: `session-working-set:${session1.resource.toString()}` }], + 'working set should be restored on switch in modal mode' + ); + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'editor part should not be revealed in modal mode' + ); + }); + + test('[B2] saves the outgoing session working set eagerly even when the incoming session workspace is not ready', async () => { + // The workspace-gated `activeSessionForWorkingSet` derive holds back while + // the incoming session's workspace folders resolve. The outgoing session's + // working set must still be saved eagerly (on the raw active session change) + // so it — including which editor was active — is restored on return, rather + // than being lost because another autorun closed its editors during the lag. + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createController({ useModal: 'some', workspaceFolders }); + + const session1 = makeSession(URI.parse('session:1')); + // Session 2's workspace folder is not (yet) registered, so the gated derive + // holds back and never fires an apply for it. + const session2 = makeSession(URI.parse('session:2'), { + workspace: { + uri: URI.file('/other'), + label: 'other', + icon: Codicon.repo, + folders: [{ root: URI.file('/other'), workingDirectory: URI.file('/other'), name: 'other', description: undefined, gitRepository: undefined }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }, + }); + + harness.visibleEditorsList = [{}]; + harness.activeSessionObs.set(session1, undefined); + await timeout(0); + + harness.saveWorkingSetCalls = []; + harness.applyWorkingSetCalls = []; + harness.activeSessionObs.set(session2, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.saveWorkingSetCalls, + [`session-working-set:${session1.resource.toString()}`], + 'the outgoing session working set should be saved eagerly despite the gated apply holding back' + ); + assert.deepStrictEqual(harness.applyWorkingSetCalls, [], 'the gated apply should hold back while the incoming workspace is not ready'); + }); + // --- [B3] Persistence & migration / [B4] Save --- test('[B3] migrates legacy sessions.workingSets key and [B4] persists to sessions.layoutState', () => { diff --git a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts index e09711c3361ae8..22bd2919fe312b 100644 --- a/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts +++ b/src/vs/sessions/contrib/layout/test/browser/desktopSessionLayoutController.test.ts @@ -5,18 +5,33 @@ import assert from 'assert'; import { timeout } from '../../../../../base/common/async.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ISettableObservable } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { MainEditorAreaVisibleContext } from '../../../../../workbench/common/contextkeys.js'; import { StorageScope, WillSaveStateReason } from '../../../../../platform/storage/common/storage.js'; import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { ViewContainerLocation } from '../../../../../workbench/common/views.js'; import { ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { SinglePaneChangesTabMissingContext, SinglePaneDetailChangesOrFilesActiveContext, SinglePaneFilesTabMissingContext } from '../../../../common/contextkeys.js'; +import { BrowserEditorInput } from '../../../../../workbench/contrib/browserView/common/browserEditorInput.js'; +import { FileEditorInput } from '../../../../../workbench/contrib/files/browser/editors/fileEditorInput.js'; +import { EmptyFileEditorInput } from '../../../editor/browser/emptyFileEditorInput.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IEditorWillOpenEvent, isResourceEditorInput } from '../../../../../workbench/common/editor.js'; import { LayoutController } from '../../browser/desktopSessionLayoutController.js'; +import { SinglePaneLayoutController, TOGGLE_DETAILS_COMMAND_ID } from '../../browser/singlePaneLayoutController.js'; import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID } from '../../../changes/common/changes.js'; +import '../../../changes/browser/changesActions.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; -import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession } from './layoutControllerTestUtils.js'; +import { NewChangesTabAction, NewFileTabAction } from '../../../editor/browser/addTabActions.js'; +import { createTestHarness, ICreateOptions, ITestLayoutHarness, makeChange, makeSession, TestStubEditorInput } from './layoutControllerTestUtils.js'; suite('LayoutController (desktop)', () => { @@ -27,6 +42,25 @@ suite('LayoutController (desktop)', () => { getViewState(sessionResource: URI) { return this._viewStateBySession.get(sessionResource); } + getEditorPartHidden(sessionResource: URI): boolean | undefined { + return this._editorPartHiddenBySession.get(sessionResource); + } + runWithRestore(work: () => void | Promise<unknown>): void { + this._withSessionLayoutRestore(work); + } + } + + class TestSinglePaneController extends SinglePaneLayoutController { + /** Runs `work` while a session-switch layout restore is held (see `_withSessionLayoutRestore`). */ + runWithRestore(work: () => void | Promise<unknown>): void { + this._withSessionLayoutRestore(work); + } + getViewState(sessionResource: URI) { + return this._viewStateBySession.get(sessionResource); + } + getEditorPartHidden(sessionResource: URI): boolean | undefined { + return this._editorPartHiddenBySession.get(sessionResource); + } } function createController(options: ICreateOptions = {}): TestLayoutController { @@ -34,6 +68,11 @@ suite('LayoutController (desktop)', () => { return store.add(harness.instaService.createInstance(TestLayoutController)); } + function createSinglePaneController(options: ICreateOptions = {}): TestSinglePaneController { + harness = createTestHarness(store, options); + return store.add(harness.instaService.createInstance(TestSinglePaneController)); + } + teardown(() => store.clear()); ensureNoDisposablesAreLeakedInTestSuite(); @@ -188,7 +227,423 @@ suite('LayoutController (desktop)', () => { ); }); - // --- [D4] New-session submit --- + test('[D3c/single-pane] restores aux-bar hidden state even when external reveal fires during working-set apply', async () => { + // Scenario 4: Session A (created) has detail closed (aux-bar hidden, editor visible). + // Session B (created) has both visible. Switch A->B, then B->A. External component + // (the single-pane detail panel) reveals aux-bar during working-set restore. A's + // hidden state must still be restored. + createSinglePaneController(); + const sessionA = makeSession(URI.parse('session:a')); + const sessionB = makeSession(URI.parse('session:b')); + + // Session A active, user hides the detail panel (aux-bar) while editor is open. + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.partVisibility.set(Parts.EDITOR_PART, true); + + // Switch to session B (both editor and aux-bar visible). + harness.activeSessionObs.set(sessionB, undefined); + harness.visibleSessionsObs.set([sessionB], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + // Simulate the single-pane detail panel revealing the aux-bar during + // working-set restore (while _isRestoringSessionLayout is true). + harness.onApplyWorkingSet = () => { + if (!harness.partVisibility.get(Parts.AUXILIARYBAR_PART)) { + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + } + }; + + // Switch back to session A. + harness.setPartHiddenCalls = []; + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + + // Clean up hook. + harness.onApplyWorkingSet = undefined; + + // The aux-bar should be hidden to match session A's saved state. The external + // reveal during working-set apply must NOT overwrite the per-session state. + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar should be hidden when returning to session A (detail-closed state)' + ); + }); + + test('[single-pane] restores the detail panel after a browser tab hides it', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + const isChangesOrFilesActive = () => harness.contextKeyService.getContextKeyValue(SinglePaneDetailChangesOrFilesActiveContext.key); + + assert.strictEqual(isChangesOrFilesActive(), false, 'hidden target should clear the editor chevron context'); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + assert.strictEqual(isChangesOrFilesActive(), true, 'changes target should enable the editor chevron context'); + + const browserEditor = Object.create(BrowserEditorInput.prototype) as BrowserEditorInput; + Object.defineProperty(browserEditor, 'resource', { value: URI.parse('browser://test') }); + + harness.activeEditorInput = browserEditor; + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(isChangesOrFilesActive(), false, 'browser target should clear the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'browser tabs should hide the detail panel' + ); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + assert.strictEqual(isChangesOrFilesActive(), true, 'files target should enable the editor chevron context'); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'file tabs should restore the detail panel after browser hides it' + ); + assert.ok( + harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + 'file tabs should reopen the Files container after browser hides it' + ); + }); + + test('[single-pane] hides the detail panel when the main editor part is empty and keeps it closed on tab open', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + const isChangesOrFilesActive = () => harness.contextKeyService.getContextKeyValue(SinglePaneDetailChangesOrFilesActiveContext.key); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + assert.strictEqual(isChangesOrFilesActive(), true, 'non-empty no-active-editor fallback should keep contextual detail active'); + + harness.setPartHiddenCalls = []; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.editorGroupsHaveContent = false; + harness.activeEditorInput = undefined; + harness.onDidEditorsChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + isChangesOrFilesActive: isChangesOrFilesActive(), + hiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true).length, + }, { + isChangesOrFilesActive: false, + hiddenCalls: 1, + }); + + // A tab re-opens: the context key flips back on, but the detail is NOT + // force-revealed (a created session defaults to the editor with the detail closed). + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.editorGroupsHaveContent = true; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidEditorsChange.fire(); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + isChangesOrFilesActive: isChangesOrFilesActive(), + reveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }, { + isChangesOrFilesActive: true, + reveals: 0, + openedFiles: false, + }); + }); + + test('[cmd+n] keeps the detail panel visible for a new-session view with a transiently empty editor group', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + // The Files tab is being (re)ensured, so the editor group is transiently empty. + harness.editorGroupsHaveContent = false; + harness.activeEditorInput = undefined; + harness.onDidEditorsChange.fire(); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + // The detail must NOT be hidden for the new-session view (unlike a created + // session, where an empty group means the whole side pane was closed). + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true).length, + 0); + }); + + test('[single-pane] keeps the detail panel closed by default when a file/changes editor is active', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Detail closed (the created-session default, not a browser-tab hide). + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + await timeout(0); + + // A file tab becomes active: the detail must stay closed (no force-reveal). + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + reveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }, { + reveals: 0, + openedFiles: false, + }); + }); + + test('[per-session detail] does not force-reveal the detail on editor activation, during or after a restore', async () => { + const controller = createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Session's detail is closed (the created-session default) with its editor visible. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.partVisibility.set(Parts.EDITOR_PART, true); + await timeout(0); + + // Hold a session-switch restore open. The restore makes the Files editor + // active; that editor change must NOT reveal the detail. + let releaseRestore!: () => void; + const restoreGate = new Promise<void>(resolve => { releaseRestore = resolve; }); + controller.runWithRestore(() => restoreGate); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0, + 'the detail must stay closed during a session-switch restore'); + + // After the restore ends, a plain editor activation still does not reveal + // the detail (a created session defaults to the editor with the detail closed). + releaseRestore(); + await restoreGate; + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0, + 'the detail stays closed by default after the restore'); + }); + + test('[Scenario C] does not re-reveal the detail on reload when the whole side pane was closed', async () => { + createSinglePaneController({ activateAux: true }); + await timeout(0); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + // Whole side pane closed (as persisted across a reload): both the editor + // content and the detail are hidden. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + await timeout(0); + + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + + // The restored managed tab becomes active; the detail must NOT re-reveal. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.strictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false).length, + 0); + }); + + test('[per-session detail] keeps the whole side pane closed when returning to a session that had it closed', async () => { + // Session A had the whole side pane closed (editor + detail hidden). Switch + // to session B (side pane open), then back to A. The detail panel must not + // re-reveal A's aux bar: returning to A must restore its closed side pane. + createSinglePaneController({ activateAux: true, revealAuxiliaryBarOnOpen: true, workspaceFolders: [{ uri: URI.file('/repo') }] }); + await timeout(0); + const sessionA = makeSession(URI.parse('session:a')); + const sessionB = makeSession(URI.parse('session:b')); + + // Session A active with the whole side pane closed. + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + await timeout(0); + + // Switch to session B (side pane open). + harness.activeSessionObs.set(sessionB, undefined); + harness.visibleSessionsObs.set([sessionB], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + // Switch back to A. The restore makes A's managed file editor active while + // B's aux bar is still visible (the detail autorun captures it as visible). + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + aux: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + editor: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + aux: false, + editor: false, + }); + }); + + test('[per-session detail] restores detail visibility on session switch despite a stale queued detail sync', async () => { + createSinglePaneController({ activateAux: true, revealAuxiliaryBarOnOpen: true, workspaceFolders: [{ uri: URI.file('/repo') }] }); + await timeout(0); + const sessionA = makeSession(URI.parse('session:a')); + const sessionB = makeSession(URI.parse('session:b')); + + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + await timeout(0); + + harness.activeSessionObs.set(sessionB, undefined); + harness.visibleSessionsObs.set([sessionB], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + await timeout(0); + + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(sessionA, undefined); + harness.visibleSessionsObs.set([sessionA], undefined); + await timeout(0); + + assert.strictEqual(harness.partVisibility.get(Parts.AUXILIARYBAR_PART), false, + 'session A detail-hidden state must win over any queued detail-container sync'); + }); + + test('[per-session detail] persists detail visibility and restores it after reload', async () => { + let controller = createSinglePaneController({ activateAux: true }); + await timeout(0); + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + harness.storageService.flush(WillSaveStateReason.SHUTDOWN); + const persisted = JSON.parse(harness.storageService.get('sessions.singlePane.layoutState', StorageScope.WORKSPACE) ?? '[]') as { sessionResource: string; viewState?: { auxiliaryBarVisible: boolean } }[]; + assert.deepStrictEqual(persisted.map(entry => ({ sessionResource: entry.sessionResource, auxiliaryBarVisible: entry.viewState?.auxiliaryBarVisible })), + [{ sessionResource: session.resource.toString(), auxiliaryBarVisible: false }]); + + store.clear(); + + controller = createSinglePaneController({ + activateAux: true, + revealAuxiliaryBarOnOpen: true, + initialPartVisibility: new Map([[Parts.AUXILIARYBAR_PART, true], [Parts.EDITOR_PART, true]]), + layoutState: persisted, + }); + harness.activeSessionObs.set(session, undefined); + await timeout(0); + + assert.deepStrictEqual({ + auxVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + viewState: controller.getViewState(session.resource)?.auxiliaryBarVisible, + }, { + auxVisible: false, + viewState: false, + }); + }); + + test('[B2] captures editor-part hidden state eagerly when the user closes the side pane', () => { + const controller = createController(); + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + + // User closes the side pane (editor part hidden) while on the session. + setPartVisible(Parts.EDITOR_PART, false); + + assert.strictEqual(controller.getEditorPartHidden(session.resource), true, + 'editor-part hidden must be captured at the moment the user closes it'); + + // User reopens it. + setPartVisible(Parts.EDITOR_PART, true); + assert.strictEqual(controller.getEditorPartHidden(session.resource), false, + 'editor-part hidden must update when the user reopens it'); + }); + + test('[B2] a later transient editor reveal does not overwrite a session\'s captured closed state during a switch', () => { + const controller = createController(); + const sessionA = makeSession(URI.parse('session:a')); + const sessionB = makeSession(URI.parse('session:b')); + harness.activeSessionObs.set(sessionA, undefined); + + // A: user closes the editor part -> captured hidden. + setPartVisible(Parts.EDITOR_PART, false); + assert.strictEqual(controller.getEditorPartHidden(sessionA.resource), true); + + // Simulate the switch-time race: while switching to B the editor part is + // revealed by B's layout restore (the capture listener ignores changes + // during a restore). A's captured closed state must be preserved. + controller.runWithRestore(() => { + harness.activeSessionObs.set(sessionB, undefined); + setPartVisible(Parts.EDITOR_PART, true); + }); + + assert.strictEqual(controller.getEditorPartHidden(sessionA.resource), true, + 'a restore-driven editor reveal must not overwrite session A\'s captured closed state'); + }); test('[D4] keeps the open side pane and shows Changes when a new session is submitted', () => { createController(); @@ -562,20 +1017,299 @@ suite('LayoutController (desktop)', () => { assert.ok(harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), 'aux bar should be restored'); }); - test('[D8] does not reveal the Changes view for an untitled session', () => { - createController(); - const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled }); - harness.activeSessionObs.set(untitled, undefined); + test('[reopen default single-pane] a created session opens the side pane to the editor with the detail closed', () => { + const controller = createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.editorGroupsHaveContent = true; - harness.openedViews = []; - harness.activeEditorResource = harness.sessionChangesService.getChangesEditorResource(untitled.resource); - harness.onDidActiveEditorChange.fire(); + // The side pane starts fully closed with no remembered parts (e.g. after a reload). + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.setPartHiddenCalls = []; - assert.ok(!harness.openedViews.includes(CHANGES_VIEW_ID), 'untitled sessions are governed by D3b/D4, not D8'); + controller.toggleSidePane(); + + assert.deepStrictEqual({ + editorRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + detailRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + }, { editorRevealed: true, detailRevealed: false }); }); - test('[D8] does not reveal the Changes view while multiple sessions are visible', () => { - createController(); + test('[reopen default single-pane] a new-session view opens the side pane to the Files detail', () => { + const controller = createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + harness.editorGroupsHaveContent = true; + + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.setPartHiddenCalls = []; + + controller.toggleSidePane(); + + assert.deepStrictEqual({ + editorRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + detailRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + }, { editorRevealed: false, detailRevealed: true }); + }); + + test('[D8] does not reveal the Changes view for an untitled session', () => { + createController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled }); + harness.activeSessionObs.set(untitled, undefined); + + harness.openedViews = []; + harness.activeEditorResource = harness.sessionChangesService.getChangesEditorResource(untitled.resource); + harness.onDidActiveEditorChange.fire(); + + assert.ok(!harness.openedViews.includes(CHANGES_VIEW_ID), 'untitled sessions are governed by D3b/D4, not D8'); + }); + + test('[R1] single-pane hides the editor on entering a new-session view but keeps an explicit in-session reveal', async () => { + createSinglePaneController(); + const untitled1 = makeSession(URI.parse('session:untitled1'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + const untitled2 = makeSession(URI.parse('session:untitled2'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled1, undefined); + await timeout(0); + + const firstReveal = { + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + openedFiles: harness.openedViewContainers.includes(SESSIONS_FILES_CONTAINER_ID), + }; + + // An *explicit* editor reveal in the same new-session view (opening a file, + // toggling details off) must stick. + harness.setPartHiddenCalls = []; + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + const explicitRevealEditorHiddenCalls = harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden); + + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + harness.setPartHiddenCalls = []; + harness.openedViewContainers = []; + harness.editorRevealedExplicitly = false; + + harness.activeSessionObs.set(untitled2, undefined); + await timeout(0); + + assert.deepStrictEqual({ + firstReveal, + explicitRevealEditorHiddenCalls, + secondRevealEditorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + firstReveal: { + editorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + openedFiles: true, + }, + explicitRevealEditorHiddenCalls: [], + secondRevealEditorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + }); + }); + + test('[R1] single-pane re-hides the editor on an automatic reveal in a new-session view', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // An automatic reveal (working-set restore, an inherited-visible editor, a + // layout race) is not explicit, so R1 re-hides it. + harness.editorRevealedExplicitly = false; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [{ part: Parts.EDITOR_PART, hidden: true }]); + }); + + test('[R1] single-pane hides the editor when the managed empty File tab is the active editor', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + editorHiddenCalls: [{ part: Parts.EDITOR_PART, hidden: true }], + }); + }); + + test('[R1/T2] single-pane does not hide the editor when a real file is the active editor', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/file.ts') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + // A spurious visibility signal must still not re-hide while real content is active. + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + }, { + editorHiddenCalls: [], + }); + }); + + test('[R1/T4] single-pane keeps the editor open when a file is opened before it becomes active', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // New-session view starts with the managed empty tab active and the editor hidden. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // Opening a file reveals the editor (onWillOpenEditor) *before* the file + // becomes the active editor, marking the reveal explicit. R1 must not undo it. + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + const beforeActiveEditor = harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden); + + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/package.json') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + beforeActiveEditor, + afterActiveEditor: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + beforeActiveEditor: [], + afterActiveEditor: [], + editorVisible: true, + }); + }); + + test('[R1] single-pane keeps the editor open when switching to the Files tab while the editor is already visible', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // New-session view; the user opens a file, so the editor is revealed and visible. + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + harness.editorRevealedExplicitly = true; + const fileEditor = Object.create(FileEditorInput.prototype) as FileEditorInput; + Object.defineProperty(fileEditor, 'resource', { value: URI.file('/repo/package.json') }); + harness.activeEditorInput = fileEditor; + harness.onDidActiveEditorChange.fire(); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + harness.setPartHiddenCalls = []; + + // The user switches to the managed Files placeholder tab. The editor is + // already visible, so switching tabs must NOT hide the editor area — even + // though the reveal is no longer flagged explicit (the flag is cleared when + // the reveal-sync suppression is re-armed for non-real content). + harness.editorRevealedExplicitly = false; + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorHiddenCalls: [], + editorVisible: true, + }); + }); + + test('[R1/T2] single-pane keeps the editor open when details is toggled off in a new-session view', async () => { + createSinglePaneController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeEditorInput = store.add(new EmptyFileEditorInput()); + harness.onDidActiveEditorChange.fire(); + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.setPartHiddenCalls = []; + + // Toggling details off reveals the empty editor (so the side pane does not + // vanish). The active editor stays the managed empty tab, but the reveal is + // explicit; R1 must not re-hide the editor it was just asked to show. + harness.editorRevealedExplicitly = true; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await timeout(0); + + assert.deepStrictEqual({ + editorHiddenCalls: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorHiddenCalls: [], + editorVisible: true, + }); + }); + + test('[R1] single-pane hides the editor when entering a new-session view with an inherited-visible editor', async () => { + createSinglePaneController(); + const existing = makeSession(URI.parse('session:existing')); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + // Start on a created session with the editor visible and left explicitly revealed. + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.editorRevealedExplicitly = true; + harness.setPartHiddenCalls = []; + + // Entering the new-session view must reset to editor-closed, even though the + // inherited editor was explicitly revealed for the previous session. + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [{ part: Parts.EDITOR_PART, hidden: true }]); + }); + + test('[D3b] standard controller does not hide the editor on new-session side-pane reveal', async () => { + createController(); + const untitled = makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden), + [] + ); + }); + + test('[D8] does not reveal the Changes view while multiple sessions are visible', () => { + createController(); const a = makeSession(URI.parse('session:a')); const b = makeSession(URI.parse('session:b')); harness.visibleSessionsObs.set([a, b], undefined); @@ -726,6 +1460,165 @@ suite('LayoutController (desktop)', () => { ); }); + test('[single-pane] reveals the editor part for a created session on switch, even with useModal all (Editor-only default)', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createSinglePaneController({ singlePaneLayoutEnabled: true, workspaceFolders }); + const untitled = makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + + // On the new-session view the editor part is hidden. + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + // Navigating to the existing (created) session reveals the editor part to + // show the managed Changes editor (the side pane is no longer left closed). + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'editor part should be revealed for the created session' + ); + }); + + test('[single-pane] preserves detail-only layout when an active new session is replaced by its created session', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + const controller = createSinglePaneController({ singlePaneLayoutEnabled: true, workspaceFolders }); + const draft = makeSession(URI.parse('session:draft'), { status: SessionStatus.Untitled, isCreated: false }); + const created = makeSession(URI.parse('session:created')); + + // The new-session view starts Files/detail-only: detail visible, editor hidden. + harness.activeSessionObs.set(draft, undefined); + harness.visibleSessionsObs.set([draft], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + assert.strictEqual(controller.getEditorPartHidden(draft.resource), true); + + harness.setPartHiddenCalls = []; + harness.openedViews = []; + // The provider commits the draft as a new resource, then the visible slot updates. + harness.onDidReplaceSession.fire({ from: draft, to: created }); + harness.activeSessionObs.set(created, undefined); + harness.visibleSessionsObs.set([created], undefined); + await timeout(0); + + assert.deepStrictEqual({ + editorReveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden === false).length, + editorHides: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden === true).length, + openedChanges: harness.openedViews.includes(CHANGES_VIEW_ID), + editorPartHidden: controller.getEditorPartHidden(created.resource), + detailVisible: harness.partVisibility.get(Parts.AUXILIARYBAR_PART), + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorReveals: 0, + editorHides: 0, + openedChanges: true, + editorPartHidden: true, + detailVisible: true, + editorVisible: false, + }); + }); + + test('[single-pane] preserves editor-hidden layout on submit even when the draft state was never captured', async () => { + // Mirrors the real app: the new-session editor starts hidden with no + // visible→hidden transition, so `_editorPartHiddenBySession` is never + // captured for the draft. The fix must still keep the editor hidden on + // submit (via the draft→committed replacement preserve), not reveal it. + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createSinglePaneController({ singlePaneLayoutEnabled: true, workspaceFolders }); + const draft = makeSession(URI.parse('session:draft'), { status: SessionStatus.Untitled, isCreated: false }); + const created = makeSession(URI.parse('session:created')); + + // Detail-only on-screen, but WITHOUT firing the editor-visibility capture event. + harness.activeSessionObs.set(draft, undefined); + harness.visibleSessionsObs.set([draft], undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + + harness.setPartHiddenCalls = []; + // The provider commits the draft, then the visible slot updates. + harness.onDidReplaceSession.fire({ from: draft, to: created }); + harness.activeSessionObs.set(created, undefined); + harness.visibleSessionsObs.set([created], undefined); + await timeout(0); + + assert.deepStrictEqual({ + editorReveals: harness.setPartHiddenCalls.filter(c => c.part === Parts.EDITOR_PART && c.hidden === false).length, + editorVisible: harness.partVisibility.get(Parts.EDITOR_PART), + }, { + editorReveals: 0, + editorVisible: false, + }); + }); + + test('[single-pane] does not reveal the editor part for a created session whose editor was explicitly hidden', async () => { + const workspaceFolders = [{ uri: URI.file('/repo') }]; + createSinglePaneController({ + singlePaneLayoutEnabled: true, + workspaceFolders, + layoutState: [{ sessionResource: 'session:existing', editorPartHidden: true }], + }); + const untitled = makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }); + const existing = makeSession(URI.parse('session:existing')); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + harness.activeSessionObs.set(existing, undefined); + await timeout(0); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'the editor part must stay hidden for a session whose editor was explicitly hidden' + ); + }); + + test('[single-pane] does not reveal the editor part for a created quick chat on switch', async () => { + createSinglePaneController({ singlePaneLayoutEnabled: true }); + const untitled = makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }); + const quickChat = makeSession(URI.parse('session:qc'), { isQuickChat: true }); + + harness.activeSessionObs.set(untitled, undefined); + await timeout(0); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + // A quick chat has no side pane, so switching to it must never auto-reveal + // the editor part even though the session is created. + harness.activeSessionObs.set(quickChat, undefined); + await timeout(0); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'the editor part must not be revealed for a quick chat' + ); + }); + + test('[single-pane] hides a visible editor part when switching to a quick chat with an empty editor group', async () => { + createSinglePaneController({ singlePaneLayoutEnabled: true, activateAux: true }); + await timeout(0); + // A prior session left the editor part visible; the quick chat's editor + // group is empty (no managed tabs), so the whole side pane must collapse. + harness.editorGroupsHaveContent = false; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.setPartHiddenCalls = []; + + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === true), + 'the editor part should hide for a quick chat with an empty editor group' + ); + }); + // --- [B4] + [D1] Persistence --- test('[B4] persists aux-bar view state to sessions.layoutState key', () => { @@ -1107,4 +2000,723 @@ suite('LayoutController (desktop)', () => { assert.deepStrictEqual(sidebarHiddenCalls(), []); }); + + // --- [D7 single-pane] Auto-hide the sessions list only on explicit details open --- + + test('[D7 single-pane] hides the sessions list when details is opened via the toggle action', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), [true]); + }); + + test('[D7 single-pane] restores the sessions list when details is closed via the toggle action', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + controller.toggleDetails(); + harness.setPartHiddenCalls = []; + + // Details now open -> toggling again closes it and restores the auto-hidden list. + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), [false]); + }); + + test('[D7 single-pane] does not touch the sessions list on automatic details opens', () => { + createSinglePaneController(); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + // A programmatic aux-bar visibility change (submit/restore) is not the + // toggle action, so the sessions list stays as-is. + setPartVisible(Parts.AUXILIARYBAR_PART, true); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] does not manage the sessions list while multiple sessions are visible', () => { + const controller = createSinglePaneController(); + harness.visibleSessionsObs.set([ + makeSession(URI.parse('session:1')), + makeSession(URI.parse('session:2')), + ], undefined); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] does not restore a sessions list the user reopened manually', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + controller.toggleDetails(); + + // User manually reopens the sessions list -> control handed back. + setPartVisible(Parts.SIDEBAR_PART, true); + harness.setPartHiddenCalls = []; + + // Closing details must now not touch the sessions list. + controller.toggleDetails(); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] restores an auto-hidden sessions list once the side pane is fully hidden', () => { + const controller = createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + // Opening details auto-hides the sessions list (only the aux bar is visible). + controller.toggleDetails(); + harness.setPartHiddenCalls = []; + + // The whole side pane is later hidden by other means (e.g. switching to a + // quick chat, which has no side pane). The list must not be left collapsed + // while the side pane is hidden, so restore it. + setPartVisible(Parts.AUXILIARYBAR_PART, false); + + assert.deepStrictEqual(sidebarHiddenCalls(), [false]); + }); + + test('[D7 single-pane] does not restore a manually-hidden sessions list when the side pane is hidden', () => { + createSinglePaneController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + // User manually closes the sessions list (not an auto-hide). + setPartVisible(Parts.SIDEBAR_PART, false); + harness.setPartHiddenCalls = []; + + // The side pane later fully closes; a user-closed list must stay closed. + setPartVisible(Parts.AUXILIARYBAR_PART, false); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[D7 single-pane] contributes the Toggle Details command to the editor title layout cluster', () => { + createSinglePaneController(); + + const items = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) + .filter(isIMenuItem) + .filter(item => item.command.id === TOGGLE_DETAILS_COMMAND_ID); + + assert.strictEqual(items.length, 1, 'exactly one Toggle Details item on the editor title layout cluster'); + const when = items[0].when?.serialize() ?? ''; + assert.deepStrictEqual({ + icon: ThemeIcon.isThemeIcon(items[0].command.icon) ? items[0].command.icon.id : undefined, + order: items[0].order, + hasToggled: !!items[0].command.toggled, + gatedOnEditorArea: when.includes(MainEditorAreaVisibleContext.key), + }, { + icon: Codicon.listSelection.id, + order: 30, + hasToggled: true, + gatedOnEditorArea: true, + }); + }); + + // --- [Scenario 8] Auto-hide the sessions list when opening a file --- + + function openEditor(editor: EditorInput): void { + const event: IEditorWillOpenEvent = { groupId: 1, editor }; + harness.onWillOpenEditor.fire(event); + } + + test('[Scenario 8] hides the sessions list when a real file is opened in a created session with the editor closed', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), [true]); + }); + + test('[Scenario 8] does not hide the sessions list in a new (uncreated) session', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:untitled'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list when the editor area is already open', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, true); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list when a managed empty tab is opened', async () => { + createSinglePaneController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + await timeout(0); + harness.setPartHiddenCalls = []; + + openEditor(store.add(new EmptyFileEditorInput())); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + test('[Scenario 8] does not hide the sessions list on file open while multiple sessions are visible', () => { + createSinglePaneController(); + harness.visibleSessionsObs.set([ + makeSession(URI.parse('session:1')), + makeSession(URI.parse('session:2')), + ], undefined); + harness.partVisibility.set(Parts.SIDEBAR_PART, true); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + openEditor(Object.create(FileEditorInput.prototype) as FileEditorInput); + + assert.deepStrictEqual(sidebarHiddenCalls(), []); + }); + + // --- [D10] Auxiliary bar part hidden when it has no active view containers --- + + test('[D10] hides the aux-bar part for a quick chat when its view containers are gated off', async () => { + createController(); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.activeAuxViewContainerIds = []; + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + // A quick chat gates off Changes + Files, so the aux bar has no active + // view containers — the part must hide instead of showing an empty column. + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar part should hide when a quick chat has no active view containers' + ); + }); + + test('[D10] does not hide the aux bar during early reload when there is no active session yet', () => { + createController({ activeAuxViewContainerIds: [] }); + // Startup/reload: aux restored visible (persisted) but no active session yet; + // its containers are transiently inactive. Hiding here is the reload flicker + // (opens then closes) — D10 must leave it alone until a session settles. + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + [], + 'aux-bar part must not be hidden by D10 while there is no active session' + ); + }); + + test('[D10] does not hide the aux bar for a workspace session with transiently empty containers', async () => { + createController({ activeAuxViewContainerIds: [] }); + // A real workspace session whose Files/Changes context keys have not settled + // yet (containers transiently inactive). D10 must not collapse its side pane. + harness.activeSessionObs.set(makeSession(URI.parse('session:ws')), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + [], + 'aux-bar part must not be hidden by D10 for a workspace session with transiently empty containers' + ); + }); + + test('[D10] never reveals an empty aux-bar part', async () => { + createController({ activeAuxViewContainerIds: [] }); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.setPartHiddenCalls = []; + + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'aux-bar part should never be revealed when it has no active view containers' + ); + }); + + test('[D10] re-hides the aux-bar part if a switch to a quick chat left it visible with no containers', async () => { + createController({ activeAuxViewContainerIds: [] }); + // Mirror a switch to a workspace-less quick chat where D3a returned early + // (no workspace) and left a previously-visible aux bar showing. + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangeViewContainerVisibility.fire({ id: CHANGES_VIEW_CONTAINER_ID, visible: false, location: ViewContainerLocation.AuxiliaryBar }); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar part should be hidden reactively when a quick chat has no active view containers' + ); + }); + + test('[D10] leaves the aux-bar part alone when it has active view containers', () => { + createController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + // Changes + Files still active (default) — the reactive sync must not touch the part. + harness.onDidChangeActiveViewDescriptors.fire(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART), + [], + 'aux-bar part should be left as-is while it has active view containers' + ); + }); + + test('[D10] hides the aux-bar part when a quick chat becomes visible with no active containers', async () => { + createController({ activeAuxViewContainerIds: [] }); + harness.activeSessionObs.set(makeSession(URI.parse('session:qc'), { isQuickChat: true }), undefined); + await timeout(0); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + // The part became visible (e.g. a bare detail toggle that shows the column + // before any container is opened) without any container-/descriptor-change + // signal firing. For a quick chat D10 must still reconcile the empty column + // away so the toggle/context key never reads "on" over a blank panel. + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === true), + 'aux-bar part should hide when a quick chat becomes visible with no active view containers' + ); + }); + + test('[D10] leaves the aux-bar part visible when it becomes visible with active containers', () => { + createController(); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.setPartHiddenCalls = []; + + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.part === Parts.AUXILIARYBAR_PART), + [], + 'aux-bar part should stay visible when it becomes visible with active view containers' + ); + }); + + // --- [D10] Toggle Side Panel with an empty aux bar --- + + test('[D10] toggling the side pane with no aux containers reveals the editor, not an empty aux bar', () => { + const controller = createController({ activeAuxViewContainerIds: [] }); + // Side pane fully closed; editors exist but no aux view containers. + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.editorGroupsHaveContent = true; + harness.setPartHiddenCalls = []; + + controller.toggleSidePane(); + + assert.ok( + harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + 'toggle should reveal the editor part' + ); + assert.ok( + !harness.setPartHiddenCalls.some(c => c.part === Parts.AUXILIARYBAR_PART && c.hidden === false), + 'toggle should never reveal an empty aux bar' + ); + }); + + test('[D10] toggling the side pane with neither editors nor aux containers reveals nothing', () => { + const controller = createController({ activeAuxViewContainerIds: [] }); + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.editorGroupsHaveContent = false; + harness.setPartHiddenCalls = []; + + controller.toggleSidePane(); + + assert.deepStrictEqual( + harness.setPartHiddenCalls.filter(c => c.hidden === false), + [], + 'toggle should reveal nothing when there is no content on either side' + ); + }); + + // --- Single-pane managed docked tabs (Changes + Files placeholder) --- + + async function settle(): Promise<void> { + for (let i = 0; i < 6; i++) { + await timeout(0); + } + } + + function hasFilesTab(): boolean { + return harness.activeGroupEditors.some(e => e instanceof EmptyFileEditorInput); + } + + function hasChangesTab(): boolean { + return harness.activeGroupEditors.some(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined); + } + + test('[managed tabs] ensures the Changes and Files tabs for a created session under suppression', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + + assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: true, hasFilesTab: true }); + }); + + test('[managed tabs / Changes pill] reveals the editor area before opening the managed Changes editor', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + const session = makeSession(URI.parse('session:1')); + harness.activeSessionObs.set(session, undefined); + await settle(); + + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.setPartHiddenCalls = []; + + const handler = CommandsRegistry.getCommand('workbench.agentSessions.action.viewChanges')?.handler; + assert.ok(handler, 'Changes pill command should be registered'); + + await handler(harness.instaService, session); + await settle(); + + assert.deepStrictEqual({ + editorRevealed: harness.setPartHiddenCalls.some(c => c.part === Parts.EDITOR_PART && c.hidden === false), + hasChangesTab: hasChangesTab(), + }, { + editorRevealed: true, + hasChangesTab: true, + }); + }); + + test('[managed tabs / Scenario 9] shows only the Files tab for a new-session view', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + await settle(); + + assert.deepStrictEqual({ hasChangesTab: hasChangesTab(), hasFilesTab: hasFilesTab() }, { hasChangesTab: false, hasFilesTab: true }); + }); + + test('[managed tabs / Scenario 9] removes the Files tab while a real editor is open and re-adds it when none remain', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + assert.strictEqual(hasFilesTab(), true); + + // A real file opens into a visible editor area. + const realEditor = store.add(new TestStubEditorInput(URI.file('/repo/a.ts'))); + harness.activeGroupEditors.push(realEditor); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidEditorsChange.fire(); + await settle(); + const filesRemoved = !hasFilesTab(); + + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(realEditor), 1); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.deepStrictEqual({ + filesRemoved, + filesReadded: hasFilesTab(), + }, { + filesRemoved: true, + filesReadded: true, + }); + }); + + test('[managed tabs / Scenario 9] keeps the Files tab when a non-file editor (e.g. the browser) opens', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + assert.strictEqual(hasFilesTab(), true); + + // A non-file editor (the integrated browser uses the browserView scheme) opens + // into a visible editor area. It must NOT collapse the Files placeholder. + const browserEditor = store.add(new TestStubEditorInput(URI.parse('browserView://host/page'))); + harness.activeGroupEditors.push(browserEditor); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'a non-file editor must not remove the Files tab'); + }); + + test('[single-pane] closes non-managed tabs when the editor area hides and reopens them when shown', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + + // A real file opens between the managed tabs while the editor area is visible. + const fileResource = URI.file('/repo/a.ts'); + harness.activeGroupEditors.splice(1, 0, store.add(new TestStubEditorInput(fileResource))); + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await settle(); + const originalIndex = harness.activeGroupEditors.findIndex(e => e.resource && isEqual(e.resource, fileResource)); + + // Hide the editor area: the real file tab closes, the managed Files tab stays. + harness.partVisibility.set(Parts.EDITOR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: false }); + await settle(); + + const closedFile = harness.closedEditors.some(e => isEqual(e.resource!, fileResource)); + const filesTabKept = hasFilesTab(); + const fileTabGone = !harness.activeGroupEditors.some(e => e.resource && isEqual(e.resource, fileResource)); + + // Show the editor area again: the file tab is reopened at its original position. + harness.openedEditors = []; + harness.partVisibility.set(Parts.EDITOR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.EDITOR_PART, visible: true }); + await settle(); + + assert.deepStrictEqual({ + closedFile, + filesTabKept, + fileTabGone, + reopenedFile: harness.openedEditors.some(e => isResourceEditorInput(e) && isEqual(e.resource, fileResource)), + restoredAtOriginalIndex: harness.activeGroupEditors.findIndex(e => e.resource && isEqual(e.resource, fileResource)) === originalIndex, + }, { + closedFile: true, + filesTabKept: true, + fileTabGone: true, + reopenedFile: true, + restoredAtOriginalIndex: true, + }); + }); + + test('[managed tabs / Change 2] does not re-ensure a managed tab after the user closes it', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + assert.ok(fileTab); + + // User closes the Files tab. + const index = harness.activeGroupEditors.indexOf(fileTab); + harness.activeGroupEditors.splice(index, 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.strictEqual(hasFilesTab(), false, 'the dismissed Files tab stays closed'); + }); + + test('[managed tabs / Change 2] re-ensures a dismissed tab after switching sessions', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + const index = harness.activeGroupEditors.indexOf(fileTab); + harness.activeGroupEditors.splice(index, 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.onDidEditorsChange.fire(); + await settle(); + assert.strictEqual(hasFilesTab(), false); + + // Switching sessions clears dismissals and re-populates. + harness.activeSessionObs.set(makeSession(URI.parse('session:2')), undefined); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'a dismissed tab is re-ensured for the new session'); + }); + + test('[managed tabs / add-tab] closing the Changes tab flips SinglePaneChangesTabMissingContext', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; + assert.strictEqual(harness.contextKeyService.getContextKeyValue(SinglePaneChangesTabMissingContext.key), false); + + // User closes the Changes tab. + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(changesTab), 1); + harness.onDidCloseEditor.fire({ editor: changesTab }); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.deepStrictEqual({ + hasChangesTab: hasChangesTab(), + changesTabMissing: harness.contextKeyService.getContextKeyValue(SinglePaneChangesTabMissingContext.key) + }, { hasChangesTab: false, changesTabMissing: true }); + }); + + test('[managed tabs / add-tab] closing the Files tab flips SinglePaneFilesTabMissingContext', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + assert.strictEqual(harness.contextKeyService.getContextKeyValue(SinglePaneFilesTabMissingContext.key), false); + + // User closes the Files tab. + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.onDidEditorsChange.fire(); + await settle(); + + assert.deepStrictEqual({ + hasFilesTab: hasFilesTab(), + filesTabMissing: harness.contextKeyService.getContextKeyValue(SinglePaneFilesTabMissingContext.key) + }, { hasFilesTab: false, filesTabMissing: true }); + }); + + test('[managed tabs / add-tab] reopening the Changes tab clears its dismissal and the missing context', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + const session = URI.parse('session:1'); + harness.activeSessionObs.set(makeSession(session), undefined); + await settle(); + const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; + + // User closes the Changes tab -> dismissed, context becomes true. + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(changesTab), 1); + harness.onDidCloseEditor.fire({ editor: changesTab }); + harness.onDidEditorsChange.fire(); + await settle(); + assert.strictEqual(harness.contextKeyService.getContextKeyValue(SinglePaneChangesTabMissingContext.key), true); + + // Reopen it (as the `+` "Changes" entry does): the Changes editor reappears. + const changesResource = harness.sessionChangesService.getChangesEditorResource(session); + harness.activeGroupEditors.push(store.add(new TestStubEditorInput(changesResource))); + harness.onDidEditorsChange.fire(); + await settle(); + + // The dismissal is cleared so the controller resumes managing the tab: a + // later routine sync retains it and the missing context stays false. + harness.onDidEditorsChange.fire(); + await settle(); + assert.deepStrictEqual({ + hasChangesTab: hasChangesTab(), + changesTabMissing: harness.contextKeyService.getContextKeyValue(SinglePaneChangesTabMissingContext.key) + }, { hasChangesTab: true, changesTabMissing: false }); + }); + + test('[managed tabs / add-tab] reopening managed tabs from the plus menu adds them at the end', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + const session = URI.parse('session:1'); + harness.activeSessionObs.set(makeSession(session), undefined); + await settle(); + + const changesTab = harness.activeGroupEditors.find(e => !(e instanceof EmptyFileEditorInput) && e.resource !== undefined)!; + const filesTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + const extraEditor = store.add(new TestStubEditorInput(URI.file('/repo/extra.ts'))); + harness.activeGroupEditors.push(extraEditor); + + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(changesTab), 1); + harness.onDidCloseEditor.fire({ editor: changesTab }); + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(filesTab), 1); + harness.onDidCloseEditor.fire({ editor: filesTab }); + harness.onDidEditorsChange.fire(); + await settle(); + + await new NewChangesTabAction().run(harness.instaService); + await new NewFileTabAction().run(harness.instaService); + + assert.deepStrictEqual(harness.activeGroupEditors.map(editor => { + if (editor === extraEditor) { + return 'extra'; + } + if (editor instanceof EmptyFileEditorInput) { + return 'files'; + } + if (editor.resource && isEqual(editor.resource, harness.sessionChangesService.getChangesEditorResource(session))) { + return 'changes'; + } + return 'other'; + }), ['extra', 'changes', 'files']); + }); + + test('[managed tabs / reload] closing a stale Changes tab happens under editor-visibility suppression', async () => { + createSinglePaneController({ activateAux: true }); + await settle(); + + // A stale Changes tab for a previous session is restored into the group. + const staleChangesResource = harness.sessionChangesService.getChangesEditorResource(URI.parse('session:stale')); + harness.activeGroupEditors.push(store.add(new TestStubEditorInput(staleChangesResource))); + + harness.activeSessionObs.set(makeSession(URI.parse('session:1')), undefined); + await settle(); + + const staleClosed = harness.closedEditors.some(e => e.resource && isEqual(e.resource, staleChangesResource)); + const allClosesSuppressed = harness.closeSuppressionFlags.every(flag => flag); + assert.deepStrictEqual({ staleClosed, allClosesSuppressed }, { staleClosed: true, allClosesSuppressed: true }); + }); + + test('[managed tabs / Issue 1] re-ensures the Files tab when the side pane is reopened via the aux bar alone', async () => { + createSinglePaneController({ activateAux: true, initialPartVisibility: new Map([[Parts.EDITOR_PART, false], [Parts.AUXILIARYBAR_PART, true]]) }); + await settle(); + + harness.activeSessionObs.set(makeSession(URI.parse('session:new'), { status: SessionStatus.Untitled, isCreated: false }), undefined); + await settle(); + const fileTab = harness.activeGroupEditors.find(e => e instanceof EmptyFileEditorInput)!; + assert.ok(fileTab); + + // User closes the Files tab; the whole side pane closes (aux hidden). + harness.activeGroupEditors.splice(harness.activeGroupEditors.indexOf(fileTab), 1); + harness.onDidCloseEditor.fire({ editor: fileTab }); + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, false); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: false }); + await settle(); + assert.strictEqual(hasFilesTab(), false); + + // Reopen the side pane by revealing ONLY the aux bar (editor stays hidden). + harness.partVisibility.set(Parts.AUXILIARYBAR_PART, true); + harness.onDidChangePartVisibility.fire({ partId: Parts.AUXILIARYBAR_PART, visible: true }); + await settle(); + + assert.strictEqual(hasFilesTab(), true, 'reopening via the aux bar re-ensures the Files tab'); + }); }); diff --git a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts index 9093a9966e8523..6fd52442e39fa2 100644 --- a/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts +++ b/src/vs/sessions/contrib/layout/test/browser/layoutControllerTestUtils.ts @@ -4,41 +4,57 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { IDimension } from '../../../../../base/browser/dom.js'; import { constObservable, ISettableObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; -import { ViewContainerLocation } from '../../../../../workbench/common/views.js'; -import { IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; +import { IViewContainerModel, IViewDescriptorService, ViewContainer, ViewContainerLocation } from '../../../../../workbench/common/views.js'; +import { IEditorGroup, IEditorGroupsService, IEditorWorkingSet } from '../../../../../workbench/services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../../../workbench/services/editor/common/editorService.js'; import { IPartVisibilityChangeEvent, IWorkbenchLayoutService, Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { IPaneCompositePartService } from '../../../../../workbench/services/panecomposite/browser/panecomposite.js'; import { IPaneComposite } from '../../../../../workbench/common/panecomposite.js'; import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { EditorInput } from '../../../../../workbench/common/editor/editorInput.js'; +import { IEditorWillOpenEvent, IUntypedEditorInput, isResourceEditorInput } from '../../../../../workbench/common/editor.js'; import { IActiveSession, ISessionsChangeEvent, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; -import { IChat, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionFileChange, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionChangesService, SessionChangesService } from '../../../changes/browser/sessionChangesService.js'; import { CHANGES_VIEW_CONTAINER_ID } from '../../../changes/common/changes.js'; import { SESSIONS_FILES_CONTAINER_ID } from '../../../files/browser/files.contribution.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js'; +import { ILifecycleService } from '../../../../../workbench/services/lifecycle/common/lifecycle.js'; +import { IChangesViewService } from '../../../changes/common/changesViewService.js'; export function makeChange(filePath: string): ISessionFileChange { return { uri: URI.file(filePath), insertions: 1, deletions: 0 }; } +/** A minimal editor input for tests, identified only by its resource. */ +export class TestStubEditorInput extends EditorInput { + constructor(private readonly _resource: URI) { super(); } + override get typeId(): string { return 'test.stubEditor'; } + override get resource(): URI { return this._resource; } + override toUntyped(): IUntypedEditorInput { return { resource: this._resource }; } +} + export function makeSession(resource: URI, opts?: { status?: SessionStatus; isCreated?: boolean; changes?: readonly ISessionFileChange[]; workspace?: ISessionWorkspace; + isQuickChat?: boolean; }): IActiveSession { const status = observableValue('status', opts?.status ?? SessionStatus.Completed); const chat: IChat = { @@ -53,6 +69,7 @@ export function makeSession(resource: URI, opts?: { mode: observableValue('mode', undefined), isArchived: observableValue('isArchived', false), isRead: observableValue('isRead', true), + interactivity: observableValue('interactivity', ChatInteractivity.Full), lastTurnEnd: observableValue('lastTurnEnd', undefined), description: observableValue('description', undefined), }; @@ -93,13 +110,17 @@ export function makeSession(resource: URI, opts?: { chats: observableValue('chats', [chat]), activeChat: observableValue('activeChat', chat), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), isCreated: opts?.isCreated === undefined ? status.map(status => status !== SessionStatus.Untitled) : observableValue('isCreated', opts.isCreated), sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + lastClosedChat: undefined, + visibleChatTabs: constObservable([chat]), + shouldShowChatTabs: constObservable(false), + isQuickChat: constObservable(opts?.isQuickChat ?? false), }; } @@ -117,6 +138,12 @@ export interface ICreateOptions { readonly mainContainerWidth?: number; /** Initial part visibility overrides applied before the controller is constructed (mirrors restored layout after a reload). */ readonly initialPartVisibility?: ReadonlyMap<Parts, boolean>; + /** IDs of aux-bar view containers active at construction (defaults to Changes + Files). Empty ⇒ no active aux containers (e.g. a quick chat). */ + readonly activeAuxViewContainerIds?: readonly string[]; + /** When set, resolves the lifecycle `Restored` phase so a single-pane controller's managed-tab / detail-panel behaviour activates. */ + readonly activateAux?: boolean; + /** When true, the layout service reports single-pane layout enabled (drives base single-pane branches). */ + readonly singlePaneLayoutEnabled?: boolean; } /** @@ -130,21 +157,59 @@ export interface ITestLayoutHarness { activeSessionObs: ISettableObservable<IActiveSession | undefined>; visibleSessionsObs: ISettableObservable<readonly (IActiveSession | undefined)[]>; onDidChangeSessions: Emitter<ISessionsChangeEvent>; + onDidReplaceSession: Emitter<{ readonly from: ISession; readonly to: ISession }>; onDidChangePartVisibility: Emitter<IPartVisibilityChangeEvent>; onDidChangeEditorMaximized: Emitter<void>; onDidActiveEditorChange: Emitter<void>; + onWillOpenEditor: Emitter<IEditorWillOpenEvent>; + onDidCloseEditor: Emitter<{ editor: EditorInput }>; + onDidEditorsChange: Emitter<void>; onDidLayoutMainContainer: Emitter<IDimension>; + onDidChangeViewContainerVisibility: Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>; + onDidChangeActiveViewDescriptors: Emitter<void>; + /** IDs of aux-bar view containers that are currently active (shown as a tab). */ + activeAuxViewContainerIds: string[]; mainContainerWidth: number; editorMaximized: boolean; partVisibility: Map<Parts, boolean>; openedViewContainers: string[]; openedViews: string[]; setPartHiddenCalls: { hidden: boolean; part: Parts }[]; + /** Value returned by the layout service's `isEditorRevealedExplicitly()` mock. */ + editorRevealedExplicitly: boolean; + /** Predicate registered via `setEditorRevealOnOpenExclusion()` (managed editors that shouldn't reveal the editor area). */ + editorRevealOnOpenExclusion: ((editor: EditorInput) => boolean) | undefined; + /** Current suppression depth for `suppressEditorPartAutoVisibility()`. */ + editorPartAutoVisibilitySuppressionDepth: number; + /** Whether the lifecycle `Restored` phase has resolved (activates single-pane managed-tab / detail-panel behaviour). */ + activateAux: boolean; + /** Editors in the main part's active group (drives the single-pane managed-tab logic). */ + activeGroupEditors: EditorInput[]; + /** Records editors closed via `IEditorService.closeEditors`. */ + closedEditors: EditorInput[]; + /** Records untyped editors reopened via `IEditorService.openEditors`. */ + openedEditors: IUntypedEditorInput[]; + /** Records the depth-at-close for each `closeEditors` call, to assert layout-driven closes happen while suppressed. */ + closeSuppressionFlags: boolean[]; activePaneCompositeId: string | undefined; pinnedAuxiliaryBarContainerIds: string[]; visibleEditorsList: readonly unknown[]; activeEditorResource: URI | undefined; + /** Whether the editor groups have content (drives `hasEditors` in `toggleSidePane`). */ + editorGroupsHaveContent: boolean; + /** Records every `applyWorkingSet` call made by the controller. */ + applyWorkingSetCalls: (IEditorWorkingSet | 'empty')[]; + /** Records the name of every `saveWorkingSet` call made by the controller. */ + saveWorkingSetCalls: string[]; + /** + * Optional callback invoked synchronously during `applyWorkingSet`, allowing + * tests to simulate external visibility changes (e.g. the single-pane detail + * panel) while `_isRestoringSessionLayout` is true. + */ + onApplyWorkingSet?: () => void; readonly sessionChangesService: ISessionChangesService; + readonly contextKeyService: MockContextKeyService; + activeEditorInput?: EditorInput; } export function createTestHarness(store: DisposableStore, options: ICreateOptions = {}): ITestLayoutHarness { @@ -152,13 +217,20 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption const storageService = store.add(new TestStorageService()); if (options.layoutState) { - storageService.store('sessions.layoutState', JSON.stringify(options.layoutState), StorageScope.WORKSPACE, 0); + const raw = JSON.stringify(options.layoutState); + // Seed both the classic desktop key and the fresh single-pane key so the + // same harness serves both the LayoutController and SinglePaneLayoutController tests. + storageService.store('sessions.layoutState', raw, StorageScope.WORKSPACE, 0); + storageService.store('sessions.singlePane.layoutState', raw, StorageScope.WORKSPACE, 0); } if (options.newSessionViewState) { - storageService.store('sessions.newSessionViewState', JSON.stringify(options.newSessionViewState), StorageScope.WORKSPACE, 0); + const raw = JSON.stringify(options.newSessionViewState); + storageService.store('sessions.newSessionViewState', raw, StorageScope.WORKSPACE, 0); + storageService.store('sessions.singlePane.newSessionViewState', raw, StorageScope.WORKSPACE, 0); } if (options.newSessionViewStateRaw !== undefined) { storageService.store('sessions.newSessionViewState', options.newSessionViewStateRaw, StorageScope.WORKSPACE, 0); + storageService.store('sessions.singlePane.newSessionViewState', options.newSessionViewStateRaw, StorageScope.WORKSPACE, 0); } instaService.stub(IStorageService, storageService); @@ -166,6 +238,8 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption configService.setUserConfiguration('workbench.editor.useModal', options.useModal ?? 'all'); configService.setUserConfiguration('sessions.layout.autoCollapseSessionsSidebar', options.responsiveSidebar ?? true); instaService.stub(IConfigurationService, configService); + const contextKeyService = store.add(new MockContextKeyService()); + instaService.stub(IContextKeyService, contextKeyService); const harness: ITestLayoutHarness = { instaService, @@ -173,10 +247,17 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption activeSessionObs: observableValue<IActiveSession | undefined>('activeSession', undefined), visibleSessionsObs: observableValue<readonly (IActiveSession | undefined)[]>('visibleSessions', []), onDidChangeSessions: store.add(new Emitter<ISessionsChangeEvent>()), + onDidReplaceSession: store.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>()), onDidChangePartVisibility: store.add(new Emitter<IPartVisibilityChangeEvent>()), onDidChangeEditorMaximized: store.add(new Emitter<void>()), onDidActiveEditorChange: store.add(new Emitter<void>()), + onWillOpenEditor: store.add(new Emitter<IEditorWillOpenEvent>()), + onDidCloseEditor: store.add(new Emitter<{ editor: EditorInput }>()), + onDidEditorsChange: store.add(new Emitter<void>()), onDidLayoutMainContainer: store.add(new Emitter<IDimension>()), + onDidChangeViewContainerVisibility: store.add(new Emitter<{ id: string; visible: boolean; location: ViewContainerLocation }>()), + onDidChangeActiveViewDescriptors: store.add(new Emitter<void>()), + activeAuxViewContainerIds: options.activeAuxViewContainerIds ? [...options.activeAuxViewContainerIds] : [CHANGES_VIEW_CONTAINER_ID, SESSIONS_FILES_CONTAINER_ID], mainContainerWidth: options.mainContainerWidth ?? 2000, editorMaximized: false, partVisibility: new Map<Parts, boolean>([ @@ -188,15 +269,49 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption openedViewContainers: [], openedViews: [], setPartHiddenCalls: [], + editorRevealedExplicitly: false, + editorRevealOnOpenExclusion: undefined, + editorPartAutoVisibilitySuppressionDepth: 0, + activateAux: options.activateAux ?? false, + activeGroupEditors: [], + closedEditors: [], + openedEditors: [], + closeSuppressionFlags: [], activePaneCompositeId: undefined, pinnedAuxiliaryBarContainerIds: [SESSIONS_FILES_CONTAINER_ID, CHANGES_VIEW_CONTAINER_ID], visibleEditorsList: [], activeEditorResource: undefined, - sessionChangesService: new SessionChangesService(), + activeEditorInput: undefined, + editorGroupsHaveContent: true, + applyWorkingSetCalls: [], + saveWorkingSetCalls: [], + sessionChangesService: new SessionChangesService(new class extends mock<IEditorService>() { }, instaService, configService), + contextKeyService, + }; + + const testActiveGroup: IEditorGroup = new class extends mock<IEditorGroup>() { + override readonly id = 1; + override get editors() { return harness.activeGroupEditors as IEditorGroup['editors']; } + override get count() { return harness.activeGroupEditors.length; } + override get isEmpty() { return harness.activeGroupEditors.length === 0; } + override isPinned() { return true; } + override pinEditor() { } + override getIndexOfEditor(editor: EditorInput) { return harness.activeGroupEditors.indexOf(editor); } + override moveEditor(editor: EditorInput, _target: IEditorGroup, options?: { index?: number }) { + const currentIndex = harness.activeGroupEditors.indexOf(editor); + if (currentIndex === -1) { + return false; + } + harness.activeGroupEditors.splice(currentIndex, 1); + const targetIndex = Math.max(0, Math.min(options?.index ?? harness.activeGroupEditors.length, harness.activeGroupEditors.length)); + harness.activeGroupEditors.splice(targetIndex, 0, editor); + return true; + } }; instaService.stub(ISessionsManagementService, new class extends mock<ISessionsManagementService>() { override readonly onDidChangeSessions = harness.onDidChangeSessions.event; + override readonly onDidReplaceSession = harness.onDidReplaceSession.event; override getSessions() { return []; } }); instaService.stub(ISessionsService, new class extends mock<ISessionsService>() { @@ -204,7 +319,31 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption override readonly visibleSessions = harness.visibleSessionsObs; }); - instaService.stub(ISessionChangesService, harness.sessionChangesService); + instaService.stub(ISessionChangesService, new class extends mock<ISessionChangesService>() { + override getChangesEditorResource(sessionResource: URI): URI { return harness.sessionChangesService.getChangesEditorResource(sessionResource); } + override getSessionResource(editorResource: URI): URI | undefined { return harness.sessionChangesService.getSessionResource(editorResource); } + override async openChangesEditor(sessionResource: URI, options?: { index?: number }): Promise<IEditorGroup> { + const resource = harness.sessionChangesService.getChangesEditorResource(sessionResource); + if (!harness.activeGroupEditors.some(e => e.resource && isEqual(e.resource, resource))) { + const editor = store.add(new TestStubEditorInput(resource)); + const index = options?.index; + if (typeof index === 'number' && index >= 0 && index <= harness.activeGroupEditors.length) { + harness.activeGroupEditors.splice(index, 0, editor); + } else { + harness.activeGroupEditors.push(editor); + } + } + return testActiveGroup; + } + }); + instaService.stub(IChangesViewService, new class extends mock<IChangesViewService>() { + override setChangesetId(): void { } + }); + instaService.stub(ILifecycleService, new class extends mock<ILifecycleService>() { + // Resolves only when a test opts in via `activateAux`, so the single-pane + // managed-tab / detail-panel behaviour is not spun up otherwise. + override when(): Promise<void> { return harness.activateAux ? Promise.resolve() : new Promise<void>(() => { }); } + }); instaService.stub(IWorkbenchLayoutService, new class extends mock<IWorkbenchLayoutService>() { override isVisible(part: Parts): boolean { @@ -220,15 +359,32 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } } override hasFocus(_part: Parts): boolean { return false; } - suppressEditorPartAutoVisibility(): IDisposable { return Disposable.None; } + suppressEditorPartAutoVisibility(): IDisposable { + harness.editorPartAutoVisibilitySuppressionDepth++; + return toDisposable(() => harness.editorPartAutoVisibilitySuppressionDepth--); + } + setEditorRevealOnOpenExclusion(predicate: (editor: EditorInput) => boolean): IDisposable { + harness.editorRevealOnOpenExclusion = predicate; + return toDisposable(() => { harness.editorRevealOnOpenExclusion = undefined; }); + } + isEditorRevealedExplicitly(): boolean { return harness.editorRevealedExplicitly; } + revealEditorPartExplicitly(): void { + harness.editorRevealedExplicitly = true; + this.setPartHidden(false, Parts.EDITOR_PART); + } override readonly onDidChangePartVisibility = harness.onDidChangePartVisibility.event; isEditorMaximized(): boolean { return harness.editorMaximized; } + get isSinglePaneLayoutEnabled(): boolean { return options.singlePaneLayoutEnabled ?? false; } readonly onDidChangeEditorMaximized = harness.onDidChangeEditorMaximized.event; override readonly onDidLayoutMainContainer = harness.onDidLayoutMainContainer.event; override get mainContainerDimension(): IDimension { return { width: harness.mainContainerWidth, height: 1000 }; } } as Partial<IWorkbenchLayoutService> as IWorkbenchLayoutService); instaService.stub(IViewsService, new class extends mock<IViewsService>() { + override readonly onDidChangeViewContainerVisibility = harness.onDidChangeViewContainerVisibility.event; + override isViewContainerActive(id: string): boolean { + return harness.activeAuxViewContainerIds.includes(id); + } override async openViewContainer(id: string) { harness.openedViewContainers.push(id); revealAuxiliaryBar(); @@ -242,6 +398,24 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption } }); + instaService.stub(IViewDescriptorService, new class extends mock<IViewDescriptorService>() { + override readonly onDidChangeViewContainers = Event.None; + override readonly onDidChangeContainerLocation = Event.None; + override getViewContainersByLocation(location: ViewContainerLocation): ViewContainer[] { + if (location !== ViewContainerLocation.AuxiliaryBar) { + return []; + } + return [CHANGES_VIEW_CONTAINER_ID, SESSIONS_FILES_CONTAINER_ID].map(id => { + const container: Partial<ViewContainer> = { id }; + return container as ViewContainer; + }); + } + override getViewContainerModel(_container: ViewContainer): IViewContainerModel { + const model = { onDidChangeActiveViewDescriptors: harness.onDidChangeActiveViewDescriptors.event }; + return model as unknown as IViewContainerModel; + } + }); + function revealAuxiliaryBar(): void { if (!options.revealAuxiliaryBarOnOpen || harness.partVisibility.get(Parts.AUXILIARYBAR_PART) === true) { return; @@ -267,19 +441,75 @@ export function createTestHarness(store: DisposableStore, options: ICreateOption instaService.stub(IEditorService, new class extends mock<IEditorService>() { override get visibleEditors() { return harness.visibleEditorsList as IEditorService['visibleEditors']; } override readonly onDidActiveEditorChange = harness.onDidActiveEditorChange.event; + override readonly onWillOpenEditor = harness.onWillOpenEditor.event; + override readonly onDidCloseEditor = harness.onDidCloseEditor.event as unknown as IEditorService['onDidCloseEditor']; + override readonly onDidEditorsChange = harness.onDidEditorsChange.event as unknown as IEditorService['onDidEditorsChange']; override get activeEditor() { + if (harness.activeEditorInput) { + return harness.activeEditorInput as IEditorService['activeEditor']; + } if (!harness.activeEditorResource) { return undefined; } const editor = { resource: harness.activeEditorResource }; return editor as IEditorService['activeEditor']; } + override async openEditor(...args: unknown[]): Promise<undefined> { + const editor = args[0]; + if (editor instanceof EditorInput && !harness.activeGroupEditors.includes(editor)) { + const options = args[1] as { index?: number } | undefined; + const index = options?.index; + if (typeof index === 'number' && index >= 0 && index <= harness.activeGroupEditors.length) { + harness.activeGroupEditors.splice(index, 0, store.add(editor)); + } else { + harness.activeGroupEditors.push(store.add(editor)); + } + } + return undefined; + } + override async openEditors(editors: readonly IUntypedEditorInput[]): Promise<never[]> { + for (const editor of editors) { + harness.openedEditors.push(editor); + const resource = isResourceEditorInput(editor) ? editor.resource : undefined; + if (resource) { + const stub = store.add(new TestStubEditorInput(resource)); + const index = editor.options?.index; + if (typeof index === 'number' && index >= 0 && index <= harness.activeGroupEditors.length) { + harness.activeGroupEditors.splice(index, 0, stub); + } else { + harness.activeGroupEditors.push(stub); + } + } + } + return []; + } + override async closeEditors(editors: readonly { editor: EditorInput }[]): Promise<void> { + for (const { editor } of editors) { + const index = harness.activeGroupEditors.indexOf(editor); + if (index !== -1) { + harness.closeSuppressionFlags.push(harness.editorPartAutoVisibilitySuppressionDepth > 0); + harness.activeGroupEditors.splice(index, 1); + harness.closedEditors.push(editor); + } + } + } }); instaService.stub(IEditorGroupsService, new class extends mock<IEditorGroupsService>() { - override get groups() { return [{ isEmpty: false }] as unknown as IEditorGroupsService['groups']; } - override saveWorkingSet(name: string): IEditorWorkingSet { return { id: name, name }; } - override async applyWorkingSet() { return true; } + override get mainPart() { + const groups = this.groups; + return new class extends mock<IEditorGroupsService['mainPart']>() { + override get groups() { return groups; } + override get activeGroup() { return testActiveGroup; } + }; + } + override get groups() { return [{ isEmpty: !harness.editorGroupsHaveContent }] as unknown as IEditorGroupsService['groups']; } + override saveWorkingSet(name: string): IEditorWorkingSet { harness.saveWorkingSetCalls.push(name); return { id: name, name }; } + override async applyWorkingSet(workingSet: IEditorWorkingSet | 'empty') { + harness.applyWorkingSetCalls.push(workingSet); + harness.onApplyWorkingSet?.(); + return true; + } override deleteWorkingSet() { } }); diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts index ae1946dbfa6c36..72c43fd2730b84 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionTour.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { IObservable } from '../../../../../base/common/observable.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorPartModalContext } from '../../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; @@ -34,6 +36,21 @@ export const NEW_SESSION_TOUR_ID = 'sessions.onboarding.newSession'; */ export const NEW_SESSION_ONBOARDING_SEEN_KEY = NEW_SESSION_TOUR_ID; +/** + * ExP treatment flag names for Tour 2's A/B experiment. + * + * - `behaviorFlag` — boolean: `true` shows the tour (treatment), `false` is control. + * - `assignmentContextIdFlag` — string: this tour's assignment-context identifier, + * the key its scorecard groups on. Both arms MUST resolve it to the *same* value, + * which MUST start with the reserved `onb-` prefix (see + * `ONBOARDING_ASSIGNMENT_CONTEXT_PREFIX`). It is distinct from Tour 1's id so the + * two tours report into separate scorecards. + */ +const NEW_SESSION_EXPERIMENT = { + behaviorFlag: 'onb.newSession.show', + assignmentContextIdFlag: 'onb.newSession.id', +} as const; + const newSessionPayload: ISpotlightPayload = { steps: [ { @@ -59,14 +76,16 @@ const newSessionPayload: ISpotlightPayload = { * {@link NewSessionTourContribution}, which flips it after the eligible user * presses the pulsing New Session button. * `ChatContextKeys.enabled` keeps the tour hidden when AI features are disabled. + * The modal-editor gate keeps the tour hidden while a modal editor is showing. */ export function createNewSessionTour(signal: IObservable<boolean>): IOnboardingScenario<ISpotlightPayload> { return { id: NEW_SESSION_TOUR_ID, seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorPartModalContext.toNegated()), trigger: { kind: 'observable', signal }, priority: 100, + experiment: NEW_SESSION_EXPERIMENT, presentation: { kind: SPOTLIGHT_PRESENTATION_KIND, payload: newSessionPayload, diff --git a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts index e05c0a7f9035e3..131fbc403e0d56 100644 --- a/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts +++ b/src/vs/sessions/contrib/onboardingTours/browser/tours/newSessionViewTour.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { IObservable } from '../../../../../base/common/observable.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { EditorPartModalContext } from '../../../../../workbench/common/contextkeys.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { IOnboardingScenario } from '../../../../../workbench/contrib/onboarding/common/onboardingScenario.js'; import { ISpotlightPayload, SPOTLIGHT_PRESENTATION_KIND } from '../../../../../workbench/contrib/onboarding/browser/spotlight/spotlightTypes.js'; @@ -36,6 +38,21 @@ import { SessionHarnessPickerVisibleContext, SessionIsolationPickerVisibleContex */ export const NEW_SESSION_VIEW_TOUR_ID = 'sessions.onboarding.newSessionView'; +/** + * ExP treatment flag names for Tour 1's A/B experiment. + * + * - `behaviorFlag` — boolean: `true` shows the tour (treatment), `false` is control. + * - `assignmentContextIdFlag` — string: this tour's assignment-context identifier, + * the key its scorecard groups on. Both arms MUST resolve it to the *same* value, + * which MUST start with the reserved `onb-` prefix (see + * `ONBOARDING_ASSIGNMENT_CONTEXT_PREFIX`). It is distinct from Tour 2's id so the + * two tours report into separate scorecards. + */ +const NEW_SESSION_VIEW_EXPERIMENT = { + behaviorFlag: 'onb.newSessionView.show', + assignmentContextIdFlag: 'onb.newSessionView.id', +} as const; + const newSessionViewPayload: ISpotlightPayload = { steps: [ { @@ -71,6 +88,7 @@ const newSessionViewPayload: ISpotlightPayload = { * {@link NewSessionViewTourContribution}, which flips it once an eligible * (brand-new) user has the new-session view open and rendered. * `ChatContextKeys.enabled` keeps the tour hidden when AI features are disabled. + * The modal-editor gate keeps the tour hidden while a modal editor is showing. * * Shares {@link NEW_SESSION_ONBOARDING_SEEN_KEY} with the pulsing-button * {@link createNewSessionTour} variant, so a user who has seen either tour is @@ -80,9 +98,10 @@ export function createNewSessionViewTour(signal: IObservable<boolean>): IOnboard return { id: NEW_SESSION_VIEW_TOUR_ID, seenKey: NEW_SESSION_ONBOARDING_SEEN_KEY, - when: ChatContextKeys.enabled, + when: ContextKeyExpr.and(ChatContextKeys.enabled, EditorPartModalContext.toNegated()), trigger: { kind: 'observable', signal }, priority: 100, + experiment: NEW_SESSION_VIEW_EXPERIMENT, presentation: { kind: SPOTLIGHT_PRESENTATION_KIND, payload: newSessionViewPayload, diff --git a/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css new file mode 100644 index 00000000000000..fdd5ab06431b7e --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/media/promptTimeline.css @@ -0,0 +1,264 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.prompt-timeline-rail { + position: absolute; + top: 0; + right: 0; + bottom: var(--prompt-timeline-bottom, 0); + width: 36px; + pointer-events: none; + z-index: 10; +} + +/* Anchors the rail overlay to the chat widget container (added/removed with the rail's lifecycle). */ +.prompt-timeline-host { + position: relative; +} + +.prompt-timeline-rail.hidden { + display: none; +} + +/* Not enough horizontal room next to the transcript: hide the marks but keep the rail laid out so its ResizeObserver can detect it fits again. */ +.prompt-timeline-rail.overflowing .prompt-timeline-ruler-marks { + display: none; +} + +/* Interactive hover/focus card: prompt text, clickable diff stat, and files. */ +.prompt-timeline-card { + position: absolute; + right: 44px; + width: 280px; + background-color: var(--vscode-editorHoverWidget-background); + color: var(--vscode-editorHoverWidget-foreground); + border: 1px solid var(--vscode-editorHoverWidget-border); + border-radius: 6px; + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + pointer-events: auto; + overflow: hidden; + z-index: 20; +} + +.prompt-timeline-card.hidden { + display: none; +} + +.prompt-timeline-card-head { + padding: 8px 10px; +} + +.prompt-timeline-card-text { + font-size: 12px; + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.prompt-timeline-card-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.prompt-timeline-card-diff-action { + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 6px; + margin-left: -7px; + padding: 3px 7px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 11px; + cursor: pointer; +} + +.prompt-timeline-card-diff-action:hover { + background-color: var(--vscode-list-hoverBackground); + border-color: var(--vscode-focusBorder); +} + +.prompt-timeline-card-diff-action:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 1px; +} + +.prompt-timeline-card-diff-action-chevron { + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.prompt-timeline-card-diff-action:hover .prompt-timeline-card-diff-action-chevron { + color: var(--vscode-focusBorder); +} + +.prompt-timeline-card-no-edits { + margin-top: 6px; + color: var(--vscode-descriptionForeground); + font-size: 11px; +} + +.prompt-timeline-card-stat .added, +.prompt-timeline-card-fstat .added { + color: var(--vscode-gitDecoration-addedResourceForeground, var(--vscode-charts-green)); +} + +.prompt-timeline-card-stat .removed, +.prompt-timeline-card-fstat .removed { + color: var(--vscode-gitDecoration-deletedResourceForeground, var(--vscode-charts-red)); + margin-left: 4px; +} + +.prompt-timeline-card-files { + border-top: 1px solid var(--vscode-editorHoverWidget-border); + margin-top: 8px; + max-height: 140px; + overflow-y: auto; +} + +.prompt-timeline-card-file { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 4px 10px; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 11px; + text-align: left; +} + +.prompt-timeline-card-file:hover { + background-color: var(--vscode-list-hoverBackground); +} + +.prompt-timeline-card-file:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.prompt-timeline-card-fname { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.prompt-timeline-card-fstat { + font-variant-numeric: tabular-nums; +} + +/* Overview-ruler style: the session compressed into the rail height like the editor overview ruler (proportional marks + the scrollbar slider as a you-are-here thumb). */ +.prompt-timeline-ruler-marks { + position: absolute; + inset: 0; + pointer-events: none; +} + +/* Each mark is a >=24px hit target positioned at its proportional scroll offset. */ +.prompt-timeline-ruler-mark { + position: absolute; + right: 0; + width: 22px; + height: 24px; + display: flex; + align-items: center; + justify-content: flex-end; + padding-right: 4px; + border: none; + background: transparent; + cursor: pointer; + pointer-events: auto; +} + +.prompt-timeline-ruler-mark.hidden { + display: none; +} + +/* The visible mark: a short bar, neutral unless the turn changed code. */ +.prompt-timeline-ruler-bar { + display: flex; + overflow: hidden; + width: 12px; + height: 2px; + border-radius: 1px; + background-color: var(--vscode-scrollbarSlider-background); + transition: width 80ms ease, height 80ms ease, background-color 80ms ease; +} + +/* Two-tone edited signal: a green added segment and a red removed segment, sized by the turn's split. */ +.prompt-timeline-ruler-bar .seg-add, +.prompt-timeline-ruler-bar .seg-del { + height: 100%; + min-width: 1px; +} + +.prompt-timeline-ruler-bar.edited .seg-add { + background-color: var(--vscode-editorOverviewRuler-addedForeground); +} + +.prompt-timeline-ruler-bar.edited .seg-del { + background-color: var(--vscode-editorOverviewRuler-deletedForeground); +} + +.prompt-timeline-ruler-mark:hover .prompt-timeline-ruler-bar { + width: 16px; + height: 3px; + background-color: var(--vscode-scrollbarSlider-hoverBackground); +} + +/* Active = the prompt currently in view. */ +.prompt-timeline-ruler-mark.active .prompt-timeline-ruler-bar { + width: 18px; + height: 3px; + background-color: var(--vscode-focusBorder); +} + +.prompt-timeline-ruler-mark.active .prompt-timeline-ruler-bar .seg-add, +.prompt-timeline-ruler-mark.active .prompt-timeline-ruler-bar .seg-del { + background-color: transparent; +} + +.prompt-timeline-rail .prompt-timeline-ruler-mark:focus { + outline: none; +} + +.prompt-timeline-rail .prompt-timeline-ruler-mark:focus-visible .prompt-timeline-ruler-bar { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: 2px; +} + +/* The you-are-here thumb reuses the scrollbar slider. */ +.prompt-timeline-ruler-thumb { + position: absolute; + right: 2px; + width: 6px; + border-radius: 3px; + background-color: var(--vscode-scrollbarSlider-background); + pointer-events: none; +} + +.prompt-timeline-ruler-thumb.hidden { + display: none; +} + +.prompt-timeline-rail-ruler:hover .prompt-timeline-ruler-thumb { + background-color: var(--vscode-scrollbarSlider-hoverBackground); +} + +@media (prefers-reduced-motion: reduce) { + .prompt-timeline-ruler-bar { + transition: none; + } +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptBucketing.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptBucketing.ts new file mode 100644 index 00000000000000..2813ba2986cd99 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptBucketing.ts @@ -0,0 +1,169 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** A user prompt that can be represented on the prompt timeline. */ +export interface PromptItem { + /** Stable id of the user request (chat request view model id). */ + readonly requestId: string; + /** Preview text of the prompt (already single-line/plain is fine). */ + readonly text: string; + /** Creation time in ms since epoch. */ + readonly timestamp: number; +} + +/** A timeline tick representing one or more chronological prompts. */ +export interface PromptBucket { + /** Representative prompt (the FIRST prompt in the bucket) — the jump target. */ + readonly prompt: PromptItem; + /** All prompts grouped into this bucket, in chronological order. */ + readonly prompts: readonly PromptItem[]; + /** How many prompts this tick represents (== prompts.length). */ + readonly count: number; +} + +/** Hard cap on the number of ticks produced. */ +export const MAX_TICKS = 24; + +const oneDayMs = 86400000; + +function bucketKey(date: Date, now: Date): string { + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + if (date >= startOfToday) { + return `today-${date.getTime()}`; + } + + const daysAgo = (startOfToday.getTime() - date.getTime()) / oneDayMs; + if (daysAgo < 1) { + return `yesterday-h${date.getHours()}`; + } + + if (daysAgo < 30) { + return `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; + } + + return `month-${date.getFullYear()}-${date.getMonth()}`; +} + +function toBucket(prompt: PromptItem, prompts: readonly PromptItem[] = [prompt]): PromptBucket { + return { + prompt, + prompts, + count: prompts.length + }; +} + +function bucketPrompts(prompts: readonly PromptItem[], now: Date): PromptBucket[] { + const buckets: PromptBucket[] = []; + let currentKey: string | undefined; + + for (const prompt of prompts) { + const key = bucketKey(new Date(prompt.timestamp), now); + const current = buckets[buckets.length - 1]; + + if (!current || key !== currentKey) { + buckets.push(toBucket(prompt)); + currentKey = key; + } else { + const groupedPrompts = [...current.prompts, prompt]; + buckets[buckets.length - 1] = toBucket(current.prompt, groupedPrompts); + } + } + + return buckets; +} + +function uniformSample(buckets: PromptBucket[], maxTicks: number): PromptBucket[] { + if (buckets.length <= maxTicks) { + return buckets; + } + + const first = buckets[0]; + const last = buckets[buckets.length - 1]; + + if (maxTicks <= 2) { + return [first, last]; + } + + const sampled: PromptBucket[] = [first]; + const step = (buckets.length - 1) / (maxTicks - 1); + for (let i = 1; i <= maxTicks - 2; i++) { + const index = Math.round(i * step); + if (index > 0 && index < buckets.length - 1) { + sampled.push(buckets[index]); + } + } + sampled.push(last); + + return sampled; +} + +function expandBucket(bucket: PromptBucket, budget: number): PromptBucket[] { + if (bucket.count <= budget + 1) { + return bucket.prompts.map(prompt => toBucket(prompt)); + } + + const expandedCount = budget; + const remainder = bucket.prompts.slice(0, bucket.count - expandedCount); + const expandedPrompts = bucket.prompts.slice(bucket.count - expandedCount); + + return [ + toBucket(remainder[0], remainder), + ...expandedPrompts.map(prompt => toBucket(prompt)) + ]; +} + +function expandRecentBuckets(buckets: PromptBucket[], maxTicks: number): PromptBucket[] { + if (buckets.length >= maxTicks) { + return buckets; + } + + let expanded = buckets; + let total = buckets.length; + + for (let i = expanded.length - 1; i >= 0 && total < maxTicks; i--) { + const bucket = expanded[i]; + if (bucket.count <= 1) { + continue; + } + + const replacement = expandBucket(bucket, maxTicks - total); + total += replacement.length - 1; + expanded = [ + ...expanded.slice(0, i), + ...replacement, + ...expanded.slice(i + 1) + ]; + } + + return expanded; +} + +/** + * Compresses chronological prompts into a bounded set of recency-aware timeline ticks. + * Pass `now` in tests to make time-based grouping deterministic. `maxTicks` caps the + * result (defaults to {@link MAX_TICKS}); callers lower it to fit the available height. + */ +export function budgetBucketPrompts(prompts: readonly PromptItem[], now = Date.now(), maxTicks = MAX_TICKS): PromptBucket[] { + const cap = Math.min(MAX_TICKS, Math.max(1, maxTicks)); + const buckets = bucketPrompts(prompts, new Date(now)); + if (buckets.length > cap) { + return uniformSample(buckets, cap); + } + + if (buckets.length < cap) { + return expandRecentBuckets(buckets, cap); + } + + return buckets; +} + +/** Internal helpers exposed only for focused unit tests. */ +export const _testing = { + bucketKey, + bucketPrompts, + uniformSample, + expandRecentBuckets +} as const; diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimeline.contribution.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimeline.contribution.ts new file mode 100644 index 00000000000000..7dcefcfa206f68 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimeline.contribution.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; +import { PROMPT_TIMELINE_ENABLED_SETTING } from '../common/promptTimeline.js'; +import { registerPromptTimelineActions } from './promptTimelineActions.js'; +import { PromptTimelineWidgetContrib } from './promptTimelineWidgetContrib.js'; + +Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [PROMPT_TIMELINE_ENABLED_SETTING]: { + type: 'boolean', + default: false, + description: localize('sessions.promptTimeline.enabled', "Controls whether the prompt timeline rail is shown alongside the chat transcript in the Agents window. The rail lets you scan and jump between the prompts you have sent."), + tags: ['experimental'], + experiment: { mode: 'startup' }, + }, + }, +}); + +ChatWidget.CONTRIBS.push(PromptTimelineWidgetContrib); +registerPromptTimelineActions(); diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineActions.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineActions.ts new file mode 100644 index 00000000000000..c0a5c6727ed9df --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineActions.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. + *--------------------------------------------------------------------------------------------*/ + +import { localize, localize2 } from '../../../../nls.js'; +import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IChatWidgetService } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { PromptTimelineCommandId, PROMPT_TIMELINE_ENABLED_SETTING } from '../common/promptTimeline.js'; +import { PromptTimelineWidgetContrib } from './promptTimelineWidgetContrib.js'; + +const CATEGORY = localize2('promptTimeline.category', "Chat"); + +/** True unless the prompt timeline setting is explicitly disabled. */ +const TIMELINE_ENABLED = ContextKeyExpr.notEquals(`config.${PROMPT_TIMELINE_ENABLED_SETTING}`, false); + +/** Commands require AI features to be on and the prompt timeline setting to be enabled. */ +const TIMELINE_PRECONDITION = ContextKeyExpr.and(ChatContextKeys.enabled, TIMELINE_ENABLED); + +/** Resolves the prompt timeline contribution for the active session's chat widget. */ +function getPromptTimeline(accessor: ServicesAccessor): PromptTimelineWidgetContrib | undefined { + const widgetService = accessor.get(IChatWidgetService); + const sessionsService = accessor.get(ISessionsService); + const resource = sessionsService.activeSession.get()?.activeChat.get().resource; + const widget = (resource && widgetService.getWidgetBySessionResource(resource)) ?? widgetService.lastFocusedWidget; + return widget?.getContrib<PromptTimelineWidgetContrib>(PromptTimelineWidgetContrib.ID); +} + +interface IPromptPickItem extends IQuickPickItem { + readonly requestId: string; +} + +function formatStat(tick: { stat?: { added: number; removed: number; fileCount: number } }): string | undefined { + if (!tick.stat) { + return undefined; + } + const files = tick.stat.fileCount === 1 + ? localize('promptTimeline.oneFile', "1 file") + : localize('promptTimeline.nFiles', "{0} files", tick.stat.fileCount); + return localize('promptTimeline.statDetail', "+{0} \u2212{1} · {2}", tick.stat.added, tick.stat.removed, files); +} + +class GoToPromptAction extends Action2 { + constructor() { + super({ + id: PromptTimelineCommandId.GoToPrompt, + title: localize2('promptTimeline.goToPrompt', "Go to Prompt..."), + category: CATEGORY, + f1: true, + precondition: TIMELINE_PRECONDITION, + }); + } + override async run(accessor: ServicesAccessor): Promise<void> { + const contrib = getPromptTimeline(accessor); + const prompts = contrib?.getAllPrompts() ?? []; + if (!contrib || prompts.length === 0) { + return; + } + + const quickInputService = accessor.get(IQuickInputService); + const items: IPromptPickItem[] = prompts.map(prompt => ({ + label: prompt.text || localize('promptTimeline.emptyPrompt', "(empty prompt)"), + description: formatStat(prompt), + requestId: prompt.requestId, + })); + + const picked = await quickInputService.pick(items, { + placeHolder: localize('promptTimeline.pickPlaceholder', "Go to a prompt in this chat"), + matchOnDescription: true, + }); + if (picked) { + contrib.reveal(picked.requestId); + } + } +} + +export function registerPromptTimelineActions(): void { + registerAction2(GoToPromptAction); +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineCard.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineCard.ts new file mode 100644 index 00000000000000..e789038c7d79cb --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineCard.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. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, clearNode, EventType } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IPromptReviewFileEvent } from './promptTimelineRail.js'; +import { PromptFileDiff, PromptTick } from './promptTimelineModel.js'; + +/** + * The interactive preview shown when a prompt mark is hovered or focused, shared + * by every rail style. Renders the prompt text, its diff summary (a shortcut to + * review the whole prompt) and per-file rows, and stays open while hovered. + */ +export class PromptTimelineCard extends Disposable { + + private readonly _element: HTMLElement; + private readonly _contentDisposables = this._register(new DisposableStore()); + private _hovered = false; + private _hideTimer: ReturnType<typeof setTimeout> | undefined; + private _filesProvider: (tick: PromptTick) => readonly PromptFileDiff[] = () => []; + + private readonly _onDidReview = this._register(new Emitter<PromptTick>()); + readonly onDidReview: Event<PromptTick> = this._onDidReview.event; + + private readonly _onDidReviewFile = this._register(new Emitter<IPromptReviewFileEvent>()); + readonly onDidReviewFile: Event<IPromptReviewFileEvent> = this._onDidReviewFile.event; + + constructor(private readonly _container: HTMLElement) { + super(); + this._element = append(this._container, $('.prompt-timeline-card')); + this._element.classList.add('hidden'); + this._register(addDisposableListener(this._element, EventType.MOUSE_ENTER, () => { this._hovered = true; })); + this._register(addDisposableListener(this._element, EventType.MOUSE_LEAVE, () => { this._hovered = false; this.scheduleHide(); })); + } + + setFilesProvider(provider: (tick: PromptTick) => readonly PromptFileDiff[]): void { + this._filesProvider = provider; + } + + /** Builds the card for a tick and positions it centered on `anchorCenterY` (relative to the container). */ + show(tick: PromptTick, anchorCenterY: number): void { + if (this._hideTimer !== undefined) { + clearTimeout(this._hideTimer); + this._hideTimer = undefined; + } + this._contentDisposables.clear(); + clearNode(this._element); + + const head = append(this._element, $('.prompt-timeline-card-head')); + append(head, $('.prompt-timeline-card-text')).textContent = tick.text; + // Grouped ticks show how many prompts they cover. No absolute time: agent-host + // sessions don't record per-turn timestamps, so it would be misleading. + if (tick.count > 1) { + append(head, $('.prompt-timeline-card-meta')).textContent = localize('promptTimeline.groupedCount', "{0} prompts", tick.count); + } + + const files = tick.stat ? this._filesProvider(tick) : []; + if (tick.stat) { + const diffAction = append(head, $<HTMLButtonElement>('button.prompt-timeline-card-diff-action')); + diffAction.setAttribute('aria-label', localize( + 'promptTimeline.reviewChangesForPrompt', + "Review Changes for Prompt: {0}", + tick.text, + )); + this._renderStat(append(diffAction, $('span.prompt-timeline-card-stat')), tick.stat.added, tick.stat.removed); + append(diffAction, $('span')).textContent = tick.stat.fileCount === 1 + ? localize('promptTimeline.oneFile', "1 file") + : localize('promptTimeline.nFiles', "{0} files", tick.stat.fileCount); + append(diffAction, $('span.prompt-timeline-card-diff-action-chevron')).textContent = '\u203A'; + this._contentDisposables.add(addDisposableListener(diffAction, EventType.CLICK, () => { + this._onDidReview.fire(tick); + this.hide(); + })); + } else { + append(head, $('div.prompt-timeline-card-no-edits')).textContent = localize('promptTimeline.noEdits', "no edits"); + } + + if (files.length > 0) { + const list = append(this._element, $('.prompt-timeline-card-files')); + for (const file of files) { + const row = append(list, $<HTMLButtonElement>('button.prompt-timeline-card-file')); + row.title = file.name; + append(row, $('.prompt-timeline-card-fname')).textContent = file.name; + this._renderStat(append(row, $('.prompt-timeline-card-fstat')), file.added, file.removed); + this._contentDisposables.add(addDisposableListener(row, EventType.CLICK, () => { + this._onDidReviewFile.fire({ tick, file: file.modifiedURI }); + this.hide(); + })); + } + } + + this._element.classList.remove('hidden'); + const top = anchorCenterY - this._element.offsetHeight / 2; + const clampedTop = Math.max(4, Math.min(top, this._container.clientHeight - this._element.offsetHeight - 4)); + this._element.style.top = `${clampedTop}px`; + } + + private _renderStat(container: HTMLElement, added: number, removed: number): void { + append(container, $('span.added')).textContent = `+${added}`; + append(container, $('span.removed')).textContent = `\u2212${removed}`; + } + + /** Hides the card shortly, unless it (or a mark) is re-hovered first. */ + scheduleHide(): void { + if (this._hideTimer !== undefined) { + clearTimeout(this._hideTimer); + } + this._hideTimer = setTimeout(() => { + this._hideTimer = undefined; + if (!this._hovered) { + this.hide(); + } + }, 200); + } + + hide(): void { + this._hovered = false; + this._contentDisposables.clear(); + this._element.classList.add('hidden'); + } + + override dispose(): void { + if (this._hideTimer !== undefined) { + clearTimeout(this._hideTimer); + } + super.dispose(); + } +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts new file mode 100644 index 00000000000000..c5dae37168be35 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineModel.ts @@ -0,0 +1,449 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { localize } from '../../../../nls.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, IObservableSignal, IReader, ISettableObservable, observableFromEvent, observableSignal, observableValue, transaction } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { basename, isEqual } from '../../../../base/common/resources.js'; +import { generateUuid } from '../../../../base/common/uuid.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IFileService } from '../../../../platform/files/common/files.js'; +import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; +import { MultiDiffEditorInput } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffEditorInput.js'; +import { MultiDiffEditorItem } from '../../../../workbench/contrib/multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { IMultiDiffEditorOptions } from '../../../../editor/browser/widget/multiDiffEditor/multiDiffEditorWidgetImpl.js'; +import { ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; +import { IChatResponseFileChangesService } from '../../../../workbench/contrib/chat/browser/chatResponseFileChangesService.js'; +import { IChatEditingService, IEditSessionEntryDiff } from '../../../../workbench/contrib/chat/common/editing/chatEditingService.js'; +import { isRequestVM } from '../../../../workbench/contrib/chat/common/model/chatViewModel.js'; +import { budgetBucketPrompts, MAX_TICKS, PromptItem } from './promptBucketing.js'; + +/** Aggregated diff stats for the edits a prompt (or bucket) produced. */ +export interface PromptDiffStat { + readonly added: number; + readonly removed: number; + readonly fileCount: number; +} + +/** A single file changed by a prompt, used by the hover card / diff drill-down. */ +export interface PromptFileDiff { + readonly name: string; + readonly originalURI: URI; + /** File identity / go-to-file target (may be the live working file). */ + readonly modifiedURI: URI; + /** RHS content the diff should render; the frozen after-turn snapshot when available. */ + readonly diffModifiedURI: URI; + readonly added: number; + readonly removed: number; +} + +/** Content-space layout used by the overview-ruler rail to place marks + the viewport thumb. */ +export interface IPromptScrollLayout { + /** Each prompt's top offset in transcript content pixels. */ + readonly marks: readonly { readonly requestId: string; readonly top: number }[]; + /** Total transcript content height in pixels. */ + readonly total: number; + /** Current scroll offset in content pixels. */ + readonly scrollTop: number; +} + +/** A single tick shown on the prompt timeline rail. */ +export interface PromptTick { + /** Jump target: the request id of the first prompt in the bucket. */ + readonly requestId: string; + /** Request ids of every prompt this tick represents (for active tracking). */ + readonly allRequestIds: readonly string[]; + /** Preview text (first prompt in the bucket). */ + readonly text: string; + /** Creation time (ms since epoch) of the first prompt in the bucket. */ + readonly timestamp: number; + /** How many prompts this tick represents. */ + readonly count: number; + /** Accessible label announced for the tick. */ + readonly ariaLabel: string; + /** Diff summary of the edits this tick produced, if any. */ + readonly stat?: PromptDiffStat; +} + +const MAX_PREVIEW_LENGTH = 80; + +/** First non-empty line of a prompt, trimmed and length-capped for previews. */ +function getPromptPreview(text: string): string { + const firstLine = text.split('\n').map(l => l.trim()).find(l => l.length > 0) ?? ''; + return firstLine.length <= MAX_PREVIEW_LENGTH ? firstLine : `${firstLine.slice(0, MAX_PREVIEW_LENGTH)}…`; +} + +/** Whether two derived prompt lists are equivalent (order, id, text and time). */ +function promptsEqual(a: readonly PromptItem[], b: readonly PromptItem[]): boolean { + return a.length === b.length && a.every((p, i) => + p.requestId === b[i].requestId && p.text === b[i].text && p.timestamp === b[i].timestamp); +} + +/** A user prompt entry (used by the keyboard "Go to Prompt" picker, independent of rail density). */ +export interface PromptEntry { + readonly requestId: string; + readonly text: string; + readonly timestamp: number; + readonly stat?: PromptDiffStat; +} + +/** + * Derives the prompt timeline (bucketed ticks + the active tick) from a chat + * widget's view model, and reveals prompts on request. + */ +export class PromptTimelineModel extends Disposable { + + /** All user prompts in the chat, updated as the transcript changes. */ + private readonly _prompts: ISettableObservable<readonly PromptItem[]> = observableValue<readonly PromptItem[]>(this, []); + + /** The chat session resource, tracked reactively so the editing session can be resolved. */ + private readonly _sessionResource: IObservable<URI | undefined>; + + /** The chat editing session for this chat, if one exists (local or agent-host). */ + private readonly _editingSession = derived(this, reader => { + const resource = this._sessionResource.read(reader); + if (!resource) { + return undefined; + } + return this.chatEditingService.editingSessionsObs.read(reader).find(s => isEqual(s.chatSessionResource, resource)); + }); + + /** Recency-bucketed ticks, capped to a fixed maximum so each keeps a >=24px slot. */ + private readonly _baseTicks = derived<readonly PromptTick[]>(this, reader => { + const prompts = this._prompts.read(reader); + return budgetBucketPrompts(prompts, Date.now(), MAX_TICKS).map((bucket): PromptTick => ({ + requestId: bucket.prompt.requestId, + allRequestIds: bucket.prompts.map(p => p.requestId), + text: bucket.prompt.text, + timestamp: bucket.prompt.timestamp, + count: bucket.count, + ariaLabel: bucket.count === 1 + ? localize('promptTimeline.tick', "Prompt: {0}", bucket.prompt.text) + : localize('promptTimeline.tickGrouped', "{0} prompts starting with: {1}", bucket.count, bucket.prompt.text), + })); + }); + + /** Ticks decorated with per-prompt diff stats (server per-turn changeset, else editing session). */ + private readonly _ticks = derived<readonly PromptTick[]>(this, reader => { + const base = this._baseTicks.read(reader); + return base.map(tick => { + const stat = this._statForRequests(tick.allRequestIds, reader); + return stat ? { ...tick, stat } : tick; + }); + }); + get ticks(): IObservable<readonly PromptTick[]> { return this._ticks; } + + private readonly _activeRequestId: ISettableObservable<string | undefined> = observableValue<string | undefined>(this, undefined); + get activeRequestId(): IObservable<string | undefined> { return this._activeRequestId; } + + /** Fires when the transcript scroll offset or content height changes (drives the ruler rail). */ + private readonly _scrollLayoutSignal: IObservableSignal<void> = observableSignal<void>(this); + get onDidChangeScrollLayout(): IObservable<void> { return this._scrollLayoutSignal; } + + private readonly _viewModelListener = this._register(new MutableDisposable()); + + constructor( + private readonly widget: ChatWidget, + @IChatEditingService private readonly chatEditingService: IChatEditingService, + @IChatResponseFileChangesService private readonly chatResponseFileChangesService: IChatResponseFileChangesService, + @IEditorService private readonly editorService: IEditorService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IFileService private readonly fileService: IFileService, + ) { + super(); + // Assigned here (not as a field initializer) because it reads `this.widget`, + // which parameter properties only assign once the constructor body runs. + this._sessionResource = observableFromEvent(this, this.widget.onDidChangeViewModel, () => this.widget.viewModel?.sessionResource); + this._register(this.widget.onDidChangeViewModel(() => this._bindViewModel())); + this._register(this.widget.onDidScroll(() => { this._updateActive(); this._triggerScrollLayout(); })); + this._register(this.widget.onDidChangeContentHeight(() => this._triggerScrollLayout())); + // Re-evaluate the active tick whenever the ticks change. + this._register(autorun(reader => { + this._baseTicks.read(reader); + this._updateActive(); + this._triggerScrollLayout(); + })); + this._bindViewModel(); + } + + private _triggerScrollLayout(): void { + transaction(tx => this._scrollLayoutSignal.trigger(tx)); + } + + /** + * The prompts' positions in transcript content space, for the overview-ruler + * rail. Each prompt's top comes from the chat list's layout height model + * (`ChatWidget.getElementTop`), the same virtualization-safe source the + * scrollbar uses, so off-screen prompts get correct positions without waiting + * for their rows to render. Marks, `total`, and `scrollTop` all share the + * list's content-pixel space, so the viewport thumb stays aligned. + */ + getScrollLayout(): IPromptScrollLayout | undefined { + const items = this.widget.viewModel?.getItems(); + if (!items) { + return undefined; + } + const marks: { requestId: string; top: number }[] = []; + for (const item of items) { + if (isRequestVM(item)) { + const top = this.widget.getElementTop(item); + if (top !== undefined) { + marks.push({ requestId: item.id, top }); + } + } + } + return { marks, total: this.widget.scrollHeight, scrollTop: this.widget.scrollTop }; + } + + private _bindViewModel(): void { + this._viewModelListener.value = this.widget.viewModel?.onDidChange(() => this._recompute()); + this._recompute(); + } + + private _recompute(): void { + const prompts: PromptItem[] = []; + for (const item of this.widget.viewModel?.getItems() ?? []) { + if (isRequestVM(item)) { + prompts.push({ requestId: item.id, text: getPromptPreview(item.messageText), timestamp: item.timestamp }); + } + } + + // Streaming fires onDidChange for every token; only rebuild ticks when the + // set of prompts actually changed. Rendered heights still shift, so refresh + // the active tick either way. + if (promptsEqual(prompts, this._prompts.get())) { + this._updateActive(); + return; + } + this._prompts.set(prompts, undefined); + } + + /** Recomputes which tick maps to the prompt currently scrolled into view. */ + private _updateActive(): void { + const ticks = this._baseTicks.get(); + const items = this.widget.viewModel?.getItems(); + if (!items || ticks.length === 0) { + this._activeRequestId.set(undefined, undefined); + return; + } + + // The active prompt is the last request whose top edge is at or above the + // viewport top. Positions come from the list's layout height model, so + // off-screen prompts resolve correctly (not just rendered ones). + const scrollTop = this.widget.scrollTop; + const threshold = 24; + let activeRequestId: string | undefined; + let activeTimestamp = 0; + for (const item of items) { + if (isRequestVM(item)) { + const top = this.widget.getElementTop(item); + if (top !== undefined && top <= scrollTop + threshold) { + activeRequestId = item.id; + activeTimestamp = item.timestamp; + } + } + } + + if (activeRequestId === undefined) { + // Scrolled above the oldest prompt: the oldest tick is the active one + // (the loop advances oldest -> newest as you scroll down). + this._activeRequestId.set(ticks.at(0)?.requestId, undefined); + return; + } + + let activeTick = ticks.find(t => t.allRequestIds.includes(activeRequestId!)); + if (!activeTick) { + // The active prompt's bucket may have been sampled away; fall back to the + // nearest surviving tick at or before it (ticks are chronological). + for (const tick of ticks) { + if (tick.timestamp <= activeTimestamp) { + activeTick = tick; + } else { + break; + } + } + } + this._activeRequestId.set((activeTick ?? ticks[ticks.length - 1]).requestId, undefined); + } + + /** Reveals the request with the given id near the top of the transcript. */ + reveal(requestId: string): void { + const item = this.widget.viewModel?.getItems().find(i => isRequestVM(i) && i.id === requestId); + if (item) { + this.widget.reveal(item, 0); + } + // Normalize to the owning tick's representative id so the active highlight + // works even when the id is a mid-bucket prompt (picker). + const owningTick = this._baseTicks.get().find(t => t.allRequestIds.includes(requestId)); + this._activeRequestId.set(owningTick?.requestId ?? requestId, undefined); + } + + /** The changed files for a tick's prompts, aggregated per file (for the hover card / drill-down). */ + getRequestFiles(tick: PromptTick): readonly PromptFileDiff[] { + const byPath = new Map<string, PromptFileDiff>(); + for (const requestId of tick.allRequestIds) { + for (const diff of this._diffsForRequest(requestId)) { + if (diff.identical) { + continue; + } + const key = diff.modifiedURI.toString(); + const existing = byPath.get(key); + if (existing) { + // Grouped tick, same file across prompts: the prompts are + // chronological, so keep the earliest `originalURI` (before) but + // advance `diffModifiedURI` to this later prompt's after-snapshot + // so the opened diff spans the whole tick, not just the first edit. + byPath.set(key, { + ...existing, + diffModifiedURI: diff.modifiedSnapshotURI ?? diff.modifiedURI, + added: existing.added + diff.added, + removed: existing.removed + diff.removed, + }); + } else { + byPath.set(key, { + name: basename(diff.modifiedURI), + originalURI: diff.originalURI, + modifiedURI: diff.modifiedURI, + diffModifiedURI: diff.modifiedSnapshotURI ?? diff.modifiedURI, + added: diff.added, + removed: diff.removed, + }); + } + } + } + return [...byPath.values()]; + } + + /** + * Opens the per-prompt changes as a multi-file diff. When a specific file is + * given (a file row in the card), the same multi-diff is opened but revealed + * at that file, so per-file and whole-prompt review share one experience. + */ + async reviewChanges(tick: PromptTick, file?: URI): Promise<void> { + const files = this.getRequestFiles(tick); + if (files.length === 0) { + return; + } + const items: MultiDiffEditorItem[] = []; + let revealResource: { original: URI | undefined; modified: URI | undefined } | undefined; + for (const f of files) { + const [originalURI, modifiedURI] = await this._readableSides(f); + if (!originalURI && !modifiedURI) { + continue; + } + // Diff the best-available before/after content, but let "go to file" open the live file. + items.push(new MultiDiffEditorItem(originalURI, modifiedURI, f.modifiedURI)); + if (file && isEqual(f.modifiedURI, file)) { + revealResource = { original: originalURI, modified: modifiedURI }; + } + } + if (items.length === 0) { + return; + } + const source = URI.parse(`multi-diff-editor:prompt-timeline/${generateUuid()}`); + const input = this.instantiationService.createInstance( + MultiDiffEditorInput, + source, + localize('promptTimeline.reviewTitle', "Changes · {0}", tick.text), + items, + false, + ); + const options: IMultiDiffEditorOptions | undefined = revealResource + ? { viewState: { revealData: { resource: revealResource } } } + : undefined; + await this.editorService.openEditor(input, options); + } + + /** + * Resolves which sides of a file diff can actually be read. Prefers the frozen + * before/after snapshots so only this turn's changes show, but the agent-host + * checkpoint blobs backing them can be missing (an added file's original, or a + * pruned/restored session where whole checkpoints are gone). The modified side + * then falls back to the live working file so review still opens with the best + * available fidelity; an unreadable side is dropped so the file still renders + * as a pure add/delete instead of crashing the diff editor. + */ + private async _readableSides(file: PromptFileDiff): Promise<[URI | undefined, URI | undefined]> { + // The provider sets originalURI === modifiedURI when there is no "before" + // (a created file); treat that as no frozen original. + const hasFrozenOriginal = !isEqual(file.originalURI, file.modifiedURI); + const hasFrozenModified = !isEqual(file.diffModifiedURI, file.modifiedURI); + const [frozenOriginalReadable, frozenModifiedReadable, liveModifiedReadable] = await Promise.all([ + hasFrozenOriginal ? this._canRead(file.originalURI) : Promise.resolve(false), + hasFrozenModified ? this._canRead(file.diffModifiedURI) : Promise.resolve(false), + this._canRead(file.modifiedURI), + ]); + const modified = frozenModifiedReadable ? file.diffModifiedURI + : liveModifiedReadable ? file.modifiedURI + : undefined; + return [frozenOriginalReadable ? file.originalURI : undefined, modified]; + } + + private async _canRead(resource: URI): Promise<boolean> { + // Agent-host git-blob URIs always `stat` successfully even when the blob + // is missing, so probe with an actual read to detect unreadable sides. + // Read a single byte: enough to surface a not-found error without pulling + // whole (potentially large) file contents just to test availability. + try { + await this.fileService.readFile(resource, { length: 1 }); + return true; + } catch { + return false; + } + } + + /** + * All user prompts (with diff stats where available) for the picker, + * independent of the rail's bucketing. Stats are resolved one-shot, so + * agent-host prompts not currently observed by the rail fall back to their + * timestamp in the picker rather than holding a subscription per prompt. + */ + getAllPrompts(): readonly PromptEntry[] { + return this._prompts.get().map(prompt => { + const stat = this._statForRequests([prompt.requestId]); + return stat ? { ...prompt, stat } : { ...prompt }; + }); + } + + /** + * Per-request file diffs, preferring the session type's authoritative + * provider (agent-host sessions expose a server-computed per-turn changeset + * that survives reload), and falling back to the chat editing session. + */ + private _diffsForRequest(requestId: string, reader?: IReader): readonly IEditSessionEntryDiff[] { + const resource = reader ? this._sessionResource.read(reader) : this._sessionResource.get(); + if (resource) { + const provided = this.chatResponseFileChangesService.getChangesForRequest(resource, requestId); + if (provided) { + return reader ? provided.read(reader) : provided.get(); + } + } + const session = reader ? this._editingSession.read(reader) : this._editingSession.get(); + if (session) { + const obs = session.getDiffsForFilesInRequest(requestId); + return reader ? obs.read(reader) : obs.get(); + } + return []; + } + + /** Sums the diff stats across the given requests, or undefined when nothing changed. */ + private _statForRequests(requestIds: readonly string[], reader?: IReader): PromptDiffStat | undefined { + let added = 0; + let removed = 0; + const files = new Set<string>(); + for (const requestId of requestIds) { + for (const diff of this._diffsForRequest(requestId, reader)) { + if (diff.identical) { + continue; + } + added += diff.added; + removed += diff.removed; + files.add(diff.modifiedURI.toString()); + } + } + return files.size > 0 ? { added, removed, fileCount: files.size } : undefined; + } +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts new file mode 100644 index 00000000000000..303f433c8c4c20 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRail.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IPromptScrollLayout, PromptFileDiff, PromptTick } from './promptTimelineModel.js'; + +export interface IPromptReviewFileEvent { + readonly tick: PromptTick; + readonly file: URI; +} + +/** + * A prompt timeline rail: a presentation-only vertical strip pinned to the right + * edge of the chat transcript that renders one mark per prompt and lets the user + * preview, jump to, and review each prompt. Rendered as a proportional overview + * ruler with a viewport thumb, like the editor overview ruler. + */ +export interface IPromptTimelineRail extends IDisposable { + readonly domNode: HTMLElement; + + /** Fired when a mark is chosen (click / keyboard), with its request id. */ + readonly onDidSelect: Event<string>; + /** Fired to review all of a prompt's changes. */ + readonly onDidReview: Event<PromptTick>; + /** Fired to review a single changed file of a prompt. */ + readonly onDidReviewFile: Event<IPromptReviewFileEvent>; + + setFilesProvider(provider: (tick: PromptTick) => readonly PromptFileDiff[]): void; + setTicks(ticks: readonly PromptTick[]): void; + setActive(requestId: string | undefined): void; + focusTick(requestId: string): void; + setHostWidth(width: number): void; + + /** Supplies proportional scroll positions for the marks and viewport thumb. */ + setScrollLayout(layout: IPromptScrollLayout | undefined): void; +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts new file mode 100644 index 00000000000000..993245a80d985a --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineRulerRail.ts @@ -0,0 +1,254 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, append, clearNode, EventType, getWindow } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { PromptTimelineCard } from './promptTimelineCard.js'; +import { IPromptScrollLayout, PromptFileDiff, PromptTick } from './promptTimelineModel.js'; +import { IPromptReviewFileEvent, IPromptTimelineRail } from './promptTimelineRail.js'; +import './media/promptTimeline.css'; + +/** Minimum clickable target size (WCAG 2.5.8) for each mark's hit area. */ +const MIN_TARGET = 24; +/** Below this transcript width the rail hides so it does not crowd the content. */ +const MIN_HOST_WIDTH = 320; + +interface IMarkEntry { + tick: PromptTick; + readonly button: HTMLButtonElement; + readonly bar: HTMLElement; +} + +/** + * The overview-ruler rail. The whole session is compressed into the rail height + * like the editor's overview ruler: each prompt is a mark at its proportional + * scroll position, coloured only to signal whether it changed code, with the + * real scrollbar slider as a you-are-here thumb. Detail lives in the hover card. + */ +export class PromptTimelineRulerRail extends Disposable implements IPromptTimelineRail { + + private readonly _domNode: HTMLElement; + private readonly _marksContainer: HTMLElement; + private readonly _thumb: HTMLElement; + private readonly _card: PromptTimelineCard; + private readonly _markDisposables = this._register(new DisposableStore()); + private readonly _marks: IMarkEntry[] = []; + + private _activeRequestId: string | undefined; + private _layout: IPromptScrollLayout | undefined; + private _resizeObserverReady = false; + private _hostWidth = Number.POSITIVE_INFINITY; + + private readonly _onDidSelect = this._register(new Emitter<string>()); + readonly onDidSelect: Event<string> = this._onDidSelect.event; + + private readonly _onDidReview = this._register(new Emitter<PromptTick>()); + readonly onDidReview: Event<PromptTick> = this._onDidReview.event; + + private readonly _onDidReviewFile = this._register(new Emitter<IPromptReviewFileEvent>()); + readonly onDidReviewFile: Event<IPromptReviewFileEvent> = this._onDidReviewFile.event; + + get domNode(): HTMLElement { return this._domNode; } + + constructor() { + super(); + this._domNode = $('nav.prompt-timeline-rail.prompt-timeline-rail-ruler'); + this._domNode.setAttribute('aria-label', localize('promptTimeline.railLabel', "Prompt timeline")); + this._domNode.setAttribute('role', 'toolbar'); + this._domNode.setAttribute('aria-orientation', 'vertical'); + this._marksContainer = append(this._domNode, $('.prompt-timeline-ruler-marks')); + this._thumb = append(this._domNode, $('.prompt-timeline-ruler-thumb')); + this._thumb.classList.add('hidden'); + this._card = this._register(new PromptTimelineCard(this._domNode)); + this._register(this._card.onDidReview(tick => this._onDidReview.fire(tick))); + this._register(this._card.onDidReviewFile(e => this._onDidReviewFile.fire(e))); + + // Toolbar keyboard model: one Tab stop, Arrow/Home/End move between marks. + this._register(addDisposableListener(this._marksContainer, EventType.KEY_DOWN, e => this._onMarksKeyDown(e))); + + this._register(addDisposableListener(this._domNode, EventType.FOCUS_OUT, () => { + if (!this._domNode.contains(getWindow(this._domNode).document.activeElement)) { + this._card.scheduleHide(); + } + })); + } + + setFilesProvider(provider: (tick: PromptTick) => readonly PromptFileDiff[]): void { + this._card.setFilesProvider(provider); + } + + setTicks(ticks: readonly PromptTick[]): void { + const sameStructure = ticks.length === this._marks.length + && ticks.every((t, i) => this._marks[i]?.tick.requestId === t.requestId); + if (sameStructure) { + for (let i = 0; i < ticks.length; i++) { + this._renderMark(this._marks[i], ticks[i]); + } + this._updateActiveClasses(); + this._relayout(); + return; + } + + this._markDisposables.clear(); + this._marks.length = 0; + clearNode(this._marksContainer); + this._card.hide(); + + for (const tick of ticks) { + const button = append(this._marksContainer, $<HTMLButtonElement>('button.prompt-timeline-ruler-mark')); + button.tabIndex = -1; + const bar = append(button, $('span.prompt-timeline-ruler-bar')); + const entry: IMarkEntry = { tick, button, bar }; + this._renderMark(entry, tick); + const requestId = tick.requestId; + this._markDisposables.add(addDisposableListener(button, EventType.CLICK, () => this._onDidSelect.fire(requestId))); + this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_ENTER, () => this._showCard(entry))); + this._markDisposables.add(addDisposableListener(button, EventType.FOCUS, () => { this._showCard(entry); this._updateTabStops(this._marks.indexOf(entry)); })); + this._markDisposables.add(addDisposableListener(button, EventType.MOUSE_LEAVE, () => this._card.scheduleHide())); + this._marks.push(entry); + } + + this._ensureResizeObserver(); + // Make the active mark (else the first) the single Tab stop into the toolbar. + const activeIndex = this._marks.findIndex(m => m.tick.requestId === this._activeRequestId); + this._updateTabStops(activeIndex >= 0 ? activeIndex : 0); + this._updateActiveClasses(); + this._relayout(); + } + + /** Roving tabindex: exactly one mark is tabbable so the toolbar is a single Tab stop. */ + private _updateTabStops(focusIndex: number): void { + for (let i = 0; i < this._marks.length; i++) { + this._marks[i].button.tabIndex = i === focusIndex ? 0 : -1; + } + } + + private _onMarksKeyDown(e: KeyboardEvent): void { + if (this._marks.length === 0) { + return; + } + const event = new StandardKeyboardEvent(e); + const currentIndex = this._marks.findIndex(m => m.button === getWindow(this._domNode).document.activeElement); + let nextIndex: number; + switch (event.keyCode) { + case KeyCode.DownArrow: nextIndex = Math.min(this._marks.length - 1, currentIndex + 1); break; + case KeyCode.UpArrow: nextIndex = Math.max(0, currentIndex - 1); break; + case KeyCode.Home: nextIndex = 0; break; + case KeyCode.End: nextIndex = this._marks.length - 1; break; + default: return; + } + event.preventDefault(); + event.stopPropagation(); + this._updateTabStops(nextIndex); + this._marks[nextIndex]?.button.focus(); + } + + private _renderMark(entry: IMarkEntry, tick: PromptTick): void { + entry.tick = tick; + entry.button.setAttribute('aria-label', tick.ariaLabel); + // Two-tone bar: a green added segment and a red removed segment, sized by + // the turn's diff split. Gray when the turn made no edits. + clearNode(entry.bar); + const stat = tick.stat; + const edited = !!stat && stat.added + stat.removed > 0; + entry.bar.classList.toggle('edited', edited); + if (edited) { + // Only append the sides that exist so a pure-add turn is fully green and a + // pure-delete turn fully red; the min-width floor keeps a lopsided split visible. + if (stat!.added > 0) { + append(entry.bar, $('span.seg-add')).style.flexGrow = String(stat!.added); + } + if (stat!.removed > 0) { + append(entry.bar, $('span.seg-del')).style.flexGrow = String(stat!.removed); + } + } + } + + setActive(requestId: string | undefined): void { + this._activeRequestId = requestId; + this._updateActiveClasses(); + } + + focusTick(requestId: string): void { + this._marks.find(m => m.tick.requestId === requestId || m.tick.allRequestIds.includes(requestId))?.button.focus(); + } + + setHostWidth(width: number): void { + if (width > 0 && width !== this._hostWidth) { + this._hostWidth = width; + this._relayout(); + } + } + + setScrollLayout(layout: IPromptScrollLayout | undefined): void { + this._layout = layout; + this._relayout(); + } + + /** Places each mark at its proportional scroll position and sizes the viewport thumb. */ + private _relayout(): void { + const height = this._domNode.clientHeight; + const layout = this._layout; + const overflowing = this._hostWidth < MIN_HOST_WIDTH; + this._domNode.classList.toggle('overflowing', overflowing); + if (overflowing || height <= 0 || !layout || layout.total <= 0) { + this._thumb.classList.add('hidden'); + return; + } + const scale = height / layout.total; + const tops = new Map(layout.marks.map(m => [m.requestId, m.top])); + for (const entry of this._marks) { + const top = tops.get(entry.tick.requestId); + if (top === undefined) { + entry.button.classList.add('hidden'); + continue; + } + entry.button.classList.remove('hidden'); + // The button is a >=24px hit target centered on the mark's compressed position. + entry.button.style.top = `${top * scale - MIN_TARGET / 2}px`; + } + // The thumb reuses the scrollbar slider: its span is the visible viewport, + // approximated by the rail height (the rail overlays the transcript viewport). + this._thumb.classList.remove('hidden'); + this._thumb.style.top = `${(layout.scrollTop / layout.total) * height}px`; + this._thumb.style.height = `${Math.max(20, (height / layout.total) * height)}px`; + } + + private _updateActiveClasses(): void { + for (const entry of this._marks) { + const isActive = entry.tick.requestId === this._activeRequestId; + entry.button.classList.toggle('active', isActive); + if (isActive) { + entry.button.setAttribute('aria-current', 'location'); + } else { + entry.button.removeAttribute('aria-current'); + } + } + } + + private _showCard(entry: IMarkEntry): void { + const markRect = entry.button.getBoundingClientRect(); + const domRect = this._domNode.getBoundingClientRect(); + this._card.show(entry.tick, markRect.top - domRect.top + markRect.height / 2); + } + + private _ensureResizeObserver(): void { + if (this._resizeObserverReady) { + return; + } + const ResizeObserverCtor = getWindow(this._domNode).ResizeObserver; + if (!ResizeObserverCtor) { + return; + } + this._resizeObserverReady = true; + const observer = new ResizeObserverCtor(() => this._relayout()); + observer.observe(this._domNode); + this._register(toDisposable(() => observer.disconnect())); + } +} diff --git a/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts new file mode 100644 index 00000000000000..77cba5b5cf97b2 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/browser/promptTimelineWidgetContrib.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getWindow } from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatWidget } from '../../../../workbench/contrib/chat/browser/chat.js'; +import { IChatWidgetContrib, ChatWidget } from '../../../../workbench/contrib/chat/browser/widget/chatWidget.js'; +import { ChatAgentLocation } from '../../../../workbench/contrib/chat/common/constants.js'; +import { MIN_PROMPTS, PROMPT_TIMELINE_CONTRIB_ID, PROMPT_TIMELINE_ENABLED_SETTING } from '../common/promptTimeline.js'; +import { PromptTimelineModel, PromptEntry } from './promptTimelineModel.js'; +import { IPromptTimelineRail } from './promptTimelineRail.js'; +import { PromptTimelineRulerRail } from './promptTimelineRulerRail.js'; + +/** + * Per-widget contribution that overlays a prompt timeline rail on the chat + * transcript and exposes a navigation API for keyboard-driven commands. The rail + * exists only while `sessions.promptTimeline.enabled` is set, and is torn down + * and re-created when the enablement changes. + */ +export class PromptTimelineWidgetContrib extends Disposable implements IChatWidgetContrib { + + static readonly ID = PROMPT_TIMELINE_CONTRIB_ID; + readonly id = PromptTimelineWidgetContrib.ID; + + private _model: PromptTimelineModel | undefined; + private _rail: IPromptTimelineRail | undefined; + + /** Holds the model, rail and all their wiring while the feature is enabled. */ + private readonly _enablement = this._register(new DisposableStore()); + private _enabled = false; + + constructor( + private readonly widget: IChatWidget, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + ) { + super(); + + // The rail only makes sense for the main chat transcript location. + if (widget.location !== ChatAgentLocation.Chat) { + return; + } + + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(PROMPT_TIMELINE_ENABLED_SETTING)) { + this._updateRail(); + } + })); + this._updateRail(); + } + + /** Creates or disposes the rail to match the enablement setting. */ + private _updateRail(): void { + const enabled = this.configurationService.getValue<boolean>(PROMPT_TIMELINE_ENABLED_SETTING) !== false; + if (enabled === this._enabled) { + return; + } + this._enabled = enabled; + this._enablement.clear(); + this._model = undefined; + this._rail = undefined; + if (enabled) { + this._createRail(); + } + } + + private _createRail(): void { + // CONTRIBS always constructs contribs with the concrete widget. + const model = this._enablement.add(this.instantiationService.createInstance(PromptTimelineModel, this.widget as ChatWidget)); + const rail: IPromptTimelineRail = this._enablement.add(new PromptTimelineRulerRail()); + this._model = model; + this._rail = rail; + + this._mountRail(rail); + + rail.setFilesProvider(tick => model.getRequestFiles(tick)); + this._enablement.add(rail.onDidSelect(requestId => model.reveal(requestId))); + this._enablement.add(rail.onDidReview(tick => { void model.reviewChanges(tick); })); + this._enablement.add(rail.onDidReviewFile(e => { void model.reviewChanges(e.tick, e.file); })); + + this._enablement.add(autorun(reader => { + const ticks = model.ticks.read(reader); + // Toggle visibility before rendering so the rail's fit measurement in + // setTicks runs against the displayed (non-zero height) element. + rail.domNode.classList.toggle('hidden', ticks.length < MIN_PROMPTS); + rail.setTicks(ticks); + })); + + this._enablement.add(autorun(reader => { + rail.setActive(model.activeRequestId.read(reader)); + })); + + // Supply proportional scroll positions for the marks and viewport thumb. + this._enablement.add(autorun(reader => { + model.onDidChangeScrollLayout.read(reader); + rail.setScrollLayout(model.getScrollLayout()); + })); + } + + private _mountRail(rail: IPromptTimelineRail): void { + const railNode = rail.domNode; + const host = this.widget.domNode; + // Anchor the absolutely-positioned overlay to the chat widget via a class + // we own, removed on teardown so we never leave the foreign container mutated. + host.classList.add('prompt-timeline-host'); + this._enablement.add(toDisposable(() => host.classList.remove('prompt-timeline-host'))); + host.appendChild(railNode); + this._enablement.add(toDisposable(() => railNode.remove())); + + // Keep the rail above the input part so it only spans the transcript. + const inputPart = this.widget.inputPart; + this._enablement.add(autorun(reader => { + railNode.style.setProperty('--prompt-timeline-bottom', `${inputPart.height.read(reader)}px`); + })); + + // Report the host width so the rail can hide on very narrow transcripts. + const ResizeObserverCtor = getWindow(host).ResizeObserver; + if (ResizeObserverCtor) { + const observer = new ResizeObserverCtor(() => rail.setHostWidth(host.clientWidth)); + observer.observe(host); + this._enablement.add(toDisposable(() => observer.disconnect())); + } + rail.setHostWidth(host.clientWidth); + } + + // -- Navigation API (used by promptTimelineActions) -- + + /** All user prompts for the picker (every prompt, not just the bucketed ticks). */ + getAllPrompts(): readonly PromptEntry[] { + return this._model?.getAllPrompts() ?? []; + } + + reveal(requestId: string): void { + this._model?.reveal(requestId); + this._rail?.focusTick(requestId); + } +} diff --git a/src/vs/sessions/contrib/promptTimeline/common/promptTimeline.ts b/src/vs/sessions/contrib/promptTimeline/common/promptTimeline.ts new file mode 100644 index 00000000000000..c7bbcfb4012961 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/common/promptTimeline.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. + *--------------------------------------------------------------------------------------------*/ + +/** Id of the per-widget contribution that owns the prompt timeline rail. */ +export const PROMPT_TIMELINE_CONTRIB_ID = 'sessions.promptTimeline'; + +/** Setting that controls whether the prompt timeline rail is shown in the Agents window. */ +export const PROMPT_TIMELINE_ENABLED_SETTING = 'sessions.promptTimeline.enabled'; + +/** Minimum number of user prompts before the rail is shown. */ +export const MIN_PROMPTS = 2; + +export const enum PromptTimelineCommandId { + GoToPrompt = 'sessions.promptTimeline.goToPrompt', +} diff --git a/src/vs/sessions/contrib/promptTimeline/test/browser/promptBucketing.test.ts b/src/vs/sessions/contrib/promptTimeline/test/browser/promptBucketing.test.ts new file mode 100644 index 00000000000000..2ec5930f5716f9 --- /dev/null +++ b/src/vs/sessions/contrib/promptTimeline/test/browser/promptBucketing.test.ts @@ -0,0 +1,140 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { _testing, budgetBucketPrompts, MAX_TICKS, type PromptItem } from '../../browser/promptBucketing.js'; + +suite('PromptBucketing', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const now = new Date(2026, 6, 1, 12, 0, 0).getTime(); + + function prompt(requestId: string, date: Date): PromptItem { + return { + requestId, + text: `Prompt ${requestId}`, + timestamp: date.getTime() + }; + } + + test('returns no buckets for empty input', () => { + assert.deepStrictEqual(budgetBucketPrompts([], now), []); + }); + + test('keeps a single prompt as one tick', () => { + const item = prompt('p1', new Date(2026, 6, 1, 11, 30)); + + assert.deepStrictEqual(budgetBucketPrompts([item], now), [{ + prompt: item, + prompts: [item], + count: 1 + }]); + }); + + test('keeps today prompts at per-prompt granularity', () => { + const prompts = [ + prompt('p1', new Date(2026, 6, 1, 9)), + prompt('p2', new Date(2026, 6, 1, 9, 30)), + prompt('p3', new Date(2026, 6, 1, 10)) + ]; + + assert.deepStrictEqual(_testing.bucketPrompts(prompts, new Date(now)).map(bucket => ({ + requestId: bucket.prompt.requestId, + ids: bucket.prompts.map(prompt => prompt.requestId), + count: bucket.count + })), [ + { requestId: 'p1', ids: ['p1'], count: 1 }, + { requestId: 'p2', ids: ['p2'], count: 1 }, + { requestId: 'p3', ids: ['p3'], count: 1 } + ]); + }); + + test('groups older consecutive prompts by day and month', () => { + const prompts = [ + prompt('day-1', new Date(2026, 5, 20, 9)), + prompt('day-2', new Date(2026, 5, 20, 18)), + prompt('day-3', new Date(2026, 5, 19, 9)), + prompt('month-1', new Date(2026, 4, 8, 9)), + prompt('month-2', new Date(2026, 4, 25, 9)) + ]; + + assert.deepStrictEqual(_testing.bucketPrompts(prompts, new Date(now)).map(bucket => ({ + requestId: bucket.prompt.requestId, + ids: bucket.prompts.map(prompt => prompt.requestId), + count: bucket.count + })), [ + { requestId: 'day-1', ids: ['day-1', 'day-2'], count: 2 }, + { requestId: 'day-3', ids: ['day-3'], count: 1 }, + { requestId: 'month-1', ids: ['month-1', 'month-2'], count: 2 } + ]); + }); + + test('caps with uniform sampling while keeping first and last', () => { + const buckets = Array.from({ length: 10 }, (_, index) => { + const item = prompt(`p${index}`, new Date(2026, 6, 1, index)); + return { prompt: item, prompts: [item], count: 1 }; + }); + + assert.deepStrictEqual(_testing.uniformSample(buckets, 5).map(bucket => bucket.prompt.requestId), [ + 'p0', + 'p2', + 'p5', + 'p7', + 'p9' + ]); + }); + + test('expands the most recent coarse bucket when under budget', () => { + const prompts = [ + prompt('older-1', new Date(2026, 5, 20, 9)), + prompt('older-2', new Date(2026, 5, 20, 10)), + prompt('recent-1', new Date(2026, 5, 30, 9)), + prompt('recent-2', new Date(2026, 5, 30, 10)), + prompt('recent-3', new Date(2026, 5, 30, 11)) + ]; + const buckets = _testing.bucketPrompts(prompts, new Date(now)); + + assert.deepStrictEqual(_testing.expandRecentBuckets(buckets, 4).map(bucket => ({ + requestId: bucket.prompt.requestId, + ids: bucket.prompts.map(prompt => prompt.requestId), + count: bucket.count + })), [ + { requestId: 'older-1', ids: ['older-1', 'older-2'], count: 2 }, + { requestId: 'recent-1', ids: ['recent-1'], count: 1 }, + { requestId: 'recent-2', ids: ['recent-2'], count: 1 }, + { requestId: 'recent-3', ids: ['recent-3'], count: 1 } + ]); + }); + + test('never returns more than MAX_TICKS', () => { + const prompts = Array.from({ length: 80 }, (_, index) => prompt(`p${index}`, new Date(2026, 6, 1, 0, index))); + + assert.deepStrictEqual({ + length: budgetBucketPrompts(prompts, now).length, + first: budgetBucketPrompts(prompts, now)[0].prompt.requestId, + last: budgetBucketPrompts(prompts, now)[MAX_TICKS - 1].prompt.requestId + }, { + length: MAX_TICKS, + first: 'p0', + last: 'p79' + }); + }); + + test('honors an explicit maxTicks cap smaller than MAX_TICKS', () => { + const prompts = Array.from({ length: 40 }, (_, index) => prompt(`p${index}`, new Date(2026, 6, 1, 0, index))); + const result = budgetBucketPrompts(prompts, now, 6); + + assert.deepStrictEqual({ + length: result.length, + first: result[0].prompt.requestId, + last: result[result.length - 1].prompt.requestId + }, { + length: 6, + first: 'p0', + last: 'p39' + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 7ddd94631dfe86..3a0016e31822b0 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -26,6 +26,7 @@ Agent host providers implement `IAgentHostSessionsProvider` (defined in sessions Registered by `LocalAgentHostContribution` in `browser/localAgentHost.contribution.ts`: - **Gated on `chat.agentHost.enabled`** (`AgentHostEnabledSettingId`). If the setting is off the contribution returns early and registers nothing. +- The enablement bit is read once through the sessions-layer `AgentHostEnablementService`; the contribution does not subscribe to config changes. - Creates `LocalAgentHostSessionsProvider` via `IInstantiationService` and registers it through `ISessionsProvidersService.registerProvider`. - Registers a per-session-type **working-directory resolver** (`IAgentHostSessionWorkingDirectoryResolver`) for each `agent-host-${sessionType.id}` scheme, refreshed on `onDidChangeSessionTypes`. - The same module also wires the heavy lifting from the workbench chat layer at `WorkbenchPhase.AfterRestored`: @@ -46,12 +47,15 @@ The Electron-only `electron-browser/agentHost.contribution.ts` adds desktop-only | `label` | `"Local Agent Host"` | | `icon` | `Codicon.vm` | | `supportsLocalWorkspaces` | `true` | +| `supportsQuickChats` | snapshots agent-host enablement at construction — `true` when `chat.agentHost.enabled` was on then, else `false` | | `browseActions` | `[]` (local folders are browsed through the shared workspace picker) | | `order` | `-1` when `chat.agentHost.defaultSessionsProvider` is enabled (sorts before all other providers), else `1` | | `sessionTypes` | Dynamically populated from the local agent host's `rootState.agents`; the type label is the agent's unadorned `displayName` (e.g. `"Copilot"`), the type **id** is the agent provider name (e.g. `copilotcli`) so the same agent shares one session type across local and remote hosts | When the default-provider setting flips, the provider re-fires `onDidChangeSessionTypes` so the management service re-collects and re-sorts session types with the new `order`. +These session-type icons are specific to the Agents window provider. In the editor window, `agentSessions.ts` maps local Agent Host Copilot to the Local harness's `Codicon.vm` picker icon, while `agentSessionsViewer.ts` uses the same session-list status dot as the Local harness. + ## IDs and URI Schemes A single agent host session uses several distinct identifiers: @@ -75,6 +79,19 @@ A single agent host session uses several distinct identifiers: - **`NewSession`** is a disposable draft (pre-creation) session. Several can be in flight simultaneously; the management layer tears down superseded drafts via `deleteNewSession`. A draft eagerly creates its backend session once authentication settles, then **graduates** into a committed `AgentHostSessionAdapter` on first send. - The base provider is abstract; concrete providers supply: `connection`, `authenticationPending`, `resourceSchemeForProvider`, `_formatSessionTypeLabel`, `_adapterOptions` (workspace builder), `resolveWorkspace`, and optionally `_diffUriMapper`. +`notify/sessionAdded` is an authoritative upsert rather than create-only. An active provisional session can already have entered `_sessionCache` through `listSessions()` with its original checkout; when materialization publishes the final project and worktree working directory, the provider updates that adapter in place and reports it as changed. + +### Startup session caching (persistence) + +To avoid an empty list on window startup — before the agent host has started, authentication has settled, and the first `listSessions()` round-trip returns — the base provider persists a lightweight snapshot of each session summary to `IStorageService` and re-hydrates it on the next launch. This machinery lives in `BaseAgentHostSessionsProvider` and is **shared by both the local and remote providers**: + +- A subclass opts in by calling `_enableSessionCachePersistence(storageKey)` at the end of its constructor (once the identity fields that `createAdapter` depends on are set). This hydrates persisted summaries into `_sessionCache` immediately, so `getSessions()` returns cached sessions before any live list. +- `createAdapter`/`updateAdapter` capture the source `IAgentSessionMetadata` in `_metaByRawId`; `onWillSaveState` lazily serializes the cache (overlaying mutable fields — title, `updatedAt`, `isRead`, `isArchived` — read from each adapter's observables), capped at the 100 most-recently-modified entries under `StorageScope.APPLICATION`. +- Hydrated entries are reconciled against the authoritative `listSessions()` on the first successful `_refreshSessions()`: stale sessions that no longer exist are pruned. +- `_shouldTrackSessionCacheChanges()` is a hook (default `true`) the remote provider overrides to suspend dirty-tracking while its sessions are unpublished (offline), so the on-disk snapshot survives an unreachable host. + +The **only** per-provider difference is the storage key: local uses the fixed `localAgentHost.cachedSessions` (single machine-wide host); remote uses `remoteAgentHost.cachedSessions.${authority}` (one key per connection). + ## How Chat Content Loads & Sends (no `IChatSessionItemController`) A common point of confusion is whether the Agents window needs to register an @@ -119,6 +136,14 @@ sidebar list. 2. Constructs a `NewSession` draft, stores it in `_newSessions`, and fires `onDidChangeSessionConfig`. New-session model/mode selection is seeded by the existing model/agent pickers and sent on the first message. 3. If a connection exists and authentication is **not** pending, eagerly starts the backend session and resolves its dynamic config in parallel. While auth is pending the draft waits; `_resumeNewSessionAfterAuthenticationSettles` (driven by the `authenticationPending` observable going false) starts the backend for all pending drafts. +The eager session-state subscription does not compute Git metadata while the host session lifecycle is `Creating`: its initial working directory is the selected checkout, not the final isolated worktree. Materialization publishes the resolved working directory through `notify/sessionAdded` and starts the first Git-state refresh against that path; the later `session/metaChanged` / `notify/sessionSummaryChanged` updates rebuild the adapter workspace with the resolved branch. + +`createQuickChat(sessionTypeId)` is the **workspace-less** counterpart of `createNewSession` (declared via `supportsQuickChats`). It reuses the same `ISessionType` as a normal session — a quick chat is "identical minus exclusions", not a separate stack — but skips `resolveWorkspace` and builds the `NewSession` draft with `workspace === undefined` and `quickChat === true`. Both paths funnel through the shared `_createDraftSession` helper, so tracking, eager backend creation, and config resolution are otherwise identical. The draft's `session.workspace` resolves to `undefined`, and its eager `connection.createSession` call simply **omits `workingDirectory`** — there is no explicit quick-chat input flag on the wire. The agent host **infers workspace-less at create from the absent `workingDirectory`**, tags the session (`_meta.workspaceless` + persisted `copilot.workspaceless` metadata) and runs it in a stable per-session scratch cwd, with a **repo-less system prompt** (`COPILOT_AGENT_HOST_QUICK_CHAT_INSTRUCTIONS` appended) that tells the agent its cwd is a throwaway scratch directory, to stay read-only on real repos, and to delegate code changes to a dedicated session. The workspace-trust gate in `_startNewSessionBackend` is naturally skipped because a workspace-less draft has no folder to trust. Forks are **excluded** from this inference: `isWorkspaceless = !sessionConfig.fork && !sessionConfig.workingDirectory`, so a fork without an explicit `workingDirectory` inherits the source session's context rather than being tagged workspace-less. + +**Restore (persistence).** Quick chats survive reloads via the normal catalog round-trip: `listSessions()` re-advertises them with the `_meta.workspaceless` tag (carried on the session summary) — but also with the throwaway scratch cwd the host assigned. `AgentHostSessionAdapter` resolves its **session-kind once, at construction**, from `readSessionWorkspaceless(metadata._meta)` (`QuickChatSessionKind` vs `WorkspaceSessionKind`); `_computeWorkspace()` delegates to that kind, so a quick chat returns `undefined` regardless of the scratch working directory, and `ISession.isQuickChat` is a `constObservable` of the kind. Because the kind is fixed at construction and **cannot be flipped by a later `update`/`setMeta`**, the `_meta.workspaceless` tag MUST be present in the metadata passed to **every** adapter-construction path — `_refreshSessions()`/`listSessions` **and** the live `_handleSessionAdded(summary)` notification (which carries `summary._meta`). Dropping `_meta` on either path locks the committed session into `WorkspaceSessionKind` and leaks its scratch dir as a workspace (e.g. the archive-on-delete fallback then pre-fills a new session with that folder). On the host side, `CopilotAgent.listSessions()` re-emits `_meta.workspaceless` from the persisted `copilot.workspaceless` metadata (mirroring `getSessionMetadata`) so restored sessions carry the tag even after the state manager's live summary is gone. `restoreVisibleSessions` itself is workspace-agnostic — it resolves persisted slots by `sessionResource`, so a quick chat re-hydrates like any other session once the provider re-lists it. + +A quick chat is a **single-chat session** (`supportsMultipleChats: false`, forced by the `QuickChatSessionKind`), so it has no peer chats; `applyChatCatalog` collapses any state-advertised chats to the default chat. The agents-window core consumes `ISession.isQuickChat` (via `isQuickChatSession(session)`) for list grouping and context keys, rather than inferring quick-chat from `workspace === undefined`. While the fixed-at-construction kind means a later `SessionState._meta` can no longer change the workspace, the host still guarantees the tag rides on **both** the summary `_meta` and the subscribed `SessionState._meta` (`createSessionState(summary)` copies `summary._meta` onto the restored state), keeping the two channels consistent. + `createNewChat(chatId)` creates the chat session model (`IChatSessionsService.getOrCreateChatSession`) so the management service can open the widget, and returns the draft's main chat. For a committed multi-chat session, it asks the host to add a peer chat, waits for that chat to surface in the catalog, seeds its input state, and presents it as `Untitled` until its first request is sent. ## Send Flow @@ -141,7 +166,8 @@ When restoring Copilot SDK history, `mapSessionEvents` best-effort reconstructs ## CRUD & Stubbed Operations - `archiveSession` / `unarchiveSession` / `deleteSession` — round-trip to the backend. `deleteSessions` is the batch variant (used when multiple sessions are selected): it disposes each backend session and emits a single removal change event. Sessions advertise `capabilities.supportsDelete`, so the shared sessions-list "Delete..." action (contributed by the sessions workbench, gated on `SessionSupportsDeleteContext`) confirms and invokes deletion — there is no provider-specific delete action. -- `renameChat` — updates the session title. +- `renameChat` — renames a single chat independently of the session title. For an additional peer chat it dispatches `SessionTitleChanged` on that chat's channel; for the default/main chat it dispatches on the default chat channel (`setDefaultChatTitle`). The host persists the new title under `customChatTitle:<chatUri>` and re-applies it on restore — the default chat's title is seeded back through `restoreSession`/`_ensureDefaultChat`, peer chats through `_restorePeerChats` — so an independently-renamed main/peer chat survives a process restart or idle eviction instead of reverting to the session title. +- `renameSession` — updates the session-level title. - `deleteChat` — no-op (agent host sessions don't model individually deletable chats). - `forkChat(sessionId, sourceChat, turnId)` — multi-chat only. Mints a peer chat URI and calls `connection.createChat(sessionUri, chatUri, { fork: { source, turnId } })`, where `source` is the backend chat URI (a `chatId` fragment addresses a peer chat, otherwise the session's default chat). The host seeds the new chat with the forked history; the provider waits for it to surface in `cached.chats` and returns it. Routed from the **Fork Conversation** gesture via `ISessionsManagementService.forkChatInSession`; single-chat sessions instead fork into a new session (the workbench `AgentHostSessionHandler.forkSession`). @@ -153,7 +179,7 @@ The provider ships a rich set of session-scoped UI in `browser/`: |------|----------------| | `agentHostSessionConfigPicker.ts` | The per-session config picker (isolation, branch, and host-declared dynamic properties) backed by the dynamic-session-config API; includes `media/agentHostSessionConfigPicker.css`. | | `agentHostAgentPicker.ts` | Custom-agent picker for a session. | -| `agentHostModePicker.ts` | Agent mode enum picker (extends a shared `AgentHostSessionEnumPicker`). | +| `agentHostModePicker.ts` | Agent mode enum picker (extends a shared `AgentHostSessionEnumPicker`), rendered immediately before approvals in the secondary toolbar for new and active sessions. | | `agentHostModelPicker.ts` | `getAgentHostModels` — filters language models by the session resource scheme. | | `agentHostClaudePermissionModePicker.ts` | Claude-specific permission-mode picker. | | `agentHostPermissionPickerActionItem.ts` / `agentHostPermissionPickerDelegate.ts` | Toolbar action item + delegate for the permission picker. | @@ -186,6 +212,7 @@ Two synthetic filesystem providers expose JSONC settings editors: | Resource scheme | `agent-host-${sessionType.id}` | `remote-${authority}-${agent.provider}` | | Browse actions | none | host-filesystem "Folders" picker | | Diff URIs | `toAgentHostUri(uri, 'local')` | host-scoped mapper | +| Startup session cache | Shared base persistence; fixed key `localAgentHost.cachedSessions` | Shared base persistence; key `remoteAgentHost.cachedSessions.${authority}` + `unpublishCachedSessions()` offline gate | | Extra interface members | — | `connectionStatus`, `remoteAddress`, `connect`/`disconnect` | ## Tests diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts index 55e4792e36898a..ebba79f9be5c8b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostDiffs.ts @@ -10,6 +10,7 @@ import { ISessionFileDiff } from '../../../../../platform/agentHost/common/state import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; import { IChatSessionFileChange2, isIChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { ISessionFileChange, SessionStatus } from '../../../../services/sessions/common/session.js'; +import { readChangesetFileMeta } from '../../../../../platform/agentHost/common/meta/agentChangesetFileMeta.js'; /** * Maps the protocol-layer session status bitset to the UI-layer @@ -36,8 +37,8 @@ export function mapProtocolStatus(protocol: ProtocolSessionStatus): SessionStatu * @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 diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { - const normalized = normalizeFileEdit(d); +export function diffToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { + const normalized = normalizeFileEdit(file.edit); if (!normalized) { return undefined; } @@ -55,12 +56,16 @@ export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): I // fetch the snapshot of the file *before* the session's edits. const originalUri = normalized.beforeContentUri ? map(normalized.beforeContentUri) : undefined; + // Extract reviewed status from meta + const meta = readChangesetFileMeta(file); + return { uri, modifiedUri, originalUri, - insertions: d.diff?.added ?? 0, - deletions: d.diff?.removed ?? 0, + insertions: file.edit?.diff?.added ?? 0, + deletions: file.edit?.diff?.removed ?? 0, + reviewed: meta?.reviewed } satisfies IChatSessionFileChange2; } @@ -69,7 +74,7 @@ export function diffToChange(d: ISessionFileDiff, mapUri?: (uri: URI) => URI): I * or `undefined` when the underlying diff has no usable URI. */ export function changesetFileToChange(file: ChangesetFile, mapUri?: (uri: URI) => URI): IChatSessionFileChange2 | undefined { - return diffToChange(file.edit, mapUri); + return diffToChange(file, mapUri); } /** @@ -78,8 +83,8 @@ export function changesetFileToChange(file: ChangesetFile, mapUri?: (uri: URI) = * @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 ISessionFileDiff[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { - return diffs.map(d => diffToChange(d, mapUri)).filter(isDefined); +export function diffsToChanges(files: readonly ChangesetFile[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { + return files.map(d => diffToChange(d, mapUri)).filter(isDefined); } /** @@ -93,7 +98,7 @@ export function diffsToChanges(diffs: readonly ISessionFileDiff[], mapUri?: (uri * additional information the UI needs. */ export function changesetFilesToChanges(files: readonly ChangesetFile[], mapUri?: (uri: URI) => URI): IChatSessionFileChange2[] { - return diffsToChanges(files.map(f => f.edit), mapUri); + return diffsToChanges(files, mapUri); } /** diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts index ed80227052433d..dcc6cb24a8a903 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostModePicker.ts @@ -147,6 +147,10 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { return provider.isSessionConfigResolving(session.sessionId).get(); } + showPicker(anchor: HTMLElement, onHide?: () => void): boolean { + return this._showPicker(anchor, onHide); + } + private _getActiveContext(): { provider: IAgentHostSessionsProvider; sessionId: string; currentValue: string; items: readonly IAgentHostSessionEnumPickerItem[]; tooltip: string } | undefined { const session = this._session.get(); if (!session) { @@ -217,20 +221,19 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { this._triggerElement.setAttribute('aria-disabled', isResolving ? 'true' : 'false'); } - protected _showPicker(): void { - if (!this._triggerElement || this._actionWidgetService.isVisible) { - return; + protected _showPicker(anchor = this._triggerElement, onHide?: () => void): boolean { + if (!anchor || this._actionWidgetService.isVisible) { + return false; } const ctx = this._getActiveContext(); if (!ctx) { - return; + return false; } // Defensive against stale keyboard activation on a disabled chip. if (this._isCurrentlyResolvingConfig()) { - return; + return false; } - const triggerElement = this._triggerElement; const actionItems: IActionListItem<IAgentHostSessionEnumPickerItem>[] = ctx.items.map(item => ({ kind: ActionListItemKind.Action, label: item.label, @@ -261,7 +264,10 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { ctx.provider.setSessionConfigValue(ctx.sessionId, this._property, item.value) .catch(() => { /* best-effort */ }); }, - onHide: () => triggerElement.focus(), + onHide: () => { + anchor.focus(); + onHide?.(); + }, }; this._actionWidgetService.show<IAgentHostSessionEnumPickerItem>( @@ -269,7 +275,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { false, actionItems, delegate, - this._triggerElement, + anchor, undefined, [], { @@ -277,6 +283,7 @@ export abstract class AgentHostSessionEnumPicker extends Disposable { getWidgetAriaLabel: () => this._getWidgetAriaLabel(), }, ); + return true; } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts index f03e8b9298e835..4e1fdf6e515208 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionChangesets.ts @@ -5,7 +5,7 @@ import { arrayEqualsC, structuralEquals } from '../../../../../base/common/equals.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, mapObservableArrayCached, observableFromEvent, observableValue, throttledObservable } from '../../../../../base/common/observable.js'; +import { constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, mapObservableArrayCached, observableFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../../base/common/resources.js'; import { format } from '../../../../../base/common/strings.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; @@ -17,7 +17,6 @@ import { ChangesetOperation, ChangesetOperationScope, type ChangesetFile, Change import { buildDefaultChatUri, ChangesetStatus, Changeset, StateComponents, type ChangesetState, type ChatState, type ChatSummary, type SessionState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ISessionChangeset, ISessionChangesetOperation, ISessionChangesetOperationTarget, ISessionFileChange, SessionChangesetOperationScope, SessionChangesetOperationStatus, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; -import { CHANGESET_UPDATE_THROTTLE_MS } from './agentHostChangesetConstants.js'; import { changesetFileToChange } from './agentHostDiffs.js'; import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; @@ -67,7 +66,7 @@ export function createChangesets( return sessionChangesets; } -function createActiveSessionSubscriptionObs<T>( +export function createActiveSessionSubscriptionObs<T>( options: IAgentHostAdapterOptions, isActiveSessionObs: IObservable<boolean>, component: StateComponents, @@ -97,6 +96,29 @@ function createActiveSessionSubscriptionObs<T>( }); } +/** + * Selects the URI of the session's most recently modified chat — the one that + * holds the session's "last turn". Falls back to the session's default chat (or + * the synthesized default chat URI) when the state is absent/errored or no chat + * is more recent. + * + * Shared by {@link AgentHostLastTurnChangeset} and the output-stream-derived + * last-turn changes so the "Last Turn Changes" changeset and the chat input + * status pills always resolve the same chat. + */ +export function selectMostRecentChatUri(sessionState: SessionState | Error | undefined | null, sessionUri: URI): URI { + if (!sessionState || sessionState instanceof Error) { + return URI.parse(buildDefaultChatUri(sessionUri)); + } + + // `modifiedAt` is ISO 8601, so lexicographic compare is chronological. + const mostRecentChat = sessionState.chats.reduce<ChatSummary | undefined>( + (best, c) => !best || c.modifiedAt > best.modifiedAt ? c : best, + undefined + ); + return URI.parse(mostRecentChat?.resource ?? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri)); +} + function toSessionChangesetOperationScope(scope: ChangesetOperationScope): SessionChangesetOperationScope { switch (scope) { case ChangesetOperationScope.Changeset: return SessionChangesetOperationScope.Changeset; @@ -159,18 +181,8 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { private readonly _options: IAgentHostAdapterOptions, private readonly _dialogService: IDialogService, ) { - // Throttle only the changes path; `isLoadingChanges` and `operations` - // keep reading `this.changesetStateObs` directly so spinners/buttons stay - // responsive. Throttle (not debounce) so a continuous stream keeps - // updating instead of starving until edits stop. The `derived` wrapper - // defers reading the abstract `changesetStateObs` until the observable is - // actually observed (it isn't assigned yet during base construction). - const throttledChangesetStateObs = throttledObservable( - derived(reader => this.changesetStateObs.read(reader).read(reader)), - CHANGESET_UPDATE_THROTTLE_MS); - this.isLoadingChanges = derived(reader => { - const changesetState = throttledChangesetStateObs.read(reader); + const changesetState = this.changesetStateObs.read(reader).read(reader); // If the changeset state is `undefined`, it means that the first snapshot // has not yet arrived, so in order to avoid any flickering in the Changes @@ -187,7 +199,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { // For static changesets, that are persisted to the database, the // cached state will be sent over the wire while the changeset is // being computed. - return changesetState.status === ChangesetStatus.Computing && changesetState.files.length === 0; + return changesetState.status === ChangesetStatus.Computing; }); const mapDiffUri = this._options.mapDiffUri; @@ -196,7 +208,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { // files keep their reference across reducer updates, enabling the // per-file cache below to skip rebuilding them. const changesetFilesObs = derivedObservableWithCache<readonly ChangesetFile[] | undefined>(this, (reader, lastValue) => { - const changesetState = throttledChangesetStateObs.read(reader); + const changesetState = this.changesetStateObs.read(reader).read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } @@ -231,7 +243,7 @@ abstract class AbstractAgentHostChangeset implements ISessionChangeset { }); const operationsObs = derivedObservableWithCache<readonly ISessionChangesetOperation[]>(this, (reader, lastValue) => { - const changesetState = throttledChangesetStateObs.read(reader); + const changesetState = this.changesetStateObs.read(reader).read(reader); if (changesetState === null || changesetState instanceof Error) { return []; } @@ -327,11 +339,6 @@ class AgentHostChangeset extends AbstractAgentHostChangeset { this.isDefault = constObservable(changesetSummary.isDefault); } - - update(changesetSummary: Changeset): void { - this._label = changesetSummary.label; - this._description = changesetSummary.description; - } } class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { @@ -359,7 +366,8 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { // Turns moved off the session and onto a per-chat channel with the // multi-chat protocol. Subscribe to the session to discover its // chats, then track the chat that was modified most recently — its - // last completed turn is the session's "last turn". + // in-progress turn (or, when idle, its last completed turn) is the + // session's "last turn". const sessionStateObs = createActiveSessionSubscriptionObs<SessionState>( options, isActiveSessionObs, @@ -369,16 +377,7 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { const mostRecentChatUriObs = derivedOpts({ equalsFn: isEqual }, reader => { const sessionState = sessionStateObs.read(reader).read(reader); - if (!sessionState || sessionState instanceof Error) { - return URI.parse(buildDefaultChatUri(sessionUri)); - } - - // `modifiedAt` is ISO 8601, so lexicographic compare is chronological. - const mostRecentChat = sessionState.chats.reduce<ChatSummary | undefined>( - (best, c) => !best || c.modifiedAt > best.modifiedAt ? c : best, - undefined - ); - return URI.parse(mostRecentChat?.resource ?? sessionState.defaultChat ?? buildDefaultChatUri(sessionUri)); + return selectMostRecentChatUri(sessionState, sessionUri); }); const chatStateObs = createActiveSessionSubscriptionObs<ChatState>( @@ -393,7 +392,10 @@ class AgentHostLastTurnChangeset extends AbstractAgentHostChangeset { if (!chatState || chatState instanceof Error) { return undefined; } - return chatState.turns?.at(-1)?.id; + // Prefer the in-progress turn so the "last turn" reflects streaming + // edits live; once it completes it moves into `turns` under the same + // id, so the tracked changeset transitions seamlessly. + return chatState.activeTurn?.id ?? chatState.turns?.at(-1)?.id; }); // Last turn changes diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts index 18f834d1455f86..be373c957f5e60 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionConfigPicker.ts @@ -21,7 +21,7 @@ import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../.. import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; -import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; @@ -33,12 +33,13 @@ import { markOnboardingTarget } from '../../../../../workbench/contrib/onboardin import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { type IChatInputPickerOptions } from '../../../../../workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.js'; import { Menus } from '../../../../browser/menus.js'; -import { SessionProviderIdContext, IsPhoneLayoutContext } from '../../../../common/contextkeys.js'; +import { SessionProviderIdContext, IsPhoneLayoutContext, IsQuickChatSessionContext } from '../../../../common/contextkeys.js'; import { IWorkbenchLayoutService } from '../../../../../workbench/services/layout/browser/layoutService.js'; import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import type { ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { type IAgentHostSessionsProvider, isAgentHostProvider, LOCAL_AGENT_HOST_PROVIDER_ID, REMOTE_AGENT_HOST_PROVIDER_RE } from '../../../../common/agentHostSessionsProvider.js'; import { PermissionPicker } from '../../copilotChatSessions/browser/permissionPicker.js'; @@ -56,6 +57,18 @@ import { ClaudeSessionConfigKey } from '../../../../../platform/agentHost/common const IsActiveSessionRemoteAgentHost = ContextKeyExpr.regex(SessionProviderIdContext.key, REMOTE_AGENT_HOST_PROVIDER_RE); const IsActiveSessionLocalAgentHost = ContextKeyExpr.equals(SessionProviderIdContext.key, LOCAL_AGENT_HOST_PROVIDER_ID); +function showActiveSessionModePicker(accessor: ServicesAccessor): void { + const activeElement = dom.getActiveElement(); + const anchor = dom.isHTMLElement(activeElement) ? activeElement : dom.getActiveDocument().body; + const picker = accessor.get(IInstantiationService).createInstance( + isPhoneLayout(accessor.get(IWorkbenchLayoutService)) ? MobileAgentHostModePicker : AgentHostModePicker, + accessor.get(ISessionsService).activeSession, + ); + if (!picker.showPicker(anchor, () => picker.dispose())) { + picker.dispose(); + } +} + registerAction2(class extends Action2 { constructor() { super({ @@ -66,7 +79,10 @@ registerAction2(class extends Action2 { id: Menus.NewSessionRepositoryConfig, group: 'navigation', order: 3, - when: ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + when: ContextKeyExpr.and( + ContextKeyExpr.or(IsActiveSessionLocalAgentHost, IsActiveSessionRemoteAgentHost), + IsQuickChatSessionContext.negate(), + ), }], }); } @@ -289,7 +305,7 @@ export class AgentHostSessionConfigPicker extends Disposable { } // When the mode property uses the well-known schema, the dedicated // {@link AgentHostModePicker} (registered separately for - // `Menus.NewSessionConfig`) handles it. Non-conforming schemas + // `Menus.NewSessionControl`) handles it. Non-conforming schemas // still fall through to the generic per-property picker below. if (property === SessionConfigKey.Mode && isWellKnownModeSchema(schema)) { continue; @@ -760,7 +776,7 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo }, )); this._register(actionViewItemService.register( - Menus.NewSessionConfig, + Menus.NewSessionControl, NEW_SESSION_MODE_PICKER_ID, (_action, _options, scopedInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); @@ -771,7 +787,7 @@ class AgentHostSessionConfigPickerContribution extends Disposable implements IWo }, )); this._register(actionViewItemService.register( - MenuId.ChatInput, + MenuId.ChatInputSecondary, RUNNING_SESSION_MODE_PICKER_ID, (_action, _options, scopedInstantiationService) => { const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); @@ -888,7 +904,7 @@ registerAction2(class extends Action2 { override async run(): Promise<void> { } }); -// ---- New session mode picker (NewSessionConfig) ---- +// ---- New session mode picker (NewSessionControl) ---- const NEW_SESSION_MODE_PICKER_ID = 'sessions.agentHost.newSessionModePicker'; @@ -899,7 +915,7 @@ registerAction2(class extends Action2 { title: localize2('agentHostNewSessionModePicker', "Agent Mode"), f1: false, menu: [{ - id: Menus.NewSessionConfig, + id: Menus.NewSessionControl, group: 'navigation', order: 0, // On phone the {@link MobileChatInputConfigPicker} replaces @@ -960,7 +976,7 @@ registerAction2(class extends Action2 { }); -// ---- Running session mode picker (ChatInput, beside the model picker) ---- +// ---- Running session mode picker (ChatInputSecondary, before approvals) ---- const RUNNING_SESSION_MODE_PICKER_ID = 'sessions.agentHost.runningSessionModePicker'; @@ -971,19 +987,18 @@ registerAction2(class extends Action2 { title: localize2('agentHostRunningSessionModePicker', "Agent Mode"), f1: false, menu: [{ - id: MenuId.ChatInput, + id: MenuId.ChatInputSecondary, group: 'navigation', - // `OpenModelPickerAction` (the "Auto" model picker) is at order 3 - // in the same menu — sit just before it so the mode pill renders - // to the left of the model picker. - order: 2, + order: 9, // Hide the agent mode picker while a delegation (continue in) target is pending. when: ContextKeyExpr.and(ChatContextKeyExprs.isAgentHostSession, ChatContextKeys.hasPendingDelegationTarget.negate()), }], }); } - override async run(): Promise<void> { } + override async run(accessor: ServicesAccessor): Promise<void> { + showActiveSessionModePicker(accessor); + } }); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts new file mode 100644 index 00000000000000..cc782584e75cfd --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/agentHostSessionFiles.ts @@ -0,0 +1,585 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable, derivedOpts, IObservable, mapObservableArrayCached } from '../../../../../base/common/observable.js'; +import { compare as strCompare } from '../../../../../base/common/strings.js'; +import { getComparisonKey, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { normalizeFileEdit } from '../../../../../platform/agentHost/common/fileEditDiff.js'; +import type { FileEdit } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { + buildDefaultChatUri, + type ChatState, + FileEditKind, + ResponsePartKind, + type SessionState, + StateComponents, + type Turn, + type ToolCallState, + ToolCallStatus, + ToolResultContentType, +} from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionFile, ISessionFileChange, ISessionWorkspace, SessionFileOperation, sessionFileChangesEqual } from '../../../../services/sessions/common/session.js'; +import { createActiveSessionSubscriptionObs } from './agentHostSessionChangesets.js'; +import { IAgentHostAdapterOptions } from './baseAgentHostSessionsProvider.js'; + +/** + * A single file edit emitted by a tool call, decoded from the protocol so the + * reducer can classify it. Ordered so creations seen before edits keep the + * "created" classification. + */ +export interface IParsedFileEdit { + readonly kind: FileEditKind; + /** After-state URI (create/edit/rename target). */ + readonly afterUri?: URI; + /** Before-state URI (delete source / rename origin). */ + readonly beforeUri?: URI; + /** Before-content URI, used to render a diff for modified files. */ + readonly beforeContentUri?: URI; + /** Lines added by this edit, from the protocol diff metadata (0 when absent). */ + readonly insertions: number; + /** Lines removed by this edit, from the protocol diff metadata (0 when absent). */ + readonly deletions: number; +} + +/** + * The observable outputs derived from an agent-host session's live output + * stream (its chat-state turns). Both are parsed from the same underlying + * per-chat subscriptions so the stream is only walked once. + */ +export interface ISessionOutputObs { + /** + * Files created, edited or deleted **outside** the session workspace folders + * during the session (e.g. config files in the user's home directory), + * reduced across every chat and turn. + */ + readonly externalFiles: IObservable<readonly ISessionFile[]>; + /** + * Returns the file changes produced by a specific chat's **last turn** only, + * keyed by that chat's AHP chat URI (the default chat's + * {@link buildDefaultChatUri}, or a peer chat's protocol resource). Reduces + * that chat's last-turn edits into per-file {@link ISessionFileChange | + * changes} (with diff stats), mirroring the "Last Turn Changes" changeset + * without depending on it. Used by the chat input status pills to reflect + * just what the chat's most recent request produced. + */ + getLastTurnChanges(chatUri: URI): IObservable<readonly ISessionFileChange[]>; +} + +/** + * Builds the observable outputs derived from a session's live output stream. + * + * The data is parsed from the agent-host chat-state turns: each turn's response + * parts are scanned for tool calls, and each tool call's file-edit results (and + * pending edits) are collected. Two views are produced from the same parse: + * + * - {@link ISessionOutputObs.externalFiles}: edits reduced per file across all + * chats/turns so that a file first created and then edited is reported as + * {@link SessionFileOperation.Created} while a deleted file is removed; only + * files outside the workspace folders are kept. + * - {@link ISessionOutputObs.getLastTurnChanges}: given a chat's AHP URI, that + * chat's last turn's edits reduced per file into {@link ISessionFileChange | + * changes} (with diff stats), mirroring the "Last Turn Changes" changeset + * without depending on it. + * + * Computation only happens for the active, non-archived session: archived + * sessions never open a live chat-state subscription, so no parsing work is + * done for them. + */ +export function createSessionOutputObs( + sessionUri: URI, + options: IAgentHostAdapterOptions, + isActiveSessionObs: IObservable<boolean>, + isArchivedObs: IObservable<boolean>, + workspaceObs: IObservable<ISessionWorkspace | undefined>, +): ISessionOutputObs { + const mapDiffUri = options.mapDiffUri; + + // Session output is only computed for the active, non-archived session. The + // subscriptions and parsing below are all gated on this so an archived + // session does no work. + const enabledObs = derivedOpts<boolean>({ equalsFn: (a, b) => a === b }, reader => + isActiveSessionObs.read(reader) && !isArchivedObs.read(reader)); + + // Subscribe to the session to discover its chats. + const sessionStateObs = createActiveSessionSubscriptionObs<SessionState>( + options, + enabledObs, + StateComponents.Session, + constObservable(sessionUri), + ); + + // All chat URIs in the session (default chat + any peer chats). File edits + // can be produced by any chat, so we union edits across all of them. + const chatUrisObs = derivedOpts<readonly URI[]>({ equalsFn: (a, b) => a.length === b.length && a.every((u, i) => isEqual(u, b[i])) }, reader => { + if (!enabledObs.read(reader)) { + return []; + } + const sessionState = sessionStateObs.read(reader).read(reader); + const defaultChatUri = URI.parse(buildDefaultChatUri(sessionUri)); + if (!sessionState || sessionState instanceof Error) { + return [defaultChatUri]; + } + + const uris = new Map<string, URI>(); + uris.set(defaultChatUri.toString(), defaultChatUri); + for (const chat of sessionState.chats) { + const uri = URI.parse(chat.resource); + uris.set(uri.toString(), uri); + } + return [...uris.values()]; + }); + + // One observable of parsed edits per chat, subscribing to that chat's state. + // + // Completed turns (`chatState.turns`) are immutable once finalized, so each + // is parsed exactly once and memoized by turn id in a closure-scoped cache + // that lives for the chat's lifetime. Only the in-progress `activeTurn` is + // re-parsed on every streamed delta, making delta updates O(active turn) + // rather than O(all turns). The `equalsFn` ensures the downstream reducers + // only re-run when the parsed edits actually change (e.g. not for markdown + // or reasoning deltas that carry no file edits). + const editsPerChatObs = mapObservableArrayCached(undefined, chatUrisObs, (chatUri) => { + const chatStateObs = createActiveSessionSubscriptionObs<ChatState>( + options, + enabledObs, + StateComponents.Chat, + constObservable(chatUri), + ); + const parse = createIncrementalChatFileEditsParser(mapDiffUri); + return derivedOpts<IChatFileEdits & { readonly chatUri: URI }>({ equalsFn: (a, b) => isEqual(a.chatUri, b.chatUri) && chatFileEditsEqual(a, b) }, reader => { + const chatState = chatStateObs.read(reader).read(reader); + if (!chatState || chatState instanceof Error) { + return { chatUri, allEdits: [], lastTurnEdits: [] }; + } + return { chatUri, ...parse(chatState) }; + }); + }, chatUri => chatUri.toString()); + + const externalFiles = derivedOpts<readonly ISessionFile[]>({ equalsFn: sessionFilesEqual }, reader => { + const workspace = workspaceObs.read(reader); + const folderRoots = (workspace?.folders ?? []).map(f => f.workingDirectory); + + const allEdits: IParsedFileEdit[] = []; + for (const chatEditsObs of editsPerChatObs.read(reader)) { + allEdits.push(...chatEditsObs.read(reader).allEdits); + } + + return reduceSessionFiles(allEdits, folderRoots); + }); + + const getLastTurnChanges = (chatUri: URI): IObservable<readonly ISessionFileChange[]> => + derivedOpts<readonly ISessionFileChange[]>({ equalsFn: sessionFileChangesEqual }, reader => { + for (const chatEditsObs of editsPerChatObs.read(reader)) { + const chatEdits = chatEditsObs.read(reader); + if (isEqual(chatEdits.chatUri, chatUri)) { + return reduceTurnChanges(chatEdits.lastTurnEdits); + } + } + return []; + }); + + return { externalFiles, getLastTurnChanges }; +} + +/** + * Minimal shape of a turn needed to parse its file edits. {@link Turn} is + * structurally assignable to this, so production passes a real `ChatState` + * while tests can build lightweight fixtures. + */ +export interface IFileEditTurn { + readonly id: string; + readonly responseParts: Turn['responseParts']; +} + +/** A chat state reduced to just the fields needed to parse its file edits. */ +export interface IFileEditChatState { + readonly turns?: readonly IFileEditTurn[]; + readonly activeTurn?: { readonly responseParts: Turn['responseParts'] }; +} + +/** Parses the file edits contained in a single turn's response parts. */ +export type ParseTurnFileEdits = (responseParts: Turn['responseParts']) => readonly IParsedFileEdit[]; + +/** + * The file edits parsed from a chat's output stream, split into the full set + * (across all turns) and the last turn's edits alone. + */ +export interface IChatFileEdits { + /** All file edits across the chat's turns, in stream order. */ + readonly allEdits: readonly IParsedFileEdit[]; + /** + * File edits of the chat's last turn only — the in-progress `activeTurn` when + * present, otherwise the most recently completed turn. + */ + readonly lastTurnEdits: readonly IParsedFileEdit[]; +} + +/** + * Creates a stateful parser that turns a chat state into its file edits, + * **parsing each completed turn at most once**. + * + * Completed turns (`chatState.turns`) are immutable once finalized, so each is + * parsed once and memoized by turn id in the returned closure. Only the + * in-progress `activeTurn` is re-parsed on every call, making streamed-delta + * updates O(active turn) rather than O(all turns). + * + * Returns both the full edit list (for session-wide reductions) and the last + * turn's edits alone (for turn-scoped reductions); the active turn is parsed + * once and reused for both. + * + * @param mapDiffUri Optional URI mapper applied while parsing. + * @param parseTurn Per-turn parse function. Defaults to {@link parseResponseParts}; + * injectable so tests can observe how often each turn is (re)parsed. + */ +export function createIncrementalChatFileEditsParser( + mapDiffUri?: (uri: URI) => URI, + parseTurn: ParseTurnFileEdits = responseParts => parseResponseParts(responseParts, mapDiffUri), +): (chatState: IFileEditChatState) => IChatFileEdits { + const completedTurnCache = new Map<string, readonly IParsedFileEdit[]>(); + + return (chatState: IFileEditChatState): IChatFileEdits => { + const allEdits: IParsedFileEdit[] = []; + const turns: readonly IFileEditTurn[] = chatState.turns ?? []; + + // Evict cache entries for turns that are no longer completed (e.g. a turn + // that moved back to `activeTurn`, or a discarded turn) so the cache can't + // grow unbounded or return stale data. + const completedIds = new Set(turns.map(t => t.id)); + for (const id of completedTurnCache.keys()) { + if (!completedIds.has(id)) { + completedTurnCache.delete(id); + } + } + + for (const turn of turns) { + let parsed = completedTurnCache.get(turn.id); + if (!parsed) { + parsed = parseTurn(turn.responseParts); + completedTurnCache.set(turn.id, parsed); + } + if (parsed.length > 0) { + allEdits.push(...parsed); + } + } + + // The last turn is the in-progress one when streaming, else the most + // recently completed turn. The active turn is parsed a single time and + // reused for both `allEdits` and `lastTurnEdits`. + let lastTurnEdits: readonly IParsedFileEdit[]; + if (chatState.activeTurn) { + lastTurnEdits = parseTurn(chatState.activeTurn.responseParts); + allEdits.push(...lastTurnEdits); + } else if (turns.length > 0) { + lastTurnEdits = completedTurnCache.get(turns[turns.length - 1].id) ?? []; + } else { + lastTurnEdits = []; + } + + return { allEdits, lastTurnEdits }; + }; +} + +/** Parses the file edits contained in a turn's response parts (stateless). */ +export function parseResponseParts(responseParts: Turn['responseParts'], mapDiffUri?: (uri: URI) => URI): IParsedFileEdit[] { + const out: IParsedFileEdit[] = []; + for (const part of responseParts) { + if (part.kind !== ResponsePartKind.ToolCall) { + continue; + } + for (const fileEdit of getToolCallFileEdits(part.toolCall)) { + const parsed = parseFileEdit(fileEdit, mapDiffUri); + if (parsed) { + out.push(parsed); + } + } + } + return out; +} + +/** + * Extracts the {@link FileEdit | file edits} from a tool call regardless of its + * lifecycle state: completed/running results carry them in `content`, while a + * tool call awaiting confirmation carries the planned edits in `edits.items`. + */ +function getToolCallFileEdits(toolCall: ToolCallState): FileEdit[] { + const edits: FileEdit[] = []; + + // Completed/running results carry file edits in `content`... + if (toolCall.status === ToolCallStatus.Running + || toolCall.status === ToolCallStatus.Completed + || toolCall.status === ToolCallStatus.PendingResultConfirmation) { + for (const c of toolCall.content ?? []) { + if (c.type === ToolResultContentType.FileEdit) { + edits.push(c); + } + } + } else if (toolCall.status === ToolCallStatus.PendingConfirmation) { + // ...while a tool call awaiting confirmation carries the planned edits. + edits.push(...(toolCall.edits?.items ?? [])); + } + + return edits; +} + +function parseFileEdit(fileEdit: FileEdit, mapDiffUri?: (uri: URI) => URI): IParsedFileEdit | undefined { + const normalized = normalizeFileEdit(fileEdit); + if (!normalized) { + return undefined; + } + const map = (uri: URI | undefined): URI | undefined => uri ? (mapDiffUri ? mapDiffUri(uri) : uri) : undefined; + return { + kind: normalized.kind, + afterUri: map(normalized.afterUri), + beforeUri: map(normalized.beforeUri), + beforeContentUri: map(normalized.beforeContentUri), + insertions: fileEdit.diff?.added ?? 0, + deletions: fileEdit.diff?.removed ?? 0, + }; +} + +interface IMutableSessionFile { + operation: SessionFileOperation; + originalUri?: URI; +} + +/** + * Reduces an ordered list of parsed file edits into the final per-file state. + * + * Rules: + * - A file created during the session stays {@link SessionFileOperation.Created} + * even if edited afterwards. + * - A deleted file is removed from the list entirely: a file created or edited + * during the session and then deleted nets out, and a pre-existing file that + * is deleted is not surfaced. + * - Renames are modeled as a delete of the source plus a create of the target. + * - Only files outside every workspace folder root are kept. + */ +export function reduceSessionFiles(edits: readonly IParsedFileEdit[], folderRoots: readonly URI[]): ISessionFile[] { + const byUri = new Map<string, { uri: URI; file: IMutableSessionFile }>(); + + const isOutsideWorkspace = (uri: URI): boolean => + !folderRoots.some(root => isEqualOrParent(uri, root)); + + const setCreated = (uri: URI): void => { + if (!isOutsideWorkspace(uri)) { + return; + } + byUri.set(getComparisonKey(uri), { uri, file: { operation: SessionFileOperation.Created } }); + }; + + const setModified = (uri: URI, originalUri: URI | undefined): void => { + if (!isOutsideWorkspace(uri)) { + return; + } + const existing = byUri.get(getComparisonKey(uri)); + if (existing?.file.operation === SessionFileOperation.Created) { + return; // created-then-edited stays created + } + if (existing?.file.operation === SessionFileOperation.Modified) { + // Keep the earliest known original content for the diff. + existing.file.originalUri = existing.file.originalUri ?? originalUri; + return; + } + byUri.set(getComparisonKey(uri), { uri, file: { operation: SessionFileOperation.Modified, originalUri } }); + }; + + // A delete removes the file from the list entirely rather than surfacing it + // as a deleted entry: a create/edit followed by a delete nets out, and a + // pre-existing deleted file simply never appears. + const removeFile = (uri: URI): void => { + byUri.delete(getComparisonKey(uri)); + }; + + for (const edit of edits) { + switch (edit.kind) { + case FileEditKind.Create: + if (edit.afterUri) { + setCreated(edit.afterUri); + } + break; + case FileEditKind.Edit: + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri); + } + break; + case FileEditKind.Delete: + if (edit.beforeUri) { + removeFile(edit.beforeUri); + } + break; + case FileEditKind.Rename: + if (edit.beforeUri) { + removeFile(edit.beforeUri); + } + if (edit.afterUri) { + setCreated(edit.afterUri); + } + break; + } + } + + const files = [...byUri.values()].map(({ uri, file }): ISessionFile => ({ + uri, + operation: file.operation, + originalUri: file.originalUri, + })); + + files.sort((a, b) => strCompare(getComparisonKey(a.uri), getComparisonKey(b.uri))); + return files; +} + +interface IMutableTurnChange { + uri: URI; + modifiedUri: URI | undefined; + originalUri: URI | undefined; + /** Whether the file was created during the turn (kept across later edits). */ + created: boolean; + insertions: number; + deletions: number; +} + +/** + * Reduces a single turn's parsed file edits into one {@link ISessionFileChange} + * per file, aggregating diff stats. Mirrors the "Last Turn Changes" changeset + * so consumers (e.g. the chat input status pills) can reflect the last turn + * straight from the output stream. + * + * Rules: + * - Repeated edits to the same file collapse into a single change whose + * insertions/deletions are the sum of the individual edits. + * - A file created during the turn stays a creation (no original side) even if + * edited afterwards. + * - A create/edit followed by a delete in the same turn nets out; a pre-existing + * file deleted during the turn is surfaced as a deletion (no modified side to + * preview) but still counted in the stats. + * - Renames drop the source and surface the target as an edit of its + * before-content, matching the changeset's classification. + */ +export function reduceTurnChanges(edits: readonly IParsedFileEdit[]): IChatSessionFileChange2[] { + const byUri = new Map<string, IMutableTurnChange>(); + + const setCreated = (uri: URI, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + const existing = byUri.get(key); + if (existing) { + existing.created = true; + existing.modifiedUri = uri; + existing.originalUri = undefined; + existing.insertions += insertions; + existing.deletions += deletions; + return; + } + byUri.set(key, { uri, modifiedUri: uri, originalUri: undefined, created: true, insertions, deletions }); + }; + + const setModified = (uri: URI, originalUri: URI | undefined, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + const existing = byUri.get(key); + if (existing) { + existing.insertions += insertions; + existing.deletions += deletions; + if (!existing.created) { + // Keep the earliest known original content for the diff. + existing.originalUri = existing.originalUri ?? originalUri; + } + return; + } + byUri.set(key, { uri, modifiedUri: uri, originalUri, created: false, insertions, deletions }); + }; + + const setDeleted = (uri: URI, originalUri: URI | undefined, insertions: number, deletions: number): void => { + const key = getComparisonKey(uri); + if (byUri.has(key)) { + // Created/edited earlier in the same turn and now deleted: nets out. + byUri.delete(key); + return; + } + // Pre-existing file deleted during the turn: no modified side to preview. + byUri.set(key, { uri, modifiedUri: undefined, originalUri, created: false, insertions, deletions }); + }; + + for (const edit of edits) { + switch (edit.kind) { + case FileEditKind.Create: + if (edit.afterUri) { + setCreated(edit.afterUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Edit: + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Delete: + if (edit.beforeUri) { + setDeleted(edit.beforeUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + case FileEditKind.Rename: + if (edit.beforeUri) { + byUri.delete(getComparisonKey(edit.beforeUri)); + } + if (edit.afterUri) { + setModified(edit.afterUri, edit.beforeContentUri, edit.insertions, edit.deletions); + } + break; + } + } + + return [...byUri.values()].map(c => ({ + uri: c.uri, + modifiedUri: c.modifiedUri, + originalUri: c.originalUri, + insertions: c.insertions, + deletions: c.deletions, + } satisfies IChatSessionFileChange2)); +} + +function sessionFilesEqual(a: readonly ISessionFile[], b: readonly ISessionFile[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].operation !== b[i].operation + || !isEqual(a[i].uri, b[i].uri) + || !isEqual(a[i].originalUri, b[i].originalUri)) { + return false; + } + } + return true; +} + +/** + * Structural equality over parsed edits, used (via {@link chatFileEditsEqual}) + * as the per-chat observable's `equalsFn` so streamed deltas that carry no + * file-edit change (e.g. markdown or reasoning content) don't re-run the + * downstream reducers. + */ +function parsedFileEditsEqual(a: readonly IParsedFileEdit[], b: readonly IParsedFileEdit[]): boolean { + if (a === b) { + return true; + } + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].kind !== b[i].kind + || a[i].insertions !== b[i].insertions + || a[i].deletions !== b[i].deletions + || !isEqual(a[i].afterUri, b[i].afterUri) + || !isEqual(a[i].beforeUri, b[i].beforeUri) + || !isEqual(a[i].beforeContentUri, b[i].beforeContentUri)) { + return false; + } + } + return true; +} + +/** Structural equality over a chat's parsed edits (full set and last turn). */ +function chatFileEditsEqual(a: IChatFileEdits, b: IChatFileEdits): boolean { + return parsedFileEditsEqual(a.allEdits, b.allEdits) && parsedFileEditsEqual(a.lastTurnEdits, b.lastTurnEdits); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts index 2df8b48604675f..29ace082dcf70b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/baseAgentHostSessionsProvider.ts @@ -11,30 +11,29 @@ import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; -import { constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, ISettableObservable, mapObservableArrayCached, observableFromEvent, observableValue, observableValueOpts, throttledObservable, transaction, waitForState } from '../../../../../base/common/observable.js'; -import { isEqual } from '../../../../../base/common/resources.js'; +import { constObservable, derived, derivedOpts, IObservable, IReader, ISettableObservable, observableFromEvent, observableValue, observableValueOpts, transaction, waitForState, autorun } from '../../../../../base/common/observable.js'; +import { isEqual, isEqualOrParent, relativePath } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; -import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { localize } from '../../../../../nls.js'; -import { AgentSession, IAgentConnection, IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; -import { buildSessionChangesetUri } from '../../../../../platform/agentHost/common/changesetUri.js'; +import { AgentSession, AuthenticateParams, AuthenticateResult, IAgentConnection, IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { buildAnnotationsUri } from '../../../../../platform/agentHost/common/annotationsUri.js'; import { getEffectiveAgents } from '../../../../../platform/agentHost/common/customAgents.js'; import { KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { migrateLegacyAutopilotConfig } from '../../../../../platform/agentHost/common/agentHostSchema.js'; import type { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ResolveSessionConfigResult } from '../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { AgentCustomization, ChangesSummary, type ChangesetFile, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { AgentCustomization, ChangesSummary, ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, type ClientPluginCustomization, Customization, CustomizationType, ModelSelection, SessionStatus as ProtocolSessionStatus, RootConfigState, RootState, SessionActiveClient, SessionState, SessionSummary, type Changeset } from '../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, isChatAction, isSessionAction, NotificationType } from '../../../../../platform/agentHost/common/state/sessionActions.js'; -import { AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, ROOT_STATE_URI, SessionMeta, StateComponents, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCapabilities, AgentInfo, buildChatUri, buildDefaultChatUri, isDefaultChatUri, parseChatUri, readSessionGitHubState, readSessionGitState, readSessionWorkspaceless, ROOT_STATE_URI, SessionMeta, StateComponents, withSessionWorkspaceless, type ChatSummary, type ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceTrustManagementService } from '../../../../../platform/workspace/common/workspaceTrust.js'; +import { AgentHostDownloadProgress } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { ChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; @@ -45,19 +44,81 @@ import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from import { buildMutableConfigSchema, IAgentHostMcpServer, IAgentHostSessionsProvider, resolvedConfigsEqual } from '../../../../common/agentHostSessionsProvider.js'; import { agentHostSessionWorkspaceKey } from '../../../../common/agentHostSessionWorkspace.js'; import { isSessionConfigComplete } from '../../../../common/sessionConfig.js'; -import { ChatOriginKind, IChat, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, DEFAULT_CHAT_CAPABILITIES, IChat, IChatCapabilities, IGitHubInfo, ISession, ISessionAgentRef, ISessionCapabilities, ISessionChangeset, ISessionChangesSummary, ISessionFile, ISessionFileChange, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, sessionFileChangesEqual, SessionStatus, toSessionId } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions } from '../../../../services/sessions/common/sessionsProvider.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; import { computeLivePullRequestIcon } from '../../../github/browser/pullRequestIconStatus.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../github/common/types.js'; import { IPullRequestIconCache } from '../../../github/browser/pullRequestIconCache.js'; -import { CHANGESET_UPDATE_THROTTLE_MS } from './agentHostChangesetConstants.js'; -import { changesetFileToChange, mapProtocolStatus } from './agentHostDiffs.js'; +import { mapProtocolStatus } from './agentHostDiffs.js'; import { createChangesets } from './agentHostSessionChangesets.js'; +import { createSessionOutputObs, ISessionOutputObs } from './agentHostSessionFiles.js'; const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; const UNSAFE_SESSION_CONFIG_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +/** Maximum number of cached session summaries persisted per provider. */ +const CACHED_SESSIONS_MAX_PER_HOST = 100; + +/** + * Serialized shape of an {@link IAgentSessionMetadata} suitable for + * persisting via {@link IStorageService}. URIs are stored as strings + * and diffs are intentionally omitted (they are re-populated when the + * connection refreshes sessions). + */ +interface ISerializedSessionMetadata { + readonly session: string; + readonly startTime: number; + readonly modifiedTime: number; + readonly summary?: string; + readonly workingDirectory?: string; + readonly isRead?: boolean; + readonly isArchived?: boolean; + /** @deprecated Legacy name for `isArchived`. */ + readonly isDone?: boolean; + readonly project?: { readonly uri: string; readonly displayName: string }; + /** + * Whether the session is a workspace-less quick chat. Persisted because the + * adapter's session-kind is fixed at construction from this tag (see + * {@link AgentHostSessionAdapter}); dropping it on restore would leak the + * host's scratch dir as a workspace folder. + */ + readonly workspaceless?: boolean; +} + +function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { + return { + session: meta.session.toString(), + startTime: meta.startTime, + modifiedTime: meta.modifiedTime, + summary: meta.summary, + workingDirectory: meta.workingDirectory?.toString(), + isRead: meta.isRead, + isArchived: meta.isArchived, + project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, + workspaceless: readSessionWorkspaceless(meta._meta) || undefined, + }; +} + +function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { + try { + return { + session: URI.parse(raw.session), + startTime: raw.startTime, + modifiedTime: raw.modifiedTime, + summary: raw.summary, + workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, + isRead: raw.isRead, + isArchived: raw.isArchived ?? raw.isDone, + project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, + ...(raw.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + }; + } catch { + return undefined; + } +} + function isSafeSessionConfigKey(property: string): boolean { return !UNSAFE_SESSION_CONFIG_KEYS.has(property); } @@ -119,6 +180,53 @@ export const CopilotCLISessionType: ISessionType = { icon: Codicon.copilot, }; +/** + * Strategy that captures the quick-chat vs. workspace differences of an + * agent-host session in one place. The concrete kind is chosen once (from the + * session's `workspaceless` tag) at construction, so the adapter and draft + * classes delegate to it instead of re-branching on `readSessionWorkspaceless`. + */ +interface IAgentHostSessionKind { + readonly isQuickChat: boolean; + /** Whether the session requires a workspace/repository to be constructed. */ + readonly requiresWorkspace: boolean; + /** Untitled skeleton title before the first request commits the session. */ + readonly untitledTitle: string; + computeWorkspace(buildWorkspace: () => ISessionWorkspace | undefined): ISessionWorkspace | undefined; +} + +const WorkspaceSessionKind: IAgentHostSessionKind = { + isQuickChat: false, + requiresWorkspace: true, + get untitledTitle() { return localize('new session', "New Session"); }, + computeWorkspace: buildWorkspace => buildWorkspace(), +}; + +const QuickChatSessionKind: IAgentHostSessionKind = { + isQuickChat: true, + requiresWorkspace: false, + get untitledTitle() { return localize('new chat', "New Chat"); }, + computeWorkspace: () => undefined, +}; + +function sessionKind(isQuickChat: boolean): IAgentHostSessionKind { + return isQuickChat ? QuickChatSessionKind : WorkspaceSessionKind; +} + +/** + * Resolves the {@link AgentCapabilities} an agent provider advertises in a root + * state. Returns `undefined` when the root state is unavailable/errored or the + * agent is not advertised — callers treat every absent flag as unsupported. + * This replaces the previous provider-id allow-list so feature gating follows + * the capabilities each agent declares for itself. + */ +function agentCapabilitiesForProvider(rootState: RootState | Error | undefined, agentProvider: string): AgentCapabilities | undefined { + if (!rootState || rootState instanceof Error) { + return undefined; + } + return rootState.agents.find(agent => agent.provider === agentProvider)?.capabilities; +} + /** * Variation points the host provider supplies when building an adapter. * Differences between local and remote sessions (icon, description text, @@ -152,6 +260,22 @@ export interface IAgentHostAdapterOptions { readonly getConnection: () => IAgentConnection | undefined; } +/** + * Maps the protocol {@link ProtocolChatInteractivity} to the provider-agnostic + * {@link ChatInteractivity}. Absent interactivity defaults to {@link + * ChatInteractivity.Full} for backward compatibility. + */ +function toChatInteractivity(interactivity: ProtocolChatInteractivity | undefined): ChatInteractivity { + switch (interactivity) { + case ProtocolChatInteractivity.ReadOnly: + return ChatInteractivity.ReadOnly; + case ProtocolChatInteractivity.Hidden: + return ChatInteractivity.Hidden; + default: + return ChatInteractivity.Full; + } +} + /** * A non-default peer chat within an {@link AgentHostSessionAdapter}. Holds its * own observables seeded from the protocol {@link ChatSummary} so the chat tab @@ -170,9 +294,10 @@ class AdditionalChat extends Disposable { private readonly _mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; private readonly _description: ISettableObservable<IMarkdownString | undefined>; private readonly _lastTurnEnd: ISettableObservable<Date | undefined>; + private readonly _interactivity: ISettableObservable<ChatInteractivity>; private readonly _isNew: ISettableObservable<boolean>; - constructor(resource: URI, summary: ChatSummary, isNew: boolean = false) { + constructor(resource: URI, summary: ChatSummary, isNew: boolean = false, parentChat?: URI, lastTurnChanges?: IObservable<readonly ISessionFileChange[]>) { super(); const modifiedAt = summary.modifiedAt ? new Date(summary.modifiedAt) : new Date(); this._title = observableValue('chatTitle', summary.title || localize('newChatTab', "New Chat")); @@ -182,6 +307,7 @@ class AdditionalChat extends Disposable { this._mode = observableValue<{ readonly id: string; readonly kind: string } | undefined>('chatMode', undefined); this._description = observableValue<IMarkdownString | undefined>('chatDescription', summary.activity ? new MarkdownString().appendText(summary.activity) : undefined); this._lastTurnEnd = observableValue<Date | undefined>('chatLastTurnEnd', modifiedAt); + this._interactivity = observableValue<ChatInteractivity>('chatInteractivity', toChatInteractivity(summary.interactivity)); this._isNew = observableValue<boolean>('chatIsNew', isNew); this.chat = { resource, @@ -190,14 +316,22 @@ class AdditionalChat extends Disposable { updatedAt: this._updatedAt, status: derived(reader => this._isNew.read(reader) ? SessionStatus.Untitled : this._status.read(reader)), changes: constObservable([]), + lastTurnChanges, checkpoints: observableValue(this, undefined), modelId: this._modelId, mode: this._mode, isArchived: constObservable(false), isRead: constObservable(true), + interactivity: this._interactivity, description: this._description, lastTurnEnd: this._lastTurnEnd, - origin: summary.origin ? { kind: toSessionChatOriginKind(summary.origin.kind) } : undefined, + origin: summary.origin ? { kind: toSessionChatOriginKind(summary.origin.kind), parentChat } : undefined, + // Subagent (tool-origin) worker chats are transient children and can be + // neither renamed nor deleted; other peer chats are fully manageable. + capabilities: constObservable<IChatCapabilities>( + summary.origin?.kind === ProtocolChatOriginKind.Tool + ? { canRename: false, canDelete: false } + : DEFAULT_CHAT_CAPABILITIES), }; } @@ -209,6 +343,7 @@ class AdditionalChat extends Disposable { this._updatedAt.set(modifiedAt, tx); this._description.set(summary.activity ? new MarkdownString().appendText(summary.activity) : undefined, tx); this._lastTurnEnd.set(modifiedAt, tx); + this._interactivity.set(toChatInteractivity(summary.interactivity), tx); }); } @@ -261,11 +396,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly icon: ThemeIcon; readonly createdAt: Date; readonly workspace: ISettableObservable<ISessionWorkspace | undefined>; + readonly isQuickChat: IObservable<boolean>; readonly title: ISettableObservable<string>; readonly updatedAt: ISettableObservable<Date>; readonly status: ISettableObservable<SessionStatus>; readonly changes: IObservable<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>; - readonly changesets: ISettableObservable<readonly ISessionChangeset[]>; + readonly changesets: ISettableObservable<readonly ISessionChangeset[] | undefined>; + readonly externalChanges: IObservable<readonly ISessionFile[]>; readonly modelId: ISettableObservable<string | undefined>; modelSelection: ModelSelection | undefined; readonly mode: ISettableObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -283,13 +420,30 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { readonly mainChat: IObservable<IChat>; readonly chats: IObservable<readonly IChat[]>; - readonly capabilities: ISessionCapabilities; + /** + * Capabilities derived reactively from the connection's root state rather + * than snapshotted at construction time. The root state can still be loading + * when an adapter is built (the agent-host process may be starting), in which + * case the agent's advertised capabilities are not yet available; the derived + * re-emits (and drives the chat catalog / context keys) as soon as the root + * state arrives instead of being permanently frozen to the `false` defaults. + * `supportsRename`/`supportsDelete` are always supported for agent-host + * sessions. + */ + readonly capabilities: IObservable<ISessionCapabilities>; /** * The default chat (resource == this session's resource). Always present; * for single-chat sessions it is the only chat and `chats === [it]`. */ private readonly _defaultChat: IChat; + /** + * The session's live output observables (external files + per-chat last-turn + * changes), parsed once from the active-session subscriptions and shared by + * the default chat and every peer chat so each chat's status pills reflect + * that chat's own last turn. + */ + private readonly _sessionOutput: ISessionOutputObs; /** * Independent title override for the default chat tab. `undefined` means the * default chat inherits the session title; a non-empty value means the user @@ -304,12 +458,20 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * (which may have been promoted by a running peer chat). */ private readonly _defaultChatStatusOverride = observableValue<SessionStatus | undefined>('defaultChatStatusOverride', undefined); + /** Interactivity of the default chat. Driven from the default chat's protocol summary. */ + private readonly _defaultChatInteractivity = observableValue<ChatInteractivity>('defaultChatInteractivity', ChatInteractivity.Full); private readonly _mainChatObs: ISettableObservable<IChat>; private readonly _chatsObs: ISettableObservable<readonly IChat[]>; /** Additional (non-default) peer chats keyed by chatId. */ private readonly _additionalChats = this._register(new DisposableMap<string, AdditionalChat>()); /** Chat ids that have not yet sent their first request (presented as `Untitled`). */ private readonly _newChatIds = new Set<string>(); + /** + * The last {@link SessionState} applied to the chat catalog, retained so the + * catalog can be re-reconciled when {@link capabilities} change after the + * fact (see the capability autorun in the constructor). + */ + private _lastCatalogState: SessionState | undefined; private readonly _rawId: string; private readonly _resourceScheme: string; @@ -320,7 +482,14 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // list refresh). See `_applySessionMetaFromState` / `setMeta`. private _project: IAgentSessionMetadata['project']; private _workingDirectory: URI | undefined; + // The directory that the current `mode` custom-agent URI is rooted at. Used to + // compute the agent's repo-relative path so the selection can be rebased onto + // its worktree twin when the session relocates into an isolated worktree (see + // `reconcileSelectedAgent`). + private _agentBaseDir: URI | undefined; private _meta: SessionMeta | undefined; + /** Session-kind strategy chosen once at construction (quick chat vs. workspace). */ + private readonly _kind: IAgentHostSessionKind; /** * Observable mirror of {@link _meta}, kept in sync with every write to * `_meta` so reactive derivations (notably {@link gitHubInfo}) re-fire @@ -333,7 +502,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private _activity: ISettableObservable<string | undefined>; private readonly _changesSummary = observableValueOpts<ISessionChangesSummary | undefined>({ equalsFn: structuralEquals }, undefined); - readonly changesSummary: IObservable<ISessionChangesSummary | undefined>; + get changesSummary(): IObservable<ISessionChangesSummary | undefined> { return this._changesSummary; } setChangesSummary(changes: ChangesSummary | undefined): boolean { if (!changes) { return false; @@ -384,7 +553,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this.sessionId = toSessionId(providerId, this.resource); this.providerId = providerId; this.sessionType = logicalSessionType; - this.capabilities = { supportsMultipleChats: logicalSessionType === CopilotCLISessionType.id, supportsRename: true, supportsDelete: true }; + this._kind = sessionKind(readSessionWorkspaceless(metadata._meta)); this.icon = _options.icon; this.createdAt = new Date(metadata.startTime); this.title = observableValue('title', metadata.summary || `Session ${rawId.substring(0, 8)}`); @@ -450,16 +619,17 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { const livePR = prModelRef.object.pullRequest.read(reader); if (!livePR) { - // The live model hasn't been fetched yet (e.g. right after startup). Show - // the last known icon from the persistent cache so the row isn't icon-less - // while the first fetch is in flight. - const cachedIcon = this._pullRequestIconCache.get(prLink); - if (!cachedIcon) { - return baseGitHubInfo; - } + // The live model hasn't been fetched yet (e.g. right after a PR is first + // detected, or right after startup). Show the last known icon from the + // persistent cache, falling back to a neutral open-PR icon, so the row + // surfaces a PR icon immediately instead of the read/unread dot while the + // first fetch is in flight. The agent-host git state carries no PR state, + // so the live model refines it (merged/closed/draft/failing checks) within + // a poll cycle. + const icon = this._pullRequestIconCache.get(prLink) ?? computePullRequestIcon(GitHubPullRequestState.Open); return { ...baseGitHubInfo, - pullRequest: { ...baseGitHubInfo.pullRequest, icon: cachedIcon } + pullRequest: { ...baseGitHubInfo.pullRequest, icon } }; } @@ -476,9 +646,9 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { }; }); - const initialGitState = readSessionGitState(this._meta); - const initialWorkspace = _options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, initialGitState); + const initialWorkspace = this._computeWorkspace(); this.workspace = observableValue('workspace', initialWorkspace); + this.isQuickChat = constObservable(this._kind.isQuickChat); this.loading = _options.loading; this.description = derived(reader => { const status = this.status.read(reader); @@ -507,13 +677,24 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { // updated from `metadata.changes` (mirroring `SessionSummary.changes`). this.setChangesSummary(metadata.changes); - const sessionUri = AgentSession.uri(this.sessionType, rawId); - const { changesSummary, changes } = this._createChangesObs(sessionUri); - this.changesSummary = changesSummary; - this.changes = changes; + // Changesets will be resolved asynchronously when the session is active. `undefined` + // marks the uninitialized state, distinct from a resolved session that simply has no + // changesets (an empty array). + this.changesets = observableValue<readonly ISessionChangeset[] | undefined>(this, undefined); + + // Create an observable for the changes of the session's + // default changeset (ex: Branch Changes). This will always + // track the default changeset independent of the selected + // changeset. + this.changes = this._createChangesObs(); - // Changesets will be resolved asynchronously when the session is active. - this.changesets = observableValue<readonly ISessionChangeset[]>(this, []); + // Files created/edited/deleted outside the workspace, plus the last turn's + // changes, parsed from the chat-state turns. Computed lazily from the same + // active-session subscriptions used for changes. + const sessionUri = AgentSession.uri(this.sessionType, rawId); + const sessionOutput = createSessionOutputObs(sessionUri, this._options, this.isActiveSessionObs, this.isArchived, this.workspace); + this._sessionOutput = sessionOutput; + this.externalChanges = sessionOutput.externalFiles; const mainChat: IChat = { resource: this.resource, @@ -522,11 +703,13 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { updatedAt: this.updatedAt, status: derived(this, reader => this._defaultChatStatusOverride.read(reader) ?? this.status.read(reader)), changes: this.changes, + lastTurnChanges: sessionOutput.getLastTurnChanges(URI.parse(buildDefaultChatUri(sessionUri))), checkpoints: observableValue(this, undefined), modelId: this.modelId, mode: this.mode, isArchived: this.isArchived, isRead: this.isRead, + interactivity: this._defaultChatInteractivity, description: this.description, lastTurnEnd: this.lastTurnEnd, }; @@ -535,6 +718,33 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._chatsObs = observableValue<readonly IChat[]>(this, [mainChat]); this.mainChat = this._mainChatObs; this.chats = this._chatsObs; + + const connection = this._options.getConnection(); + const rootStateObs: IObservable<RootState | Error | undefined> = connection + ? observableFromEvent(this, connection.rootState.onDidChange, () => connection.rootState.value) + : constObservable(undefined); + this.capabilities = derivedOpts<ISessionCapabilities>({ owner: this, equalsFn: structuralEquals }, reader => { + const agentCapabilities = agentCapabilitiesForProvider(rootStateObs.read(reader), this.agentProvider); + return { + supportsMultipleChats: !this._kind.isQuickChat && (agentCapabilities?.multipleChats !== undefined), + supportsFork: agentCapabilities?.multipleChats?.fork ?? false, + supportsRename: true, + supportsDelete: true, + }; + }); + + // Re-apply the chat catalog when advertised capabilities change (e.g. the + // agent host's root state arrives after the session's first state update). + // Without this, a multi-chat session whose state was processed while + // `supportsMultipleChats` was still `false` would stay collapsed to + // `[defaultChat]` until the next session-state update. + this._register(autorun(reader => { + this.capabilities.read(reader); + const state = this._lastCatalogState; + if (state) { + this._applyChatCatalog(state); + } + })); } /** @@ -544,22 +754,42 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { * {@link _defaultChat}. Additional peer chats become their own {@link IChat} * whose resource carries the chatId in the URI fragment so the chat view * opens a distinct widget that the session handler routes to the matching - * chat channel. Single-chat sessions (or non-`copilotcli` types) degrade to - * `[defaultChat]`. + * chat channel. + * + * A non-default chat surfaces as a peer tab when the session supports + * multiple chats (the `copilotcli` case) OR when it is a subagent + * (tool-origin) chat. Subagent chats are always surfaced as read-only peers + * — independent of multi-chat support — so the user can review a worker's + * transcript (the agent-team pattern). Sessions with no surfaced peers + * degrade to `[defaultChat]`. */ applyChatCatalog(state: SessionState): void { + this._lastCatalogState = state; + this._applyChatCatalog(state); + } + + private _applyChatCatalog(state: SessionState): void { // The default chat's catalog title drives its independent tab title. // Empty means "inherit the session title"; a non-empty value means it was // renamed independently of the session. - const defaultChatUriStr = state.defaultChat?.toString(); - const defaultSummary = state.chats.find(s => defaultChatUriStr - ? s.resource.toString() === defaultChatUriStr - : isDefaultChatUri(s.resource)); + const defaultChatUri = state.defaultChat?.toString(); + const isDefault = (summary: ChatSummary): boolean => defaultChatUri + ? summary.resource.toString() === defaultChatUri + : isDefaultChatUri(summary.resource); + const defaultSummary = state.chats.find(isDefault); this._defaultChatTitleOverride.set(defaultSummary?.title || undefined, undefined); - - if (!this.capabilities.supportsMultipleChats || state.chats.length <= 1) { - // Single-chat: the default chat is the session, so let it reflect the - // aggregated session status directly (clear any prior override). + this._defaultChatInteractivity.set(toChatInteractivity(defaultSummary?.interactivity), undefined); + + // Subagent (tool-origin) chats always surface as read-only peers; other + // non-default chats surface only when the session supports multiple chats. + const surfacesAsPeer = (summary: ChatSummary): boolean => + !isDefault(summary) + && !!parseChatUri(summary.resource)?.chatId + && (this.capabilities.get().supportsMultipleChats || summary.origin?.kind === ProtocolChatOriginKind.Tool); + + if (!state.chats.some(surfacesAsPeer)) { + // Single visible chat: the default chat is the session, so let it + // reflect the aggregated session status directly (clear any override). this._defaultChatStatusOverride.set(undefined, undefined); if (this._additionalChats.size > 0) { this._additionalChats.clearAndDisposeAll(); @@ -573,25 +803,21 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return; } - // Multi-chat: the default chat must show its own status, not the session - // aggregate which may have been promoted by a running peer chat. + // Multiple chats: the default chat must show its own status, not the + // session aggregate which may have been promoted by a running peer chat. this._defaultChatStatusOverride.set(defaultSummary ? mapProtocolStatus(defaultSummary.status) : undefined, undefined); - const defaultChatUri = defaultChatUriStr; const seen = new Set<string>(); const ordered: IChat[] = []; for (const summary of state.chats) { - const isDefault = defaultChatUri - ? summary.resource.toString() === defaultChatUri - : isDefaultChatUri(summary.resource); - if (isDefault) { + if (isDefault(summary)) { ordered.push(this._defaultChat); continue; } - const chatId = parseChatUri(summary.resource)?.chatId; - if (!chatId) { + if (!surfacesAsPeer(summary)) { continue; } + const chatId = parseChatUri(summary.resource)!.chatId; seen.add(chatId); let entry = this._additionalChats.get(chatId); if (!entry) { @@ -618,7 +844,29 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { private _createAdditionalChat(chatId: string, summary: ChatSummary): AdditionalChat { const resource = URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: chatId }); - return new AdditionalChat(resource, summary, this._newChatIds.has(chatId)); + const lastTurnChanges = this._sessionOutput.getLastTurnChanges(URI.parse(summary.resource)); + return new AdditionalChat(resource, summary, this._newChatIds.has(chatId), this._resolveParentChatResource(summary.origin), lastTurnChanges); + } + + /** + * Maps a protocol parent-chat URI (from a Tool/Fork {@link ChatSummary.origin}) + * to this session's UI chat resource: the default chat maps to the session + * resource; peer chats carry their chatId in the resource fragment. + */ + private _resolveParentChatResource(origin: ChatSummary['origin']): URI | undefined { + const parentUri = origin && (origin.kind === ProtocolChatOriginKind.Tool || origin.kind === ProtocolChatOriginKind.Fork) + ? origin.chat + : undefined; + if (!parentUri) { + return undefined; + } + if (isDefaultChatUri(parentUri)) { + return this.resource; + } + const parentChatId = parseChatUri(parentUri)?.chatId; + return parentChatId + ? URI.from({ scheme: this._resourceScheme, path: `/${this._rawId}`, fragment: parentChatId }) + : this.resource; } /** Mark a peer chat new so it shows as `Untitled` until its first request. */ @@ -649,9 +897,105 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._getAdditionalChat(chatResource)?.setAgent(agent); } else { this.mode.set(agent ? { id: agent.uri, kind: AGENT_MODE_KIND } : undefined, undefined); + // Remember which working directory the agent URI is rooted at so the + // selection can be rebased if the session later relocates into a worktree. + this._agentBaseDir = agent ? this._workingDirectory : undefined; + } + } + + /** + * Reconcile the selected custom-agent URI against the host's current agent + * list — e.g. the session graduated with an agent picked in the original repo + * but now runs in an isolated worktree, where the host reports the same agent + * file under the worktree path. + * + * The selection is rebased by matching the agent's repo-relative path against + * the available agents (which already carry the worktree root) rather than the + * session's reported working directory. The working directory is unreliable + * here: the worktree-pathed customizations arrive well before either the + * `SessionSummary` or `SessionState` working-directory flips to the worktree, + * so a working-directory-keyed rebase would miss the window and let the picker + * destructively reset the selection. Deriving the worktree root from the agent + * list closes that race. + * + * Mirrors the agent-host backend's code to rebase by relative path. + * The re-point is only applied to a URI that actually exists in + * the supplied agent list, so it never runs ahead of the host reporting the + * worktree agents (which would otherwise re-introduce the mismatch it fixes). + */ + reconcileSelectedAgent(agents: readonly AgentCustomization[]): void { + const current = this.mode.get(); + if (!current || agents.some(a => a.uri === current.id)) { + return; // no agent selected, or the selection is already valid + } + const base = this._agentBaseDir; + if (!base) { + return; // unknown root for the current selection — nothing to rebase against + } + const agentUri = URI.parse(current.id); + if (!isEqualOrParent(agentUri, base)) { + return; // agent lives outside the repo (e.g. a user-global agent) + } + const rel = relativePath(base, agentUri); + if (!rel) { + return; + } + const relocated = this._findRelocatedAgent(agents, agentUri, base, rel); + if (relocated) { + this.mode.set({ id: relocated.uri, kind: current.kind }, undefined); + this._agentBaseDir = relocated.root; } } + /** + * Finds an available agent that is the same repo-relative file as the current + * selection but rooted under a different directory (its worktree twin). + * + * A candidate matches when its path ends with `/<rel>` on a path-segment + * boundary and the implied root (the candidate path minus that suffix) differs + * from `base`. The root is re-validated with `relativePath` so only a genuine + * relocation of the same file is accepted. Returns the matched agent's URI and + * its derived root, or `undefined` when there is no twin. + */ + private _findRelocatedAgent( + agents: readonly AgentCustomization[], + agentUri: URI, + base: URI, + rel: string, + ): { readonly uri: string; readonly root: URI } | undefined { + const suffix = `/${rel}`; + for (const agent of agents) { + const candidate = URI.parse(agent.uri); + if (candidate.scheme !== agentUri.scheme || candidate.authority !== agentUri.authority) { + continue; + } + if (!candidate.path.endsWith(suffix) || candidate.path.length === suffix.length) { + continue; // not the same relative file, or it sits at the filesystem root + } + const root = candidate.with({ path: candidate.path.slice(0, candidate.path.length - suffix.length) }); + if (isEqual(root, base) || relativePath(root, candidate) !== rel) { + continue; // same root (would have matched exactly), or not a clean relocation + } + return { uri: agent.uri, root }; + } + return undefined; + } + + /** + * Seed the selected custom agent when a session is resumed (e.g. after a + * window reload). A freshly loaded adapter starts with `mode === undefined`; + * the host persists the selection on the default chat's `ChatState.draft.agent`, + * which the provider reads and mirrors onto `session.mode` here. Guarded to + * never override a live selection (a Part 1 graduation seed or a user pick), + * keeping this a resume-only hydration. + */ + hydrateSelectedAgent(agentUri: string): void { + if (this.mode.get() !== undefined) { + return; + } + this.setChatAgent(this.resource, { uri: agentUri, name: '' }); + } + getChatModelId(chatResource: URI): string | undefined { return chatResource.fragment ? this._getAdditionalChat(chatResource)?.chat.modelId.get() @@ -692,97 +1036,28 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return undefined; } - private _createChangesObs(sessionUri: URI): { - changesSummary: IObservable<ISessionChangesSummary | undefined>; - changes: IObservable<readonly (IChatSessionFileChange | IChatSessionFileChange2)[]>; - } { - const sessionChangesetStateObs = derived(this, reader => { - const connection = this._options.getConnection(); - if (!connection) { - return constObservable(undefined); - } - - const isActiveSession = this.isActiveSessionObs.read(reader); - if (!isActiveSession) { - return constObservable(undefined); - } - - const branchChangesUri = URI.parse(buildSessionChangesetUri(sessionUri.toString())); - const subscriptionRef = connection.getSubscription(StateComponents.Changeset, branchChangesUri, 'BaseAgentHostSessionsProvider.changesets'); - reader.store.add(subscriptionRef); - - return observableFromEvent(subscriptionRef.object.onDidChange, () => subscriptionRef.object.value); - }); - - const mapDiffUri = this._options.mapDiffUri; - - // Coalesce the per-envelope changeset stream. `sessionChangesetStateObs` - // is a nested observable (which-subscription → value); flatten it to the - // value stream, then throttle. Throttle (not debounce) so a continuous - // stream keeps updating ~10x/s instead of starving until edits stop; the - // trailing read always delivers the final state. - const throttledChangesetValueObs = throttledObservable(sessionChangesetStateObs.flatten(), CHANGESET_UPDATE_THROTTLE_MS); - - // Hold the raw `ChangesetFile[]` (with last-value semantics) rather than - // the mapped changes. The changeset reducer preserves the reference of - // every file that didn't change, so keeping the raw list lets the - // per-file cache below skip rebuilding them. - const changesetFilesObs = derivedObservableWithCache<readonly ChangesetFile[] | undefined>(this, (reader, lastValue) => { - const isActiveSession = this.isActiveSessionObs.read(reader); - if (!isActiveSession) { - return lastValue; - } - - const branchChangesState = throttledChangesetValueObs.read(reader); - if (!branchChangesState || branchChangesState instanceof Error || branchChangesState.status !== 'ready') { - return lastValue; - } - - return branchChangesState.files; - }); - - // Build one change per file, reusing the cached result for files whose - // `ChangesetFile` reference is unchanged. Only the file(s) that actually - // changed get re-parsed and re-mapped, turning the previous O(all files) - // URI work per update into O(changed files). - const mappedChangesObs = mapObservableArrayCached(this, changesetFilesObs.map(files => files ?? []), file => changesetFileToChange(file, mapDiffUri)); - - const changesetChangesObs = derived<readonly (IChatSessionFileChange | IChatSessionFileChange2)[] | undefined>(this, reader => { - const files = changesetFilesObs.read(reader); - if (files === undefined) { - return undefined; - } - return mappedChangesObs.read(reader).filter(isDefined); - }); - - const changesetSummaryObs = derivedOpts<ISessionChangesSummary | undefined>({ equalsFn: structuralEquals }, reader => { - const changesetChanges = changesetChangesObs.read(reader); - if (!changesetChanges) { + private _createChangesObs(): IObservable<readonly ISessionFileChange[]> { + const defaultChangesetObs = derivedOpts<ISessionChangeset | undefined>({ + equalsFn: (c1, c2) => c1?.id === c2?.id + }, reader => { + const changesets = this.changesets.read(reader); + if (!changesets) { return undefined; } - let additions = 0, deletions = 0; - for (const change of changesetChanges) { - additions += change.insertions; - deletions += change.deletions; - } - - return { additions, deletions, files: changesetChanges.length }; + return changesets.find(c => c.isDefault.read(reader) === true); }); - const changesSummaryObs = derivedOpts<ISessionChangesSummary | undefined>({ equalsFn: structuralEquals }, reader => { - const isActiveSession = this.isActiveSessionObs.read(reader); - const changesetSummary = changesetSummaryObs.read(reader); - const changesSummary = this._changesSummary.read(reader); - - return isActiveSession && changesetSummary ? changesetSummary : changesSummary; + const defaultChangesetChangesObs = derived(reader => { + const defaultChangeset = defaultChangesetObs.read(reader); + if (!defaultChangeset) { + return []; + } + return defaultChangeset.changes.read(reader); }); - return { - changesSummary: changesSummaryObs, - changes: derivedOpts({ equalsFn: sessionFileChangesEqual }, - reader => changesetChangesObs.read(reader) ?? []) - }; + return derivedOpts({ equalsFn: sessionFileChangesEqual }, + reader => defaultChangesetChangesObs.read(reader) ?? []); } /** @@ -822,16 +1097,15 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { this._project = metadata.project; this._workingDirectory = metadata.workingDirectory; - // Only update `_meta` when the source actually provides one. `update()` - // is fed from SessionSummary (via `listSessions`/`sessionAdded` paths) - // which has no `_meta` field, so an undefined value here means "not - // included" rather than "cleared". `_meta` (e.g. git state) flows in - // exclusively via `setMeta` from `SessionState` subscription updates. + // Only update `_meta` when the source actually provides one — an + // undefined value means "not included" (e.g. a summary path that + // omits it), not "cleared". The authoritative git-state `_meta` + // still flows via `setMeta` from `SessionState` subscriptions. if (metadata._meta !== undefined) { this._meta = metadata._meta; this._metaObs.set(this._meta, tx); } - const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, readSessionGitState(this._meta)); + const workspace = this._computeWorkspace(); if (agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get())) { this.workspace.set(workspace, tx); didChange = true; @@ -883,8 +1157,7 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { */ setMeta(meta: SessionMeta | undefined): boolean { this._meta = meta; - const gitState = readSessionGitState(this._meta); - const workspace = this._options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, gitState); + const workspace = this._computeWorkspace(); const workspaceChanged = agentHostSessionWorkspaceKey(workspace) !== agentHostSessionWorkspaceKey(this.workspace.get()); transaction(tx => { this._metaObs.set(this._meta, tx); @@ -895,6 +1168,15 @@ export class AgentHostSessionAdapter extends Disposable implements ISession { return workspaceChanged; } + /** + * Resolves the session workspace. Quick chats stay workspace-less + * (`undefined`) regardless of any scratch working directory the host + * assigned; workspace sessions build from project/git metadata. + */ + private _computeWorkspace(): ISessionWorkspace | undefined { + return this._kind.computeWorkspace(() => this._options.buildWorkspace(this._project, this._workingDirectory, this.gitHubInfo, readSessionGitState(this._meta))); + } + updateChangesets(changesetsMetadata: readonly Changeset[] | undefined) { if (!changesetsMetadata) { return; @@ -947,7 +1229,19 @@ function flattenActiveClientCustomizations(state: SessionState): ClientPluginCus * Inputs needed to construct a {@link NewSession}. */ interface INewSessionConstructionContext { - readonly workspace: ISessionWorkspace; + /** + * Workspace the session is scoped to, or `undefined` for a **quick chat** + * (a workspace-less session not bound to any folder). When `undefined`, + * {@link quickChat} must be `true` and the backend session is created with + * no `workingDirectory` (the host assigns a throwaway scratch cwd). + */ + readonly workspace: ISessionWorkspace | undefined; + /** + * `true` when this is a quick chat (see {@link workspace}). Forwarded to the + * agent host on `createSession` so the session is tagged and routed as + * workspace-less. + */ + readonly quickChat?: boolean; readonly sessionType: ISessionType; readonly providerId: string; readonly icon: ThemeIcon; @@ -957,8 +1251,9 @@ interface INewSessionConstructionContext { /** * Optional initial config values to seed into the new session before its * first {@link NewSession.resolveConfig} round-trip. Used to forward - * `chat.permissions.default` into the agent host's `autoApprove` slot so - * the picker reflects the user's preference immediately. + * `chat.permissions.default` into the agent host's `autoApprove` slot and + * `git.branchPrefix` into the `worktreeBranchPrefix` slot so the values are + * present from the very first `resolveConfig`/`createSession`. */ readonly initialConfigValues?: Record<string, unknown>; /** @@ -1007,8 +1302,12 @@ class NewSession extends Disposable { readonly session: ISession; readonly sessionId: string; readonly agentProvider: string; - readonly workspaceUri: URI; + readonly workspaceUri: URI | undefined; readonly requiresWorkspaceTrust: boolean; + /** `true` when this is a workspace-less quick chat. */ + readonly isQuickChat: boolean; + /** Session-kind strategy chosen once at construction (quick chat vs. workspace). */ + private readonly _kind: IAgentHostSessionKind; private readonly _status: ISettableObservable<SessionStatus>; private readonly _title: ISettableObservable<string>; @@ -1066,12 +1365,14 @@ class NewSession extends Disposable { constructor(ctx: INewSessionConstructionContext) { super(); - const workspaceUri = ctx.workspace.folders[0]?.root; - if (!workspaceUri) { + const workspaceUri = ctx.workspace?.folders[0]?.root; + this._kind = sessionKind(!!ctx.quickChat); + if (this._kind.requiresWorkspace && !workspaceUri) { throw new Error('Workspace has no repository URI'); } this.workspaceUri = workspaceUri; - this.requiresWorkspaceTrust = !!ctx.workspace.requiresWorkspaceTrust; + this.isQuickChat = this._kind.isQuickChat; + this.requiresWorkspaceTrust = !!ctx.workspace?.requiresWorkspaceTrust; this.agentProvider = ctx.sessionType.id; this._providerId = ctx.providerId; this._logService = ctx.logService; @@ -1105,7 +1406,9 @@ class NewSession extends Disposable { changes, checkpoints, modelId: this._modelId, - mode, isArchived, isRead, description, lastTurnEnd, + mode, isArchived, isRead, + interactivity: constObservable(ChatInteractivity.Full), + description, lastTurnEnd, }; this._mainChat = observableValue<IChat>(this, mainChat); const authPending = ctx.authenticationPending; @@ -1120,6 +1423,7 @@ class NewSession extends Disposable { icon: ctx.icon, createdAt, workspace: workspaceObs, + isQuickChat: constObservable(this._kind.isQuickChat), title, updatedAt, status: this._status, @@ -1134,7 +1438,7 @@ class NewSession extends Disposable { lastTurnEnd, mainChat: this._mainChat, chats, - capabilities: { supportsMultipleChats: false, supportsRename: true, supportsDelete: true }, + capabilities: constObservable({ supportsMultipleChats: false, supportsRename: true, supportsDelete: true }), }; this.sessionId = this.session.sessionId; @@ -1152,7 +1456,8 @@ class NewSession extends Disposable { getSelectedModelId(): string | undefined { return this._selectedModelId; } clearSelectedModelId(): void { this._selectedModelId = undefined; } - + /** Untitled skeleton title used until the first request commits the session. */ + get untitledTitle(): string { return this._kind.untitledTitle; } setSelectedAgent(agent: ISessionAgentRef | undefined): void { this._selectedAgent = agent; this._mode.set(agent ? { id: agent.uri, kind: AGENT_MODE_KIND } : undefined, undefined); @@ -1290,6 +1595,12 @@ class NewSession extends Disposable { session: backendUri, workingDirectory: this.workspaceUri, config: this._config?.values, + // MCP-style opt-in: offer to receive `progress` for any + // long-running bring-up (chiefly the lazy first-use SDK + // download, which fires later at first-message + // materialization). The host echoes this token on each + // `progress` frame so `_handleProgress` can correlate it. + progressToken: generateUuid(), ...(this._selectedAgent ? { agent: { uri: this._selectedAgent.uri } } : {}), ...(this._initialActiveClient ? { activeClient: this._initialActiveClient } : {}), }); @@ -1458,6 +1769,38 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement /** Cache of adapted sessions, keyed by raw session ID. */ protected readonly _sessionCache = new Map<string, AgentHostSessionAdapter>(); + /** + * Storage key under which {@link _sessionCache} snapshots are persisted, or + * `undefined` while persistence is disabled. Set via + * {@link _enableSessionCachePersistence}, which subclasses call once their + * identity fields are ready. When `undefined`, the cache is in-memory only. + */ + private _sessionCacheStorageKey: string | undefined; + + /** + * Snapshot of the source metadata for each adapter in {@link _sessionCache}, + * keyed by raw session ID. Captured in {@link createAdapter}/{@link updateAdapter} + * and re-used by {@link _persistCache} to serialize sessions without having to + * reconstruct every `IAgentSessionMetadata` field from observables. + */ + private readonly _metaByRawId = new Map<string, IAgentSessionMetadata>(); + + /** + * Set when {@link _sessionCache} has changed since the last persist. The + * actual write happens on the next `onWillSaveState` signal from + * {@link IStorageService} so that bursts of notifications do not repeatedly + * re-serialize the whole cache. + */ + private _cacheDirty = false; + + /** + * Renders the agent host's lazy, first-use SDK download as a notification + * progress bar. Shared with the editor window so both surfaces render + * download progress identically. Fed by the `NotificationType.Progress` + * frames received in {@link _attachConnectionListeners}. + */ + private readonly _downloadProgress: AgentHostDownloadProgress; + /** * Temporary session that has been sent (first turn dispatched) but not yet * committed by the backend session list. Shown in the session list until the @@ -1466,6 +1809,27 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ protected _pendingSession: ISession | undefined; + /** + * Raw ids of backend sessions that an in-flight {@link _waitForNewSession} + * has already matched to its send, so a *concurrent* new-session send of + * the same scheme does not resolve to the same committed session. Each + * matched id is released by the owning send in its `finally`. + */ + private readonly _committingSessionRawIds = new Set<string>(); + + /** + * Own raw ids ({@link chatResource} path) of currently in-flight + * new-session sends. A send's committed backend session keeps the eager + * id it was created with, so {@link _waitForNewSession} matches a send to + * its OWN id first. The novelty fallback (for flows where the backend + * assigns a different id) must then never latch onto *another* in-flight + * send's own session — otherwise two concurrent same-scheme sends racing + * in a shared download/materialize window would swap sessions (each + * graduating onto the other's committed session). Populated at send start, + * cleared in the send's `finally`. + */ + private readonly _inFlightNewSessionOwnIds = new Set<string>(); + /** * In-flight new sessions — sessions being composed in the new-chat view * before their first message is sent, keyed by `sessionId`. See @@ -1526,6 +1890,19 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ private readonly _sessionStateIdleTimers = this._register(new DisposableMap<string, IDisposable>()); + /** + * Session ids whose views are currently visible in the Agents window. Their + * state subscription is pinned open (no idle release) so host-driven catalog + * changes the user did not initiate — most importantly spawned subagent chats + * ({@link ChatOriginKind.Tool}) — keep flowing into `cached.chats` while the + * session is on screen. Without this, the idle timer (only refreshed by + * client-initiated actions/queries) can release the state listener mid-view, + * so a subagent's `chatAdded` is dropped and its inline "Open Subagent" pill + * cannot resolve until the session is re-subscribed (e.g. switched away and + * back). Driven by {@link _syncVisibleSessionStatePins}. + */ + private readonly _pinnedSessionStates = new Set<string>(); + protected _cacheInitialized = false; private static readonly SESSION_REFRESH_RETRY_MIN_MS = 1_000; @@ -1563,12 +1940,44 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement @IWorkspaceTrustManagementService protected readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, ) { super(); + this._downloadProgress = this._register(this._instantiationService.createInstance(AgentHostDownloadProgress)); this._register(toDisposable(() => { for (const cached of this._sessionCache.values()) { cached.dispose(); } this._sessionCache.clear(); })); + + // Keep the state subscription of every on-screen session pinned so + // host-spawned catalog changes (e.g. subagents) reach `cached.chats` + // live, instead of relying on the idle timer that only client actions + // refresh. + this._register(autorun(reader => this._syncVisibleSessionStatePins(reader))); + + // Session-cache persistence. These listeners are inert until a subclass + // opts in via `_enableSessionCachePersistence` (which sets the storage + // key). They are safe to register unconditionally because they only act + // at event time and read the key lazily. + this._register(this._onDidChangeSessions.event(e => { + if (!this._shouldTrackSessionCacheChanges()) { + return; + } + if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { + this._cacheDirty = true; + } + for (const removed of e.removed) { + const rawId = this._rawIdFromChatId(removed.sessionId); + if (rawId) { + this._metaByRawId.delete(rawId); + } + } + })); + this._register(this._storageService.onWillSaveState(() => { + if (this._sessionCacheStorageKey && this._cacheDirty) { + this._persistCache(); + this._cacheDirty = false; + } + })); } // -- Subclass hooks ------------------------------------------------------- @@ -1604,9 +2013,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ...this._adapterOptions(), } satisfies IAgentHostAdapterOptions; + this._metaByRawId.set(AgentSession.id(meta.session), meta); return this._instantiationService.createInstance(AgentHostSessionAdapter, meta, this.id, resourceScheme, provider, options); } + protected updateAdapter(adapter: AgentHostSessionAdapter, meta: IAgentSessionMetadata): boolean { + this._metaByRawId.set(AgentSession.id(meta.session), meta); + this._cacheDirty = true; + return adapter.update(meta); + } + /** * Computes the URI resource scheme used to route session URIs to this * provider's content provider for a given agent provider name. Local @@ -1799,6 +2215,30 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement throw new Error(`Cannot resolve workspace for URI: ${workspaceUri.toString()}`); } + return this._createDraftSession(sessionType, workspace, false); + } + + createQuickChat(sessionTypeId: string): ISession { + const sessionType = this.sessionTypes.find(t => t.id === sessionTypeId); + if (!sessionType) { + throw new Error(this._noAgentsErrorMessage()); + } + + this._validateBeforeCreate(sessionType); + + // A quick chat is the same session type as a normal session, just + // workspace-less: no `resolveWorkspace`, no `workingDirectory`. The + // agent host runs it in a throwaway scratch cwd and tags it via the + // `quickChat` create flag. + return this._createDraftSession(sessionType, undefined, true); + } + + /** + * Builds, tracks, and eagerly starts a {@link NewSession} draft for the + * given session type. Shared by {@link createNewSession} (workspace-bound) + * and {@link createQuickChat} (workspace-less, `quickChat === true`). + */ + private _createDraftSession(sessionType: ISessionType, workspace: ISessionWorkspace | undefined, quickChat: boolean): ISession { // Tear-down of superseded drafts is handled by the management layer // (it calls `deleteNewSession` on the previous pending session). Each // new session is tracked independently in `_newSessions` so several can @@ -1808,13 +2248,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement const resourceScheme = this.resourceSchemeForProvider(sessionType.id); const newSession = new NewSession({ workspace, + quickChat, sessionType, providerId: this.id, icon: sessionType.icon, resourceScheme, authenticationPending: this.authenticationPending, logService: this._logService, - initialConfigValues: this._initialNewSessionConfig(), + initialConfigValues: this._initialNewSessionConfig(workspace), instantiationService: this._instantiationService, onSessionState: (id, state) => state === undefined ? this._handleNewSessionStateGone(id) @@ -1862,9 +2303,10 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // (AgentHostSessionHandler), so in the normal flow the folder is // already trusted here. This guards alternate entry points (e.g. // delegation). No-op for providers that don't require trust (remote). - if (newSession.requiresWorkspaceTrust) { + if (newSession.requiresWorkspaceTrust && newSession.workspaceUri) { + const workspaceUri = newSession.workspaceUri; void (async () => { - const { trusted } = await this._workspaceTrustManagementService.getUriTrustInfo(newSession.workspaceUri); + const { trusted } = await this._workspaceTrustManagementService.getUriTrustInfo(workspaceUri); // Bail if the draft was abandoned/replaced while we awaited // trust info (e.g. deleteNewSession, connection drop) — don't // spawn a backend session for a stale entry. @@ -1872,7 +2314,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return; } if (!trusted) { - this._logService.trace(`[${this.id}] Skipping eager createSession for untrusted folder ${newSession.workspaceUri.toString()}`); + this._logService.trace(`[${this.id}] Skipping eager createSession for untrusted folder ${workspaceUri.toString()}`); newSession.setLoading(false); return; } @@ -1934,8 +2376,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * (`chat.tools.global.autoApprove` policy value `false`), the approval seed * is clamped to `default` so the agent host never starts in an elevated * permission level the user is not allowed to pick. + * + * The user's `git.branchPrefix` setting (resource-scoped to the workspace's + * first folder) is seeded into the `worktreeBranchPrefix` slot so the agent + * host can prepend it to the branch it creates for an isolated worktree. */ - protected _initialNewSessionConfig(): Record<string, unknown> | undefined { + protected _initialNewSessionConfig(workspace?: ISessionWorkspace): Record<string, unknown> | undefined { const config = Object.create(null) as Record<string, unknown>; const policyRestricted = isAutoApprovePolicyRestricted(this._baseConfigurationService); @@ -1973,6 +2419,16 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement remembered[SessionConfigKey.Mode] = configuredMode; } + // Worktree branch prefix, forwarded from `git.branchPrefix`. Seeded + // here (rather than remembered) since it is derived from a setting, not + // a user pick; an empty value is omitted so the default branch naming + // is preserved. + const resource = workspace?.folders[0]?.root; + const branchPrefix = this._baseConfigurationService.getValue<string>('git.branchPrefix', { resource }); + if (typeof branchPrefix === 'string' && branchPrefix.length > 0) { + remembered[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; + } + return Object.keys(remembered).length > 0 ? remembered : undefined; } @@ -2173,6 +2629,14 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement return this._rootConfig; } + async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> { + const connection = this.connection; + if (!connection) { + return { authenticated: false }; + } + return connection.authenticate(params); + } + async setRootConfigValue(property: string, value: unknown): Promise<void> { const current = this._rootConfig; const connection = this.connection; @@ -2265,7 +2729,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement useGroupedModelPicker: true, showFeatured: true, showUnavailableFeatured: true, - showManageModelsAction: false, + showManageModelsAction: true, showAutoModel, }; } @@ -2353,10 +2817,11 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement ? c.children.filter(c => c.type === CustomizationType.McpServer) : []) .map((c): IAgentHostMcpServer => ({ - id: c.id, + id: `${sessionUri.authority}/${c.id}`, name: c.name, enabled: c.enabled, status: c.state.kind, + state: c.state, logOutputChannelId, setEnabled: (enabled: boolean) => { const connection = this.connection; @@ -2369,6 +2834,26 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement enabled, }); }, + start: async () => { + const connection = this.connection; + if (!connection) { + return; + } + connection.dispatch(sessionUri.toString(), { + type: ActionType.SessionMcpServerStartRequested, + id: c.id, + }); + }, + stop: async () => { + const connection = this.connection; + if (!connection) { + return; + } + connection.dispatch(sessionUri.toString(), { + type: ActionType.SessionMcpServerStopRequested, + id: c.id, + }); + }, })); } @@ -2549,7 +3034,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!rawId || !cached) { throw new Error(`Session '${chatId}' not found`); } - if (!cached.capabilities.supportsMultipleChats) { + if (!cached.capabilities.get().supportsMultipleChats) { throw new Error(`Session '${chatId}' does not support multiple chats`); } @@ -2592,7 +3077,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!rawId || !cached) { throw new Error(`Session '${sessionId}' not found`); } - if (!cached.capabilities.supportsMultipleChats) { + if (!cached.capabilities.get().supportsMultipleChats) { throw new Error(`Session '${sessionId}' does not support multiple chats`); } @@ -2799,6 +3284,9 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Treat that raw id as the session we are waiting for, not old state. const newSessionRawId = chatResource.path.replace(/^\//, ''); existingKeys.delete(newSessionRawId); + // Publish this send's own id so concurrent same-scheme sends don't + // latch onto it via their novelty fallback (which would swap sessions). + this._inFlightNewSessionOwnIds.add(newSessionRawId); const result = await this._chatService.sendRequest(chatResource, query, sendOptions); if (result.kind === 'rejected') { @@ -2811,15 +3299,30 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // Seed the title from the first line of the query so the new-session // tab shows something meaningful immediately. This skeleton is replaced // by the committed AgentHostSession once it arrives. - newSession.setTitle(query.split('\n')[0].substring(0, 100) || localize('new session', "New Session")); + newSession.setTitle(query.split('\n')[0].substring(0, 100) || newSession.untitledTitle); const skeleton = newSession.session; this._pendingSession = skeleton; this._onDidChangeSessions.fire({ added: [skeleton], removed: [], changed: [] }); + // Raw id claimed by _waitForNewSession for this send (released in finally). + let committedRawId: string | undefined; try { - const committedSession = await this._waitForNewSession(existingKeys, chatResource.scheme); + const committedSession = await this._waitForNewSession(existingKeys, chatResource.scheme, newSessionRawId); if (committedSession) { + committedRawId = committedSession.resource.path.substring(1); this._preserveNewSessionConfig(newSession, committedSession.sessionId); + // Carry the picked custom agent onto the committed session before + // the replace event so the agent picker doesn't reset to the + // default once the active session is swapped (the picker mirrors + // `session.mode`, which is otherwise `undefined` on the freshly + // committed adapter). The host already received the agent with the + // first turn (see `sendOptions.modeInfo`), so update only the local + // mode observable here rather than re-notifying it via `setAgent`. + if (selectedAgent) { + const committedRawIdForAgent = this._rawIdFromChatId(committedSession.sessionId); + const committedAdapter = committedRawIdForAgent ? this._sessionCache.get(committedRawIdForAgent) : undefined; + committedAdapter?.setChatAgent(committedAdapter.resource, selectedAgent); + } // Session graduated: release the eager subscription without // firing `disposeSession`. The session handler has already // acquired its own subscription (chat widget was opened @@ -2838,6 +3341,13 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement } catch { // Connection lost or timeout — fall through to the failure cleanup. } finally { + // Release the claim so unrelated future sends can match this + // session if needed; concurrent in-flight sends already captured + // their `existingKeys` and won't retroactively match it. + if (committedRawId !== undefined) { + this._committingSessionRawIds.delete(committedRawId); + } + this._inFlightNewSessionOwnIds.delete(newSessionRawId); // Defensive clear: covers the failure path where the try block // never reached the explicit clear above. this._pendingSession = undefined; @@ -2904,6 +3414,45 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement */ private static readonly SESSION_STATE_SUBSCRIPTION_IDLE_MS = 30_000; + /** + * Pin the state subscription of every currently-visible session (so + * host-driven catalog changes flow into `cached.chats` while it is on + * screen) and resume the idle-release timer for sessions that have left the + * viewport. Driven reactively by {@link ISessionsService.visibleSessions}. + */ + private _syncVisibleSessionStatePins(reader: IReader): void { + const visible = this._sessionsService.visibleSessions.read(reader); + const nowVisible = new Set<string>(); + for (const session of visible) { + if (!session) { + continue; + } + for (const cached of this._sessionCache.values()) { + if (isEqual(cached.resource, session.resource)) { + nowVisible.add(cached.sessionId); + break; + } + } + } + // Pin visible sessions: hold the subscription open, cancelling any pending + // idle release. All operations are idempotent, so re-running per tick also + // recovers a subscription that could not be created earlier (e.g. a remote + // provider that was momentarily disconnected). + for (const sessionId of nowVisible) { + this._pinnedSessionStates.add(sessionId); + this._ensureSessionStateSubscription(sessionId); + this._sessionStateIdleTimers.deleteAndDispose(sessionId); + } + // Unpin sessions that have left the viewport: resume the idle-release + // timer so the agent host can eventually evict their restored state. + for (const sessionId of [...this._pinnedSessionStates]) { + if (!nowVisible.has(sessionId)) { + this._pinnedSessionStates.delete(sessionId); + this._keepSessionStateAlive(sessionId); + } + } + } + /** * Bump the idle-release timer for `sessionId` and lazily create the * underlying subscription if needed. Called from query paths @@ -2916,6 +3465,12 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (!this._sessionStateSubscriptions.has(sessionId)) { return; } + // A visible session's subscription is pinned open; never arm the idle + // release while it is on screen. + if (this._pinnedSessionStates.has(sessionId)) { + this._sessionStateIdleTimers.deleteAndDispose(sessionId); + return; + } this._sessionStateIdleTimers.set( sessionId, disposableTimeout( @@ -2966,6 +3521,49 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (value && !(value instanceof Error)) { this._applySessionStateUpdate(sessionId, value); } + + this._hydrateAgentFromDraft(connection, cached, sessionId, sessionUri, store); + } + + /** + * Resume hydration: when a session is (re)loaded and its adapter has no agent + * selected, restore the persisted selection from the default chat's + * `ChatState.draft.agent` and mirror it onto `session.mode` (the picker's + * source of truth). + * + * The agent is persisted on the chat channel — the session channel + * ({@link SessionState}) carries no draft — so we briefly observe the default + * chat's state until its draft agent arrives. The subscription is shared and + * ref-counted with the chat session handler (no extra wire cost) and lives for + * the session-state store's lifetime. Hydration is one-shot: the observer + * stops as soon as `mode` is set — by us here, or by a concurrent graduation + * seed or user pick (guarded inside + * {@link AgentHostSessionAdapter.hydrateSelectedAgent}) — so it neither leaks, + * overrides a later selection, nor keeps re-running on every chat update. + */ + private _hydrateAgentFromDraft(connection: IAgentConnection, cached: AgentHostSessionAdapter, sessionId: string, sessionUri: URI, store: DisposableStore): void { + if (cached.mode.get() !== undefined) { + return; + } + const lastDefaultChat = this._lastSessionStates.get(sessionId)?.defaultChat; + const defaultChatUri = lastDefaultChat ? URI.parse(lastDefaultChat.toString()) : URI.parse(buildDefaultChatUri(sessionUri)); + const chatRef = connection.getSubscription(StateComponents.Chat, defaultChatUri, 'BaseAgentHostSessionsProvider.draftAgent'); + store.add(chatRef); + const listener = store.add(new MutableDisposable()); + const tryHydrate = () => { + if (cached.mode.get() === undefined) { + const chatState = chatRef.object.value; + const agentUri = chatState && !(chatState instanceof Error) ? chatState.draft?.agent?.uri : undefined; + if (agentUri) { + cached.hydrateSelectedAgent(agentUri); + } + } + if (cached.mode.get() !== undefined) { + listener.clear(); // hydration is one-shot; stop observing + } + }; + listener.value = chatRef.object.onDidChange(() => tryHydrate()); + tryHydrate(); } /** @@ -2981,12 +3579,65 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // change too — firing on all of them caused excessive picker // recomputes (and a feedback loop with `setAgent`). if (!previous || customizationsChanged(previous, state)) { + this._reconcileAgentFromState(sessionId, state); this._onDidChangeCustomAgents.fire(); this._onDidChangeCustomizations.fire(); } this._seedRunningConfigFromState(sessionId, state); this._applySessionMetaFromState(sessionId, state); this._applyChatCatalogFromState(sessionId, state); + + if (!previous) { + // This is the first time we've seen this session and the initial + // list of changesets are included in the state, so we use that to + // initialize the changeset catalogue.v Subsequent updates will be + // handled by handling the ActionType.SessionChangesetsChanged + // action. + this._applyChangesetsFromState(sessionId, state); + } + } + + /** + * Seed the cached adapter's changeset catalogue from an AHP + * {@link SessionState}. The catalogue otherwise only flows in via the live + * `SessionChangesetsChanged` action, which the host emits only when entries + * are added or removed. On restore (e.g. after a reload) nothing mutates, so + * that action never fires and the catalogue would stay empty. The restored + * `SessionState` snapshot carries the persisted `changesets`, so apply it + * here to surface the catalogue immediately. + */ + private _applyChangesetsFromState(sessionId: string, state: SessionState): void { + if (state.changesets === undefined) { + return; + } + const rawId = this._rawIdFromChatId(sessionId); + if (!rawId) { + return; + } + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + cached.updateChangesets(state.changesets); + } + + /** + * Rebase the cached running adapter's selected agent against the host's agent + * list from an AHP {@link SessionState}, before the picker is notified. A + * session that has moved into an isolated worktree keeps its selection instead + * of resetting to the default once the host starts reporting worktree-pathed + * agents. See {@link AgentHostSessionAdapter.reconcileSelectedAgent}. + */ + private _reconcileAgentFromState(sessionId: string, state: SessionState): void { + const rawId = this._rawIdFromChatId(sessionId); + if (!rawId) { + return; + } + const cached = this._sessionCache.get(rawId); + if (!cached) { + return; + } + cached.reconcileSelectedAgent(getEffectiveAgents(state.customizations)); } /** @@ -3104,6 +3755,87 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement // -- Session cache management -------------------------------------------- + /** + * Opt in to persisting {@link _sessionCache} snapshots under `storageKey`. + * Subclasses call this at the **end** of their constructor — once the + * identity fields that {@link createAdapter}/{@link resourceSchemeForProvider}/ + * {@link _adapterOptions} depend on are initialized — because the initial + * hydration builds adapters. This is why the base cannot auto-load in its + * own constructor. Persisted summaries are hydrated into {@link _sessionCache} + * immediately so {@link getSessions} returns them before the first + * `listSessions()` round-trip resolves. + */ + protected _enableSessionCachePersistence(storageKey: string): void { + this._sessionCacheStorageKey = storageKey; + this._loadCachedSessions(); + } + + /** + * Whether {@link _onDidChangeSessions} events should update the persistence + * bookkeeping ({@link _cacheDirty} + {@link _metaByRawId}). Default `true`; + * the remote provider overrides this to suspend tracking while its cached + * sessions are unpublished (offline), so the on-disk snapshot survives. + */ + protected _shouldTrackSessionCacheChanges(): boolean { + return true; + } + + /** Load persisted session summaries into {@link _sessionCache}. */ + private _loadCachedSessions(): void { + if (!this._sessionCacheStorageKey) { + return; + } + const parsed = this._storageService.getObject(this._sessionCacheStorageKey, StorageScope.APPLICATION); + if (!Array.isArray(parsed)) { + return; + } + for (const entry of parsed as readonly ISerializedSessionMetadata[]) { + const meta = deserializeMetadata(entry); + if (!meta) { + continue; + } + const rawId = AgentSession.id(meta.session); + if (this._sessionCache.has(rawId)) { + continue; + } + const cached = this.createAdapter(meta); + this._sessionCache.set(rawId, cached); + } + } + + /** + * Persist the current {@link _sessionCache} to storage, capping at + * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. + * Mutable fields are read from each adapter's observables and overlaid on + * top of the original metadata snapshot captured in {@link _metaByRawId}. + */ + private _persistCache(): void { + if (!this._sessionCacheStorageKey) { + return; + } + const entries: ISerializedSessionMetadata[] = []; + for (const [rawId, adapter] of this._sessionCache) { + const base = this._metaByRawId.get(rawId); + if (!base) { + continue; + } + entries.push(serializeMetadata({ + ...base, + summary: adapter.title.get() || base.summary, + modifiedTime: adapter.updatedAt.get().getTime(), + isRead: adapter.isRead.get(), + isArchived: adapter.isArchived.get(), + })); + } + if (entries.length === 0) { + this._storageService.remove(this._sessionCacheStorageKey, StorageScope.APPLICATION); + return; + } + entries.sort((a, b) => b.modifiedTime - a.modifiedTime); + const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); + this._storageService.store(this._sessionCacheStorageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); + } + protected _ensureSessionCache(): void { if (this._cacheInitialized) { return; @@ -3149,7 +3881,7 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement if (announceExistingAsAdded) { added.push(existing); } - if (existing.update(meta)) { + if (this.updateAdapter(existing, meta)) { changed.push(existing); } } else { @@ -3232,23 +3964,55 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement * type that happens to appear mid-send — a slow codex send racing against a * restored claude session, say — is never mistaken for this send's commit. */ - private async _waitForNewSession(existingKeys: Set<string>, expectedScheme: string): Promise<ISession | undefined> { + private async _waitForNewSession(existingKeys: Set<string>, expectedScheme: string, ownRawId: string): Promise<ISession | undefined> { + // A candidate backend session commits THIS send when it is unclaimed, + // of the expected type, and either (a) carries this send's own id — the + // eager/committed id is preserved, so this is the exact match — or + // (b) is a novel session that is not another in-flight send's own + // session (the novelty fallback covers backends that assign a fresh + // id, without letting two concurrent same-scheme sends swap sessions). + const matches = (rawId: string, scheme: string): boolean => { + if (scheme !== expectedScheme || this._committingSessionRawIds.has(rawId)) { + return false; + } + if (rawId === ownRawId) { + return true; + } + return !existingKeys.has(rawId) && !this._inFlightNewSessionOwnIds.has(rawId); + }; + await this._refreshSessions(); - for (const [key, cached] of this._sessionCache) { - if (!existingKeys.has(key) && cached.resource.scheme === expectedScheme) { - return cached; + // Prefer this send's own id; fall back to any acceptable novel session. + const scan = (): ISession | undefined => { + let fallback: ISession | undefined; + for (const cached of this._sessionCache.values()) { + const rawId = cached.resource.path.substring(1); + if (!matches(rawId, cached.resource.scheme)) { + continue; + } + if (rawId === ownRawId) { + return cached; + } + fallback ??= cached; } + return fallback; + }; + const immediate = scan(); + if (immediate) { + this._committingSessionRawIds.add(immediate.resource.path.substring(1)); + return immediate; } const waitDisposables = new DisposableStore(); try { const sessionPromise = new Promise<ISession | undefined>((resolve) => { waitDisposables.add(this._onDidChangeSessions.event(e => { - const newSession = e.added.find(s => { - const rawId = s.resource.path.substring(1); - return !existingKeys.has(rawId) && s.resource.scheme === expectedScheme; - }); + // Prefer this send's own id within the batch before falling + // back to an acceptable novel session. + const exact = e.added.find(s => s.resource.path.substring(1) === ownRawId && matches(ownRawId, s.resource.scheme)); + const newSession = exact ?? e.added.find(s => matches(s.resource.path.substring(1), s.resource.scheme)); if (newSession) { + this._committingSessionRawIds.add(newSession.resource.path.substring(1)); resolve(newSession); } })); @@ -3275,6 +4039,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement this._handleSessionRemoved(n.session); } else if (n.type === NotificationType.SessionSummaryChanged) { this._handleSessionSummaryChanged(n.session, n.changes); + } else if (n.type === NotificationType.Progress) { + this._downloadProgress.handleProgress(n); } })); @@ -3298,10 +4064,6 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionAdded(summary: SessionSummary): void { const sessionUri = URI.parse(summary.resource); const rawId = AgentSession.id(sessionUri); - if (this._sessionCache.has(rawId)) { - return; - } - const workingDir = typeof summary.workingDirectory === 'string' ? this.mapWorkingDirectoryUri(URI.parse(summary.workingDirectory)) : undefined; @@ -3321,7 +4083,20 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement workingDirectory: workingDir, changes: summary.changes, isArchived: !!(summary.status & ProtocolSessionStatus.IsArchived), + // Carry `_meta` so the adapter's session-kind (workspace-less vs. + // workspace) resolves correctly at construction — it is fixed once + // and cannot be flipped by a later `update`/`setMeta`. + ...(summary._meta !== undefined ? { _meta: summary._meta } : {}), }; + + const existing = this._sessionCache.get(rawId); + if (existing) { + if (this.updateAdapter(existing, meta)) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [existing] }); + } + return; + } + const cached = this.createAdapter(meta); this._sessionCache.set(rawId, cached); this._onDidChangeSessions.fire({ added: [cached], removed: [], changed: [] }); @@ -3447,8 +4222,8 @@ export abstract class BaseAgentHostSessionsProvider extends Disposable implement private _handleSessionMetaChanged(session: string, meta: Record<string, unknown> | undefined): void { const rawId = AgentSession.id(session); const cached = this._sessionCache.get(rawId); - if (cached) { - cached.setMeta(meta); + if (cached?.setMeta(meta)) { + this._onDidChangeSessions.fire({ added: [], removed: [], changed: [cached] }); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts index 60ddb06ca43120..3d5efaadfeb93d 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/exportDebugLogsAction.ts @@ -6,7 +6,7 @@ import { localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; @@ -30,7 +30,7 @@ export class ExportAgentHostDebugLogsAction extends Action2 { category: Categories.Developer, precondition: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.or(IsSessionsWindowContext, ContextKeyExpr.equals(`config.${AgentHostEnabledSettingId}`, true)), + ContextKeyExpr.or(IsSessionsWindowContext, AGENT_HOST_ENABLED_CONTEXT_KEY), ), }); } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts index e17f645e888fbd..7b7ff768d512de 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHost.contribution.ts @@ -4,8 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap } from '../../../../../base/common/lifecycle.js'; -import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { AgentHostContribution } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.js'; @@ -14,6 +12,7 @@ import { AgentHostTerminalContribution } from '../../../../../workbench/contrib/ import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { SessionStatus } from '../../../../services/sessions/common/session.js'; import { LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { localize } from '../../../../../nls.js'; @@ -27,7 +26,7 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis default: false, tags: ['experimental'], experiment: { mode: 'startup' }, - markdownDescription: localize('sessions.chat.agentHost.defaultSessionsProvider', "When enabled, the local agent host is used as the default sessions provider and its session types are shown first in the Agents window. Requires `#{0}#`.", AgentHostEnabledSettingId), + markdownDescription: localize('sessions.chat.agentHost.defaultSessionsProvider', "When enabled, the local agent host is used as the default sessions provider and its session types are shown first in the Agents window. Requires `#chat.agentHost.enabled#`."), }, }, }); @@ -47,14 +46,14 @@ class LocalAgentHostContribution extends Disposable implements IWorkbenchContrib static readonly ID = 'sessions.contrib.localAgentHostContribution'; constructor( - @IConfigurationService configurationService: IConfigurationService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, @IInstantiationService instantiationService: IInstantiationService, @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, @IAgentHostSessionWorkingDirectoryResolver workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, ) { super(); - if (!configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { + if (!this._agentHostEnablementService.enabled) { return; } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts index 8e4ecd200a8dc2..bf0ea11e392b73 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/localAgentHostSessionsProvider.ts @@ -27,6 +27,7 @@ import { IChatSessionsService } from '../../../../../workbench/contrib/chat/comm import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { IWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/common/environmentService.js'; import { LOCAL_AGENT_HOST_PROVIDER_ID, LocalAgentHostDefaultProviderSettingId } from '../../../../common/agentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '../../../../common/agentHostSessionWorkspace.js'; import { IGitHubInfo, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_LOCAL } from '../../../../services/sessions/common/session.js'; @@ -36,6 +37,13 @@ import { BaseAgentHostSessionsProvider } from './baseAgentHostSessionsProvider.j const LOCAL_RESOURCE_SCHEME_PREFIX = 'agent-host-'; +/** + * Storage key for the local agent host's cached session summaries. There is a + * single machine-wide local agent host, so a fixed key (no per-authority + * suffix) is used; the base provider persists under `StorageScope.APPLICATION`. + */ +const LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY = 'localAgentHost.cachedSessions'; + /** * Local-window sessions provider backed by the in-process * {@link IAgentHostService}. A thin subclass of @@ -52,6 +60,11 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; readonly supportsLocalWorkspaces = true; + /** Quick chats are only offered while the agent host is enabled. */ + get supportsQuickChats(): boolean { + return this._agentHostEnablementService.enabled; + } + /** `true` when running in the dedicated Agents window vs. a regular editor window. */ private readonly _isSessionsWindow: boolean; @@ -72,6 +85,7 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, @IChatSessionsService chatSessionsService: IChatSessionsService, @IChatService chatService: IChatService, @IChatWidgetService chatWidgetService: IChatWidgetService, @@ -96,6 +110,12 @@ export class LocalAgentHostSessionsProvider extends BaseAgentHostSessionsProvide this.browseActions = []; + // Hydrate previously-persisted session summaries so the sidebar shows + // local sessions immediately at startup, before the agent host has + // started and the first `listSessions()` round-trip (gated on + // authentication settling below) reconciles them. + this._enableSessionCachePersistence(LOCAL_AGENT_HOST_CACHED_SESSIONS_STORAGE_KEY); + this._attachConnectionListeners(this._agentHostService, this._store); const rootStateValue = this._agentHostService.rootState.value; diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css new file mode 100644 index 00000000000000..bd2e94abcf142d --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/media/openSubagentChat.css @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Styles for the "Open Subagent" pill contributed into the subagent block header + (`MenuId.ChatSubagentContent`, rendered by `OpenSubagentChatActionViewItem`). + The pill is a standalone chip that mirrors the chat file/diff pill + (`chat-codeblock-pill-widget`) — a colorless, bordered chip — instead of the + filled secondary button. `OpenSubagentChatActionViewItem` renders directly onto + its action-item container (`.action-item`), so styling targets that element. */ + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget { + display: inline-flex; + align-items: center; + gap: 4px; + box-sizing: border-box; + width: fit-content; + padding: 2px 3px; + border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + border-radius: var(--vscode-cornerRadius-small); + line-height: 1em; + font-size: var(--vscode-bodyFontSize-xSmall); + font-weight: var(--vscode-agents-fontWeight-regular); + color: var(--vscode-descriptionForeground); + background: none; + cursor: pointer; + white-space: nowrap; + text-decoration: none; +} + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget.focused { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +/* Progress: the leading conversation icon is swapped for a spinner while the + subagent is still running. Preferred signal is the pill's own + `chat-subagent-running` class, toggled from the resolved subagent chat's own + `SessionStatus.InProgress` status; the parent `.chat-subagent-part`'s + `chat-thinking-active` is kept as a fallback but stops early (the spawning tool + call completes as soon as the subagent is dispatched). Selectors are scoped + under the pill widget so they win over the actionbar's `.action-item .codicon` + sizing/visibility rules. */ +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .codicon { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 12px; + width: 12px; + height: 12px; +} + +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon { + display: none; +} + +.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget.chat-subagent-running .chat-subagent-pill-icon .chat-subagent-pill-spinner.codicon { + display: inline-flex; +} + +.monaco-workbench .chat-subagent-part.chat-thinking-active .chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget .chat-subagent-pill-icon .chat-subagent-pill-open-icon, +.chat-subagent-open-chat-toolbar .action-item.chat-subagent-pill-widget.chat-subagent-running .chat-subagent-pill-icon .chat-subagent-pill-open-icon { + display: none; +} + +/* Single chip: in the Agents window the subagent's title is shown inside the + pill, so hide the duplicate inline title text on the subagent block header (the + pill is only contributed in the Agents window, so this is scoped to + `.agent-sessions-workbench`). */ +.agent-sessions-workbench .chat-subagent-part.chat-subagent-has-chat .chat-used-context-label .monaco-button-mdlabel { + display: none; +} + +/* The subagent's agent name (e.g. "General-purpose", "Task") rendered before the + pill (outside the chip) within the subagent header toolbar, with a trailing + colon separating it from the pill. Muted so the pill title stays the focus. */ +.chat-subagent-open-chat-toolbar .chat-subagent-pill-prefix { + margin-right: 6px; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-s); + white-space: nowrap; + flex-shrink: 0; +} + +.chat-subagent-open-chat-toolbar .chat-subagent-pill-prefix::after { + content: ':'; +} + +.interactive-session .chat-used-context-label .monaco-button:hover .chat-subagent-open-chat-toolbar .chat-subagent-pill-prefix { + color: var(--vscode-foreground); +} diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts index c34857ed3943f3..9d1bdb2fa57ddf 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileAgentHostModePicker.ts @@ -34,24 +34,26 @@ export class MobileAgentHostModePicker extends AgentHostModePicker { super(session, actionWidgetService, sessionsProvidersService, telemetryService, hoverService); } - protected override _showPicker(): void { - if (!this._triggerElement) { - return; + protected override _showPicker(anchor = this._triggerElement, onHide?: () => void): boolean { + if (!anchor) { + return false; } // Guard applies to both the phone sheet and the desktop popover — // either path can dispatch through `setSessionConfigValue`. if (this._isCurrentlyResolvingConfig()) { - return; + return false; } if (this._phonePresenter.enabled.get()) { // The presenter's agent-host branch reads mode + model // directly from the active session's provider, so we don't // need to pass chat-input delegates here. - const trigger = this._triggerElement; - this._phonePresenter.showCombinedModeAndModelSheet(trigger, undefined, undefined) - .finally(() => trigger.focus()); - return; + this._phonePresenter.showCombinedModeAndModelSheet(anchor, undefined, undefined) + .finally(() => { + anchor.focus(); + onHide?.(); + }); + return true; } - super._showPicker(); + return super._showPicker(anchor, onHide); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts index f0170d5462612d..f255175b830896 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatInputConfigPicker.ts @@ -22,6 +22,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../../workbench/common/contributions.js'; import { type ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { IChatPhoneInputPresenter } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; +import { getModelProviderIcon } from '../../../../../../workbench/contrib/chat/browser/widget/input/modelProviderIcons.js'; import { Menus } from '../../../../../browser/menus.js'; import { SessionUsesCombinedConfigPickerContext, IsPhoneLayoutContext } from '../../../../../common/contextkeys.js'; import { type IAgentHostSessionsProvider, isAgentHostProvider, isAgentHostProviderId } from '../../../../../common/agentHostSessionsProvider.js'; @@ -264,6 +265,9 @@ class MobileChatInputConfigPicker extends Disposable { const currentModel = resolvedModelId ? ctx.modelItems.find(m => m.identifier === resolvedModelId) : undefined; + if (currentModel) { + dom.append(this._triggerElement, renderIcon(getModelProviderIcon(currentModel))); + } const labelText = currentModel?.metadata.name ?? localize('mobileChatInputConfigPicker.autoLabel', "Auto"); const labelSpan = dom.append(this._triggerElement, dom.$('span.chat-input-picker-label')); diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts index 7ee4331bf1f18d..09d22437c16e3f 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/mobile/mobileChatPhoneInputPresenter.ts @@ -19,6 +19,7 @@ import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../../../../../ import { IChatPhoneInputPresenter, IChatPhonePresenterImpl } from '../../../../../../workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.js'; import { IModePickerDelegate } from '../../../../../../workbench/contrib/chat/browser/widget/input/modePickerActionItem.js'; import { IModelPickerDelegate } from '../../../../../../workbench/contrib/chat/browser/widget/input/modelPickerActionItem.js'; +import { getModelProviderIcon } from '../../../../../../workbench/contrib/chat/browser/widget/input/modelProviderIcons.js'; import { IChatMode } from '../../../../../../workbench/contrib/chat/common/chatModes.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import { IWorkbenchLayoutService } from '../../../../../../workbench/services/layout/browser/layoutService.js'; @@ -170,6 +171,7 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres sheetItems.push({ id: registerAction({ kind: 'agentHostModel', model }), label: model.metadata.name, + icon: getModelProviderIcon(model), checked: model.identifier === currentModelId, sectionTitle: index === 0 ? localize('chatPhoneInput.modelSection', "Model") @@ -207,6 +209,7 @@ class MobileChatPhoneInputPresenter extends Disposable implements IChatPhonePres sheetItems.push({ id: registerAction({ kind: 'model', model }), label: model.metadata.name, + icon: getModelProviderIcon(model), checked: model.identifier === currentModel?.identifier, sectionTitle: index === 0 ? localize('chatPhoneInput.modelSection', "Model") diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts new file mode 100644 index 00000000000000..14003e82d5144e --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openSubagentChat.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/openSubagentChat.css'; +import { $ } from '../../../../../base/browser/dom.js'; +import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { IAction } from '../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, IReader } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { parseChatUri, parseSubagentSessionUri } from '../../../../../platform/agentHost/common/state/sessionState.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { IChat, SessionStatus } from '../../../../services/sessions/common/session.js'; + +// "Open Subagent" affordance for agent host worker (subagent) chats. +// +// The chat widget renders the inline subagent block in the lead chat's +// transcript and anchors the `MenuId.ChatSubagentContent` menu in its header, +// forwarding the subagent's chat resource (a URI string) as the action context. +// The widget is provider-agnostic and lives in a lower layer, so it cannot +// resolve that resource to an Agents-window tab itself. This action is +// contributed by the AGENT HOST PROVIDER — which owns the subagent chat URI +// format (see `parseChatUri`/`parseSubagentSessionUri`) — so parsing those URIs +// and mapping them to a surfaced peer chat is a natural provider concern. It is +// rendered as a pill and activates the matching tab in the active session. + +const OPEN_SUBAGENT_CHAT_ACTION_ID = 'workbench.action.chat.openAgentHostChat'; + +/** + * Recovers the surfaced peer chat's chatId (e.g. `subagent/<toolCallId>`) from a + * subagent's backend chat resource. The resource arrives in one of two canonical + * forms — a live AHP chat URI or a restored subagent session URI — each with its + * own inverse parser; the surfaced tab's chatId is `subagent/<toolCallId>` in + * both cases. + */ +function chatIdFromResource(resource: string): string | undefined { + // Live AHP chat URI: `ahp-chat://subagent/<base64-session>/<toolCallId>`. + const fromChatUri = parseChatUri(resource)?.chatId; + if (fromChatUri) { + return fromChatUri; + } + // Restored subagent session URI: `<scheme>:/<parentPath>/subagent/<toolCallId>`. + const fromSessionUri = parseSubagentSessionUri(resource); + return fromSessionUri ? `subagent/${fromSessionUri.toolCallId}` : undefined; +} + +function matchesResource(chat: IChat, resource: string, chatId: string | undefined): boolean { + if (chat.resource.toString() === resource) { + return true; + } + return !!chatId && chat.resource.fragment === chatId; +} + +/** + * The owning session's id path (e.g. `/<sessionId>`) for a subagent chat + * resource, recovered from whichever canonical form it takes. The UI session + * resource and the backend session URI differ only in scheme (e.g. + * `agent-host-copilotcli:/<id>` vs `copilotcli:/<id>`), so the path is a stable + * cross-form session key. Used to constrain matching to the owning session so a + * `subagent/<toolCallId>` chatId that collides across visible sessions can't + * open the wrong tab. + */ +function ownerSessionPath(resource: string): string | undefined { + const fromChatUri = parseChatUri(resource)?.session; + if (fromChatUri) { + try { + return URI.parse(fromChatUri).path; + } catch { + return undefined; + } + } + return parseSubagentSessionUri(resource)?.parentSession.path; +} + +/** + * Finds the surfaced peer chat (and its owning session) for a subagent chat + * resource across the active + visible sessions, constrained to the owning + * session when derivable so a `chatId` that collides across visible sessions + * can't match the wrong tab. Reactive when a {@link IReader} is provided. + */ +function findSubagentChat(sessionsService: ISessionsService, resource: string, reader: IReader | undefined): { readonly session: IActiveSession; readonly chat: IChat } | undefined { + const chatId = chatIdFromResource(resource); + const ownerPath = ownerSessionPath(resource); + const allSessions = [sessionsService.activeSession.read(reader), ...sessionsService.visibleSessions.read(reader)] + .filter((s): s is IActiveSession => !!s); + const candidates = ownerPath + ? allSessions.filter(s => s.resource.path === ownerPath) + : allSessions; + for (const session of candidates) { + const chat = session.chats.read(reader).find(c => matchesResource(c, resource, chatId)); + if (chat) { + return { session, chat }; + } + } + return undefined; +} + +/** + * Toolbar context forwarded by the subagent header (`ChatSubagentContentPart`): + * the subagent's chat resource plus its agent name (shown as the pill prefix). + * A bare resource string is also accepted for backwards compatibility. + */ +interface IOpenSubagentChatContext { + readonly chatResource: string; + readonly agentName?: string; +} + +function contextChatResource(context: unknown): string | undefined { + if (typeof context === 'string') { + return context; + } + if (context && typeof context === 'object' && typeof (context as IOpenSubagentChatContext).chatResource === 'string') { + return (context as IOpenSubagentChatContext).chatResource; + } + return undefined; +} + +function contextAgentName(context: unknown): string | undefined { + if (context && typeof context === 'object') { + const agentName = (context as IOpenSubagentChatContext).agentName; + return typeof agentName === 'string' && agentName ? agentName : undefined; + } + return undefined; +} + +class OpenSubagentChatAction extends Action2 { + constructor() { + super({ + id: OPEN_SUBAGENT_CHAT_ACTION_ID, + title: localize2('chat.subagent.openChat', "Open Subagent"), + icon: Codicon.commentDiscussion, + // Contextual: invoked from a specific subagent's header toolbar, which + // forwards that subagent's chat resource. Not a palette command. + f1: false, + menu: { id: MenuId.ChatSubagentContent, group: 'navigation' }, + }); + } + + override async run(accessor: ServicesAccessor, context?: string | IOpenSubagentChatContext): Promise<void> { + const resource = contextChatResource(context); + if (!resource) { + return; + } + const logService = accessor.get(ILogService); + const sessionsService = accessor.get(ISessionsService); + + // The pill is clicked from within the lead chat's transcript, so the + // subagent peer normally lives in the currently active session; the finder + // falls back to scanning all visible sessions in case the active slot + // differs. + const match = findSubagentChat(sessionsService, resource, undefined); + if (match) { + await sessionsService.openChat(match.session, match.chat.resource); + return; + } + + const active = sessionsService.activeSession.get(); + const available = active?.chats.get().map(c => c.resource.toString()).join(', ') ?? '(none)'; + logService.warn(`[Sessions] Cannot open subagent chat for resource '${resource}' (chatId='${chatIdFromResource(resource)}'). Available chats: ${available}`); + } +} +registerAction2(OpenSubagentChatAction); + +/** + * Renders the "Open Subagent" pill as a standalone chip (styled like the chat + * file/diff pill). See SESSIONS.md and `./media/openSubagentChat.css` for details. + */ +class OpenSubagentChatActionViewItem extends BaseActionViewItem { + + private _resolvedTitle: string | undefined; + private readonly _titleTracker = this._register(new MutableDisposable()); + private _prefixElement: HTMLElement | undefined; + private _labelElement: HTMLElement | undefined; + + constructor( + context: unknown, + action: IAction, + options: IActionViewItemOptions, + @ISessionsService private readonly sessionsService: ISessionsService, + ) { + super(context, action, options); + } + + override render(container: HTMLElement): void { + // Base render wires mouse click on the container; the actionbar wires + // keyboard (Enter/Space) via `doTrigger`, so both dispatch the action. + super.render(container); + container.classList.add('chat-subagent-pill-widget'); + // The ActionBar creates the `<li>` with role="presentation"; mark it as an + // actionable control for screen readers. + container.setAttribute('role', 'button'); + + const icon = $('span.chat-subagent-pill-icon'); + icon.appendChild($('span.chat-subagent-pill-spinner.codicon.codicon-loading.codicon-modifier-spin')); + icon.appendChild($(`span.chat-subagent-pill-open-icon${ThemeIcon.asCSSSelector(Codicon.commentDiscussion)}`)); + this._labelElement = $('span.chat-subagent-pill-label'); + container.append(icon, this._labelElement); + this._labelElement.textContent = this._labelText(); + + this._updatePrefix(); + this._updateTitleTracker(); + this.updateTooltip(); + this.updateEnabled(); + } + + override setActionContext(newContext: unknown): void { + super.setActionContext(newContext); + this._updatePrefix(); + this._updateTitleTracker(); + } + + /** + * Renders the pill prefix (the subagent's agent name, falling back to + * "Subagent") before the chip, within the enclosing toolbar container. + */ + private _updatePrefix(): void { + const toolbar = this.element?.closest('.chat-subagent-open-chat-toolbar'); + if (!toolbar) { + return; + } + if (!this._prefixElement) { + this._prefixElement = $('span.chat-subagent-pill-prefix'); + toolbar.insertBefore(this._prefixElement, toolbar.firstChild); + this._register(toDisposable(() => { + this._prefixElement?.remove(); + this._prefixElement = undefined; + })); + } + const agentName = contextAgentName(this._context); + this._prefixElement.textContent = agentName + ? agentName.charAt(0).toUpperCase() + agentName.slice(1) + : localize('chat.subagent.prefix', "Subagent"); + } + + /** + * Tracks the resolved subagent chat's title and running state. The pill's + * spinner reflects the subagent chat's **own** {@link SessionStatus.InProgress} + * status rather than the parent `.chat-subagent-part`'s `chat-thinking-active` + * class: the spawning tool call completes as soon as the subagent is + * dispatched, so that class stops early while the worker keeps running. + */ + private _updateTitleTracker(): void { + const resource = contextChatResource(this._context); + if (!resource) { + this._titleTracker.clear(); + this._setResolvedTitle(undefined); + this._setRunning(false); + return; + } + this._titleTracker.value = autorun(reader => { + const chat = findSubagentChat(this.sessionsService, resource, reader)?.chat; + this._setResolvedTitle(chat?.title.read(reader) || undefined); + this._setRunning(chat?.status.read(reader) === SessionStatus.InProgress); + }); + } + + private _setRunning(running: boolean): void { + this.element?.classList.toggle('chat-subagent-running', running); + } + + private _setResolvedTitle(title: string | undefined): void { + if (title !== this._resolvedTitle) { + this._resolvedTitle = title; + if (this._labelElement) { + this._labelElement.textContent = this._labelText(); + } + this.updateTooltip(); + } + } + + private _labelText(): string { + return this._resolvedTitle || this._action.label; + } + + protected override getTooltip(): string | undefined { + return this._action.tooltip || this._action.label || undefined; + } + + protected override updateEnabled(): void { + if (!this.element) { + return; + } + const enabled = this._action.enabled; + this.element.classList.toggle('disabled', !enabled); + this.element.setAttribute('aria-disabled', String(!enabled)); + } + + protected override updateAriaLabel(): void { + if (!this.element) { + return; + } + const ariaLabel = this._resolvedTitle + ? localize('chat.subagent.openChat.aria', "Open subagent chat: {0}", this._resolvedTitle) + : this.getTooltip(); + if (ariaLabel) { + this.element.setAttribute('aria-label', ariaLabel); + } else { + this.element.removeAttribute('aria-label'); + } + } +} + +/** + * Renders the "Open Subagent" action contributed into the subagent header + * ({@link MenuId.ChatSubagentContent}) as the same compact secondary-button pill + * used in the session header meta row. Registered for every agent host session + * (local and remote): the file is imported for side effect by both the desktop + * and web Agents-window entry points. Because it is contributed only in the + * Agents window, the regular chat view's subagent header stays empty. + */ +class OpenSubagentChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openSubagentChatActionViewItem'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + // The action view item service only notifies toolbars of a factory via + // the event passed to register(), not on registration itself. Announce + // the factory once right after registering so any subagent header toolbar + // created earlier re-renders and picks up the pill. + const onDidRegister = this._register(new Emitter<void>()); + this._register(actionViewItemService.register(MenuId.ChatSubagentContent, OPEN_SUBAGENT_CHAT_ACTION_ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(OpenSubagentChatActionViewItem, undefined, action, options); + }, onDidRegister.event)); + onDidRegister.fire(); + } +} +registerWorkbenchContribution2(OpenSubagentChatActionViewItemContribution.ID, OpenSubagentChatActionViewItemContribution, WorkbenchPhase.BlockRestore); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.ts new file mode 100644 index 00000000000000..593be893e452ea --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHost/agentHostSessionConfigPicker.test.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. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { isIMenuItem, MenuId, MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; +import { Menus } from '../../../../../../browser/menus.js'; + +import '../../../browser/agentHostSessionConfigPicker.js'; + +suite('Agent Host Session Config Picker', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('places mode immediately before approvals in secondary toolbars', () => { + const summarize = (menu: MenuId, ids: readonly string[]) => MenuRegistry.getMenuItems(menu) + .filter(isIMenuItem) + .filter(item => ids.includes(item.command.id)) + .map(item => ({ id: item.command.id, order: item.order })) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + + const newSessionIds = [ + 'sessions.agentHost.newSessionModePicker', + 'sessions.agentHost.newSessionApprovePicker', + 'sessions.agentHost.newSessionPermissionModePicker', + ]; + const runningSessionIds = [ + 'sessions.agentHost.runningSessionModePicker', + 'sessions.agentHost.runningSessionConfigPicker', + 'sessions.agentHost.runningSessionPermissionModePicker', + ]; + + assert.deepStrictEqual({ + newSessionPrimary: summarize(Menus.NewSessionConfig, newSessionIds), + newSessionSecondary: summarize(Menus.NewSessionControl, newSessionIds), + runningSessionPrimary: summarize(MenuId.ChatInput, runningSessionIds), + runningSessionSecondary: summarize(MenuId.ChatInputSecondary, runningSessionIds), + }, { + newSessionPrimary: [], + newSessionSecondary: [ + { id: 'sessions.agentHost.newSessionModePicker', order: 0 }, + { id: 'sessions.agentHost.newSessionApprovePicker', order: 1 }, + { id: 'sessions.agentHost.newSessionPermissionModePicker', order: 2 }, + ], + runningSessionPrimary: [], + runningSessionSecondary: [ + { id: 'sessions.agentHost.runningSessionModePicker', order: 9 }, + { id: 'sessions.agentHost.runningSessionConfigPicker', order: 10 }, + { id: 'sessions.agentHost.runningSessionPermissionModePicker', order: 11 }, + ], + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts new file mode 100644 index 00000000000000..7b940df991efcd --- /dev/null +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSessionFiles.test.ts @@ -0,0 +1,298 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { + FileEditKind, + ResponsePartKind, + ToolCallConfirmationReason, + ToolCallStatus, + ToolResultContentType, + type ResponsePart, +} from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionFileOperation } from '../../../../../services/sessions/common/session.js'; +import { + createIncrementalChatFileEditsParser, + IFileEditChatState, + IParsedFileEdit, + parseResponseParts, + reduceSessionFiles, + reduceTurnChanges, +} from '../../browser/agentHostSessionFiles.js'; + +// ── Protocol fixture helpers ──────────────────────────────────────────────── + +let seq = 0; + +function toolCallPart(toolCall: object): ResponsePart { + return { kind: ResponsePartKind.ToolCall, toolCall } as ResponsePart; +} + +function markdownPart(content: string): ResponsePart { + return { kind: ResponsePartKind.Markdown, id: `md-${seq++}`, content } as ResponsePart; +} + +/** A completed tool call carrying the given file-edit results. */ +function completedToolCallPart(content: object[]): ResponsePart { + return toolCallPart({ + status: ToolCallStatus.Completed, + toolCallId: `tc-${seq++}`, + toolName: 'editFile', + displayName: 'Edit File', + invocationMessage: 'Editing', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Edited', + content, + }); +} + +/** A tool call awaiting confirmation, carrying its planned edits. */ +function pendingConfirmationToolCallPart(items: object[]): ResponsePart { + return toolCallPart({ + status: ToolCallStatus.PendingConfirmation, + toolCallId: `tc-${seq++}`, + toolName: 'editFile', + displayName: 'Edit File', + invocationMessage: 'Editing', + edits: { items }, + }); +} + +function createEdit(uri: string, diff?: { added?: number; removed?: number }): object { + return { type: ToolResultContentType.FileEdit, after: { uri, content: { uri: `${uri}.after` } }, diff }; +} + +function editEdit(uri: string, diff?: { added?: number; removed?: number }): object { + return { + type: ToolResultContentType.FileEdit, + before: { uri, content: { uri: `${uri}.before` } }, + after: { uri, content: { uri: `${uri}.after` } }, + diff, + }; +} + +function deleteEdit(uri: string, diff?: { added?: number; removed?: number }): object { + return { type: ToolResultContentType.FileEdit, before: { uri, content: { uri: `${uri}.before` } }, diff }; +} + +function parsedEdit(kind: FileEditKind, uris: { after?: string; before?: string; beforeContent?: string }, diff?: { insertions?: number; deletions?: number }): IParsedFileEdit { + return { + kind, + afterUri: uris.after ? URI.file(uris.after) : undefined, + beforeUri: uris.before ? URI.file(uris.before) : undefined, + beforeContentUri: uris.beforeContent ? URI.file(uris.beforeContent) : undefined, + insertions: diff?.insertions ?? 0, + deletions: diff?.deletions ?? 0, + }; +} + +// ── Tests ─────────────────────────────────────────────────────────────────── + +suite('agentHostSessionFiles', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('incremental parser parses each completed turn once and re-parses only the active turn', () => { + // Count how many times each distinct responseParts array is parsed. + const parseCounts = new Map<ResponsePart[], number>(); + const countingParseTurn = (parts: ResponsePart[]): readonly IParsedFileEdit[] => { + parseCounts.set(parts, (parseCounts.get(parts) ?? 0) + 1); + return []; + }; + + const parse = createIncrementalChatFileEditsParser(undefined, countingParseTurn); + + // Each turn / active-turn snapshot gets a uniquely-identifiable array. + const t1Parts: ResponsePart[] = []; + const t2Parts: ResponsePart[] = []; + const active1Parts: ResponsePart[] = []; + const active2Parts: ResponsePart[] = []; + const active3Parts: ResponsePart[] = []; + + // 1) First completed turn arrives. + parse({ turns: [{ id: 't1', responseParts: t1Parts }] }); + // 2) A turn starts streaming (active). + parse({ turns: [{ id: 't1', responseParts: t1Parts }], activeTurn: { responseParts: active1Parts } }); + // 3) Same active turn streams another delta. + parse({ turns: [{ id: 't1', responseParts: t1Parts }], activeTurn: { responseParts: active2Parts } }); + // 4) Active turn finalizes into t2. + parse({ turns: [{ id: 't1', responseParts: t1Parts }, { id: 't2', responseParts: t2Parts }] }); + // 5) A new turn starts streaming. + parse({ + turns: [{ id: 't1', responseParts: t1Parts }, { id: 't2', responseParts: t2Parts }], + activeTurn: { responseParts: active3Parts }, + }); + + // Completed turns are parsed exactly once regardless of how many deltas + // followed; each active-turn snapshot is parsed exactly once. + assert.deepStrictEqual( + { + t1: parseCounts.get(t1Parts), + t2: parseCounts.get(t2Parts), + active1: parseCounts.get(active1Parts), + active2: parseCounts.get(active2Parts), + active3: parseCounts.get(active3Parts), + }, + { t1: 1, t2: 1, active1: 1, active2: 1, active3: 1 }, + ); + }); + + test('incremental parser keeps completed-turn edits while a new turn streams and tracks the last turn', () => { + const parse = createIncrementalChatFileEditsParser(); + + const t1Parts = [completedToolCallPart([createEdit('file:///a.txt')])]; + const completed: IFileEditChatState = { turns: [{ id: 't1', responseParts: t1Parts }] }; + + const first = parse(completed); + const streaming = parse({ + turns: [{ id: 't1', responseParts: t1Parts }], + activeTurn: { responseParts: [completedToolCallPart([createEdit('file:///b.txt')])] }, + }); + + assert.deepStrictEqual( + { + firstAll: first.allEdits.map(e => e.afterUri?.toString()), + firstLastTurn: first.lastTurnEdits.map(e => e.afterUri?.toString()), + streamingAll: streaming.allEdits.map(e => e.afterUri?.toString()), + streamingLastTurn: streaming.lastTurnEdits.map(e => e.afterUri?.toString()), + }, + { + // When idle, the last turn is the most recently completed turn. + firstAll: ['file:///a.txt'], + firstLastTurn: ['file:///a.txt'], + // While streaming, `allEdits` unions every turn but `lastTurnEdits` + // reflects only the in-progress turn. + streamingAll: ['file:///a.txt', 'file:///b.txt'], + streamingLastTurn: ['file:///b.txt'], + }, + ); + }); + + test('parseResponseParts extracts edits from completed and pending tool calls and ignores non-tool parts', () => { + const parts: ResponsePart[] = [ + markdownPart('hello'), + completedToolCallPart([createEdit('file:///created.txt'), editEdit('file:///edited.txt')]), + pendingConfirmationToolCallPart([deleteEdit('file:///deleted.txt')]), + ]; + + const parsed = parseResponseParts(parts); + + assert.deepStrictEqual( + parsed.map(e => ({ kind: e.kind, uri: (e.afterUri ?? e.beforeUri)?.toString() })), + [ + { kind: FileEditKind.Create, uri: 'file:///created.txt' }, + { kind: FileEditKind.Edit, uri: 'file:///edited.txt' }, + { kind: FileEditKind.Delete, uri: 'file:///deleted.txt' }, + ], + ); + }); + + test('reduceSessionFiles classifies operations and filters workspace files', () => { + const edits: IParsedFileEdit[] = [ + // created-then-edited outside workspace → Created + parsedEdit(FileEditKind.Create, { after: '/home/user/.config/app.json' }), + parsedEdit(FileEditKind.Edit, { after: '/home/user/.config/app.json', beforeContent: '/home/user/.config/app.json.before' }), + // edited outside workspace → Modified (keeps original for diff) + parsedEdit(FileEditKind.Edit, { after: '/home/user/.bashrc', beforeContent: '/home/user/.bashrc.before' }), + // deleted outside workspace → removed from the list entirely + parsedEdit(FileEditKind.Delete, { before: '/tmp/scratch.log', beforeContent: '/tmp/scratch.log.before' }), + // inside workspace → excluded + parsedEdit(FileEditKind.Create, { after: '/repo/src/index.ts' }), + ]; + + const files = reduceSessionFiles(edits, [URI.file('/repo')]); + + assert.deepStrictEqual( + files.map(f => ({ uri: f.uri.path, operation: f.operation, original: f.originalUri?.path })), + [ + { uri: '/home/user/.bashrc', operation: SessionFileOperation.Modified, original: '/home/user/.bashrc.before' }, + { uri: '/home/user/.config/app.json', operation: SessionFileOperation.Created, original: undefined }, + ], + ); + }); + + test('reduceSessionFiles reports a rename as a create of the target and drops the source', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Rename, { before: '/home/user/old.txt', after: '/home/user/new.txt', beforeContent: '/home/user/old.txt.before' }), + ]; + + const files = reduceSessionFiles(edits, [URI.file('/repo')]); + + assert.deepStrictEqual( + files.map(f => ({ uri: f.uri.path, operation: f.operation })), + [ + { uri: '/home/user/new.txt', operation: SessionFileOperation.Created }, + ], + ); + }); + + test('reduceSessionFiles drops a file that is created and then deleted', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Create, { after: '/home/user/scratch.tmp' }), + parsedEdit(FileEditKind.Delete, { before: '/home/user/scratch.tmp' }), + ]; + + const files = reduceSessionFiles(edits, [URI.file('/repo')]); + + assert.deepStrictEqual(files, []); + }); + + test('reduceTurnChanges collapses repeated edits per file and aggregates diff stats', () => { + const edits: IParsedFileEdit[] = [ + // created then edited → one created change, summed diffs, no original side + parsedEdit(FileEditKind.Create, { after: '/repo/new.ts' }, { insertions: 10 }), + parsedEdit(FileEditKind.Edit, { after: '/repo/new.ts', beforeContent: '/repo/new.ts.before' }, { insertions: 3, deletions: 1 }), + // pre-existing file edited twice → one modified change keeping the first original + parsedEdit(FileEditKind.Edit, { after: '/repo/existing.ts', beforeContent: '/repo/existing.ts.before' }, { insertions: 2, deletions: 4 }), + parsedEdit(FileEditKind.Edit, { after: '/repo/existing.ts', beforeContent: '/repo/existing.ts.before2' }, { insertions: 1 }), + // pre-existing file deleted → surfaced as a deletion (no modified side) + parsedEdit(FileEditKind.Delete, { before: '/repo/gone.ts', beforeContent: '/repo/gone.ts.before' }, { deletions: 8 }), + ]; + + const changes = reduceTurnChanges(edits).map(c => ({ + uri: c.uri.path, + modified: c.modifiedUri?.path, + original: c.originalUri?.path, + insertions: c.insertions, + deletions: c.deletions, + })); + + assert.deepStrictEqual(changes, [ + { uri: '/repo/new.ts', modified: '/repo/new.ts', original: undefined, insertions: 13, deletions: 1 }, + { uri: '/repo/existing.ts', modified: '/repo/existing.ts', original: '/repo/existing.ts.before', insertions: 3, deletions: 4 }, + { uri: '/repo/gone.ts', modified: undefined, original: '/repo/gone.ts.before', insertions: 0, deletions: 8 }, + ]); + }); + + test('reduceTurnChanges nets out a file created and then deleted in the same turn', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Create, { after: '/repo/scratch.tmp' }, { insertions: 5 }), + parsedEdit(FileEditKind.Delete, { before: '/repo/scratch.tmp' }), + ]; + + assert.deepStrictEqual(reduceTurnChanges(edits), []); + }); + + test('reduceTurnChanges reports a rename as an edit of the target and drops the source', () => { + const edits: IParsedFileEdit[] = [ + parsedEdit(FileEditKind.Rename, { before: '/repo/old.ts', after: '/repo/renamed.ts', beforeContent: '/repo/old.ts.before' }, { insertions: 1, deletions: 2 }), + ]; + + const changes = reduceTurnChanges(edits).map(c => ({ + uri: c.uri.path, + modified: c.modifiedUri?.path, + original: c.originalUri?.path, + insertions: c.insertions, + deletions: c.deletions, + })); + + assert.deepStrictEqual(changes, [ + { uri: '/repo/renamed.ts', modified: '/repo/renamed.ts', original: '/repo/old.ts.before', insertions: 1, deletions: 2 }, + ]); + }); +}); diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts index ce455187c6e789..ac9e8b5ad56bc2 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/agentHostSkillButtons.test.ts @@ -14,7 +14,7 @@ import { CommandsRegistry } from '../../../../../../platform/commands/common/com import { ContextKeyExpression, 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 { IChat } from '../../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat } from '../../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; @@ -37,6 +37,7 @@ function makeActiveSession(providerId: string): IActiveSession { mode: observableValue('mo', undefined), isArchived: observableValue('ia', false), isRead: observableValue('ir', true), + interactivity: observableValue('ii', ChatInteractivity.Full), checkpoints: observableValue('cp', undefined), lastTurnEnd: observableValue('lte', undefined), description: observableValue('d', undefined), @@ -64,11 +65,14 @@ function makeActiveSession(providerId: string): IActiveSession { chats: observableValue('chats', [chat]), activeChat: observableValue('ac', chat), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), isCreated: observableValue('isCreated', true), sticky: observableValue('sticky', false), openChats: observableValue('openChats', [chat]), closedChats: constObservable([]), + lastClosedChat: undefined, + visibleChatTabs: constObservable([chat]), + shouldShowChatTabs: constObservable(false), } satisfies IActiveSession; } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts index aa9f73b4dc2092..f0270c031a14bd 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/localAgentHostSessionsProvider.test.ts @@ -9,14 +9,15 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, ImmortalReference, toDisposable, type IReference } from '../../../../../../base/common/lifecycle.js'; import { autorun, constObservable, ISettableObservable, observableValue, type IObservable } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSession, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId, IAgentHostService, type IAgentCreateChatOptions, type IAgentCreateSessionConfig, type IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ResolveSessionConfigResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { CustomizationLoadStatus, CustomizationType, MessageKind, SessionLifecycle, type AgentInfo, type ChangesSummary, type Customization, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { buildChatUri, buildDefaultChatUri, ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, type ChangesetState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInteractivity as ProtocolChatInteractivity, ChatOriginKind as ProtocolChatOriginKind, CustomizationLoadStatus, CustomizationType, McpServerStatus, MessageKind, SessionLifecycle, type AgentInfo, type ChangesSummary, type Customization, type RootState, type SessionConfigState, type SessionState, type SessionSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { buildChatUri, buildDefaultChatUri, buildSubagentChatUri, ChangesetStatus, SessionStatus as ProtocolSessionStatus, StateComponents, withSessionWorkspaceless, type ChangesetState, type ChatState, type ChatSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ActionType, NotificationType, type ActionEnvelope, type IRootConfigChangedAction, type ChatAction, type SessionAction, type TerminalAction, type INotification, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -25,6 +26,7 @@ import { IDialogService, IFileDialogService } from '../../../../../../platform/d import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatModelReference, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -33,25 +35,26 @@ import { ChatModeKind } from '../../../../../../workbench/contrib/chat/common/co import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../../../../workbench/contrib/chat/common/languageModels.js'; import type { IChatModel, IChatModelInputState, IInputModel } from '../../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../../../services/sessions/browser/sessionsService.js'; import { IAgentHostActiveClientService } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { LocalAgentHostSessionsProvider } from '../../browser/localAgentHostSessionsProvider.js'; import { AgentHostSessionAdapter } from '../../browser/baseAgentHostSessionsProvider.js'; -import { CHANGESET_UPDATE_THROTTLE_MS } from '../../browser/agentHostChangesetConstants.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IGitHubService } from '../../../../github/browser/githubService.js'; import { GitHubPullRequestModel } from '../../../../github/browser/models/githubPullRequestModel.js'; import { IPullRequestIconCache, PullRequestIconCache } from '../../../../github/browser/pullRequestIconCache.js'; +import { computePullRequestIcon, GitHubPullRequestState } from '../../../../github/common/types.js'; import { IWorkbenchEnvironmentService } from '../../../../../../workbench/services/environment/common/environmentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; // ---- Mock IAgentHostService ------------------------------------------------- const STORAGE_KEY_REMEMBERED_SESSION_CONFIG_VALUES = 'sessions.agentHost.sessionConfigPicker.selectedValues'; -type SubscriptionState = SessionState | ChangesetState; +type SubscriptionState = SessionState | ChangesetState | ChatState; class MockAgentHostService extends mock<IAgentHostService>() { declare readonly _serviceBrand: undefined; @@ -61,7 +64,7 @@ class MockAgentHostService extends mock<IAgentHostService>() { private readonly _onDidNotification = new Emitter<INotification>(); override readonly onDidNotification = this._onDidNotification.event; private readonly _onDidRootStateChange = new Emitter<RootState>(); - private _rootStateValue: RootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo] }; + private _rootStateValue: RootState | Error | undefined = { agents: [{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo] }; override readonly rootState: IAgentSubscription<RootState>; override readonly clientId = 'test-local-client'; @@ -143,7 +146,7 @@ class MockAgentHostService extends mock<IAgentHostService>() { } public createdSessionUris: URI[] = []; - public createSessionConfigs: { config?: Record<string, unknown> }[] = []; + public createSessionConfigs: { config?: Record<string, unknown>; workingDirectory?: URI }[] = []; /** * Per-call hook used by tests to interleave operations across the * `createSession` await — e.g. to verify that no subscription is opened @@ -159,7 +162,7 @@ class MockAgentHostService extends mock<IAgentHostService>() { public wireOps: string[] = []; override async createSession(config?: IAgentCreateSessionConfig): Promise<URI> { const uri = config?.session ?? URI.parse('copilotcli:///auto-' + this._nextSeq); - this.createSessionConfigs.push({ config: config?.config }); + this.createSessionConfigs.push({ config: config?.config, workingDirectory: config?.workingDirectory }); this.wireOps.push(`createSession:${uri.toString()}`); this.createdSessionUris.push(uri); const hook = this.onCreateSession; @@ -235,6 +238,11 @@ class MockAgentHostService extends mock<IAgentHostService>() { this._sessionStateEmitters.get(changesetUri)?.fire(state); } + setChatState(chatUri: string, state: ChatState): void { + this._sessionStateValues.set(chatUri, state); + this._sessionStateEmitters.get(chatUri)?.fire(state); + } + setAgents(agents: AgentInfo[]): void { this._rootStateValue = { agents }; this._onDidRootStateChange.fire(this._rootStateValue); @@ -283,7 +291,7 @@ class MockAgentHostService extends mock<IAgentHostService>() { // ---- Test helpers ----------------------------------------------------------- -function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number }): IAgentSessionMetadata { +function createSession(id: string, opts?: { provider?: string; summary?: string; project?: { uri: URI; displayName: string }; workingDirectory?: URI; startTime?: number; modifiedTime?: number; quickChat?: boolean }): IAgentSessionMetadata { return { session: AgentSession.uri(opts?.provider ?? 'copilotcli', id), startTime: opts?.startTime ?? 1000, @@ -291,6 +299,7 @@ function createSession(id: string, opts?: { provider?: string; summary?: string; summary: opts?.summary, project: opts?.project, workingDirectory: opts?.workingDirectory, + _meta: opts?.quickChat ? withSessionWorkspaceless(undefined, true) : undefined, }; } @@ -308,11 +317,13 @@ function createPolicyRestrictedConfigurationService(): TestConfigurationService function createProvider(disposables: DisposableStore, agentHostService: MockAgentHostService, contributions = [ { type: 'agent-host-copilotcli', name: 'copilot', displayName: 'Copilot', description: 'test', icon: undefined }, -], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; gitHubService?: IGitHubService }): LocalAgentHostSessionsProvider { +], options?: { sendRequest?: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>; acquireOrLoadSession?: (resource: URI) => Promise<IChatModelReference | undefined>; lookupLanguageModel?: (modelId: string) => ILanguageModelChatMetadata | undefined; openSession?: boolean; configurationService?: IConfigurationService; activeSession?: IObservable<IActiveSession | undefined>; visibleSessions?: IObservable<readonly (IActiveSession | undefined)[]>; storageService?: IStorageService; isSessionsWindow?: boolean; confirmDelete?: boolean; workspaceTrusted?: boolean; gitHubService?: IGitHubService; agentHostEnabled?: boolean }): LocalAgentHostSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); instantiationService.stub(IAgentHostService, agentHostService); - instantiationService.stub(IConfigurationService, options?.configurationService ?? new TestConfigurationService()); + const configurationService = options?.configurationService ?? new TestConfigurationService(); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: options?.agentHostEnabled ?? true }); instantiationService.stub(IWorkspaceTrustManagementService, new class extends mock<IWorkspaceTrustManagementService>() { override isWorkspaceTrusted(): boolean { return options?.workspaceTrusted ?? true; } override async getUriTrustInfo(uri: URI) { return { uri, trusted: options?.workspaceTrusted ?? true }; } @@ -340,13 +351,16 @@ function createProvider(disposables: DisposableStore, agentHostService: MockAgen }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IStorageService, options?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(IGitHubService, options?.gitHubService ?? new class extends mock<IGitHubService>() { override findPullRequestNumberByHeadBranch = async () => undefined; }()); instantiationService.stub(IPullRequestIconCache, instantiationService.createInstance(PullRequestIconCache)); const activeSessionObs = options?.activeSession ?? constObservable<IActiveSession | undefined>(undefined); + const visibleSessionsObs = options?.visibleSessions ?? constObservable<readonly (IActiveSession | undefined)[]>([]); instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() { override readonly activeSession: IObservable<IActiveSession | undefined> = activeSessionObs; + override readonly visibleSessions: IObservable<readonly (IActiveSession | undefined)[]> = visibleSessionsObs; }()); instantiationService.stub(IAgentHostActiveClientService, new class extends mock<IAgentHostActiveClientService>() { override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); @@ -384,7 +398,7 @@ async function waitForSessionConfig(provider: LocalAgentHostSessionsProvider, se }); } -function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary }): void { +function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; changes?: ChangesSummary; workspaceless?: boolean; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); agentHost.fireNotification({ @@ -395,12 +409,25 @@ function fireSessionAdded(agentHost: MockAgentHostService, rawId: string, opts?: provider, title: opts?.title ?? `Session ${rawId}`, status: ProtocolSessionStatus.Idle, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), + createdAt: opts?.createdAt ?? new Date().toISOString(), + modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, workingDirectory: opts?.workingDirectory, changes: opts?.changes, + ...(opts?.workspaceless ? { _meta: withSessionWorkspaceless(undefined, true) } : {}), + }, + }); +} + +function fireSessionMetaChanged(agentHost: MockAgentHostService, rawId: string, meta: Record<string, unknown> | undefined, provider = 'copilotcli'): void { + agentHost.fireAction({ + channel: AgentSession.uri(provider, rawId).toString(), + action: { + type: ActionType.SessionMetaChanged, + _meta: meta, }, + serverSeq: 1, + origin: undefined, }); } @@ -423,6 +450,25 @@ function fireSessionSummaryChanged(agentHost: MockAgentHostService, rawId: strin }); } +/** + * Seed `storageService` with persisted session summaries by running a throwaway + * provider over a fresh agent host that lists `sessions`, then flushing so the + * base provider's `onWillSaveState` writes the cache to storage. Used to + * simulate what a previous window left behind for the next launch to hydrate. + */ +async function persistCachedSessions(disposables: DisposableStore, storageService: IStorageService, sessions: IAgentSessionMetadata[]): Promise<void> { + const host = new MockAgentHostService(); + disposables.add(toDisposable(() => host.dispose())); + for (const session of sessions) { + host.addSession(session); + } + createProvider(disposables, host, undefined, { storageService }); + // Let the eager refresh pick up the sessions (marking the cache dirty) then + // flush so the cache is persisted. + await timeout(0); + await storageService.flush(); +} + suite('LocalAgentHostSessionsProvider', () => { const disposables = new DisposableStore(); let agentHost: MockAgentHostService; @@ -742,13 +788,14 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(changes[0].removed.length, 1); }); - test('duplicate session added notification is ignored', () => { + test('identical session added notification is ignored', () => { const provider = createProvider(disposables, agentHost); const changes: ISessionChangeEvent[] = []; disposables.add(provider.onDidChangeSessions(e => changes.push(e))); - fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); - fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup' }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); + fireSessionAdded(agentHost, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); assert.strictEqual(changes.length, 1); }); @@ -765,6 +812,93 @@ suite('LocalAgentHostSessionsProvider', () => { // ---- Session listing via refresh ------- + test('session added authoritatively updates a listed session in place', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const originalProject = URI.parse('file:///Users/me/project'); + const originalWorkingDirectory = URI.parse('file:///Users/me/project'); + agentHost.addSession(createSession('worktree-upsert', { + summary: 'Worktree Session', + project: { uri: originalProject, displayName: 'project' }, + workingDirectory: originalWorkingDirectory, + modifiedTime: 1000, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]!; + const originalWorkspace = session.workspace.get()!; + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + + const worktreeProject = 'file:///Users/me/project.worktrees/session'; + const worktreeWorkingDirectory = 'file:///Users/me/project.worktrees/session/src'; + fireSessionAdded(agentHost, 'worktree-upsert', { + title: 'Worktree Session', + project: { uri: worktreeProject, displayName: 'project-worktree' }, + workingDirectory: worktreeWorkingDirectory, + createdAt: new Date(1000).toISOString(), + modifiedAt: new Date(2000).toISOString(), + }); + fireSessionSummaryChanged(agentHost, 'worktree-upsert', { + _meta: { git: { branchName: 'agents/worktree-session', baseBranchName: 'main' } }, + }); + + const current = provider.getSessions()[0]!; + const currentWorkspace = current.workspace.get()!; + assert.deepStrictEqual({ + sameAdapter: current === session, + originalWorkingDirectory: originalWorkspace.folders[0].workingDirectory.toString(), + workingDirectory: currentWorkspace.folders[0].workingDirectory.toString(), + branchName: currentWorkspace.folders[0].gitRepository?.branchName, + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + sameAdapter: true, + originalWorkingDirectory: originalWorkingDirectory.toString(), + workingDirectory: worktreeWorkingDirectory, + branchName: 'agents/worktree-session', + changedEvents: [[true], [true]], + }); + })); + + test('session metadata changes notify when observable git fields change', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + agentHost.addSession(createSession('git-meta', { + summary: 'Git Session', + project: { uri: URI.parse('file:///Users/me/project'), displayName: 'project' }, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]!; + const changes: ISessionChangeEvent[] = []; + disposables.add(provider.onDidChangeSessions(e => changes.push(e))); + const meta = { + git: { + branchName: 'feature/worktree', + baseBranchName: 'main', + hasGitHubRemote: true, + upstreamBranchName: 'origin/feature/worktree', + incomingChanges: 2, + outgoingChanges: 3, + uncommittedChanges: 4, + }, + }; + + fireSessionMetaChanged(agentHost, 'git-meta', meta); + fireSessionMetaChanged(agentHost, 'git-meta', meta); + + const gitRepository = session.workspace.get()!.folders[0].gitRepository!; + assert.deepStrictEqual({ + branchName: gitRepository.branchName, + uncommittedChanges: gitRepository.uncommittedChanges, + changedEvents: changes.map(change => change.changed.map(changed => changed === session)), + }, { + branchName: 'feature/worktree', + uncommittedChanges: 4, + changedEvents: [[true]], + }); + })); + test('getSessions populates from listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { agentHost.addSession(createSession('list-1', { summary: 'First' })); agentHost.addSession(createSession('list-2', { summary: 'Second' })); @@ -921,6 +1055,99 @@ suite('LocalAgentHostSessionsProvider', () => { }); })); + // ---- Startup session cache (persistence) ------- + + test('hydrates persisted sessions on startup before the live list is available', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('cached-1', { summary: 'Cached One' })]); + + // Fresh launch: authentication is still pending so the eager refresh is + // deferred, yet the persisted session must surface immediately. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + assert.deepStrictEqual({ + listSessionsCalls: nextHost.listSessionsCallCount, + cachedTitles: provider.getSessions().map(s => s.title.get()), + }, { + listSessionsCalls: 0, + cachedTitles: ['Cached One'], + }); + })); + + test('hydrated quick chat stays workspace-less after reload despite a scratch working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // Regression #324581: a committed quick chat persisted into the startup + // cache carries a scratch cwd. The adapter's session-kind is fixed at + // construction from `_meta.workspaceless`, so the tag must survive the + // serialize/deserialize round-trip — otherwise the restored session + // leaks the scratch dir as a workspace folder. + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [ + createSession('quick-cached', { + summary: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-cached'), + quickChat: true, + }), + ]); + + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.setAuthenticationPending(true); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === 'quick-cached'); + assert.deepStrictEqual({ + workspace: session?.workspace.get(), + isQuickChat: session?.isQuickChat?.get(), + }, { + workspace: undefined, + isQuickChat: true, + }); + })); + + test('reconciles hydrated sessions against the authoritative list, pruning stale entries', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('stale-1', { summary: 'Stale' })]); + + // Fresh launch with an authoritative (empty) list: the hydrated session + // shows immediately, then is pruned once the first refresh succeeds. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + const beforeRefresh = provider.getSessions().map(s => s.title.get()); + await timeout(0); + const afterRefresh = provider.getSessions().map(s => s.title.get()); + + assert.deepStrictEqual({ beforeRefresh, afterRefresh }, { beforeRefresh: ['Stale'], afterRefresh: [] }); + })); + + test('hydrated sessions survive a failed initial listSessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + await persistCachedSessions(disposables, storageService, [createSession('resilient-1', { summary: 'Resilient' })]); + + // Fresh launch where the first listSessions() throws (e.g. + // AHP_AUTH_REQUIRED before the token is effective). Without caching the + // list would be empty until the retry heals; the persisted session must + // stay visible throughout. + const nextHost = new MockAgentHostService(); + disposables.add(toDisposable(() => nextHost.dispose())); + nextHost.failListSessionsCount = 1; + nextHost.addSession(createSession('resilient-1', { summary: 'Resilient' })); + const provider = createProvider(disposables, nextHost, undefined, { storageService }); + + await timeout(0); + const afterFailedList = provider.getSessions().map(s => s.title.get()); + + // The backoff retry (min 1s) heals; the session remains listed. + await timeout(1_100); + const afterRetry = provider.getSessions().map(s => s.title.get()); + + assert.deepStrictEqual({ afterFailedList, afterRetry }, { afterFailedList: ['Resilient'], afterRetry: ['Resilient'] }); + })); + test('uses project metadata as workspace group source', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const projectUri = URI.file('/home/user/vscode'); const workingDirectory = URI.file('/tmp/copilot-worktrees/vscode-feature'); @@ -1022,6 +1249,150 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(agentHost.dispatchedActions, []); }); + test('restores the selected agent from the default chat draft on resume', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'resume-agent', { title: 'Resume Agent Session' }); + + const session = provider.getSessions().find(s => s.title.get() === 'Resume Agent Session'); + assert.ok(session); + assert.strictEqual(session!.mode.get(), undefined); + + // `getSessionConfig` opens the session-state subscription, which also opens + // the default chat subscription used to read the persisted draft agent. + provider.getSessionConfig(session!.sessionId); + + const defaultChatUri = buildDefaultChatUri(AgentSession.uri('copilotcli', 'resume-agent')); + agentHost.setChatState(defaultChatUri, { + resource: defaultChatUri, + title: 'Resume Agent Session', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date(0).toISOString(), + turns: [], + draft: { text: '', origin: { kind: MessageKind.User }, agent: { uri: 'agent://resumed' } }, + }); + + assert.deepStrictEqual(session!.mode.get(), { id: 'agent://resumed', kind: 'agent' }); + }); + + test('does not override a live agent selection with the persisted draft agent', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'resume-nooverride', { title: 'Resume No Override' }); + + const session = provider.getSessions().find(s => s.title.get() === 'Resume No Override'); + assert.ok(session); + + // A live pick wins; a later draft snapshot must not clobber it. + provider.setAgent?.(session!.sessionId, { uri: 'agent://live', name: 'live' }); + provider.getSessionConfig(session!.sessionId); + + const defaultChatUri = buildDefaultChatUri(AgentSession.uri('copilotcli', 'resume-nooverride')); + agentHost.setChatState(defaultChatUri, { + resource: defaultChatUri, + title: 'Resume No Override', + status: ProtocolSessionStatus.Idle, + modifiedAt: new Date(0).toISOString(), + turns: [], + draft: { text: '', origin: { kind: MessageKind.User }, agent: { uri: 'agent://resumed' } }, + }); + + assert.deepStrictEqual(session!.mode.get(), { id: 'agent://live', kind: 'agent' }); + }); + + test('rebases the selected agent to its worktree twin from the agent list before the working directory flips', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'rebase-worktree', { title: 'Rebase Worktree', workingDirectory: 'file:///Users/me/vscode' }); + + const session = provider.getSessions().find(s => s.title.get() === 'Rebase Worktree'); + assert.ok(session); + + // A folder agent is picked while the session still runs in the repo. + const folderAgent = 'file:///Users/me/vscode/.github/agents/sessions.md'; + const worktreeAgent = 'file:///Users/me/vscode.worktrees/rebase-worktree/.github/agents/sessions.md'; + provider.setAgent?.(session!.sessionId, { uri: folderAgent, name: 'sessions' }); + + // The host reports the worktree-pathed agents (the folder twin is gone) + // well before the working directory flips to the worktree. The rebase + // must derive the worktree root from the agent list, not the (still + // folder) working directory, so the selection is re-pointed in time. + provider.getSessionConfig(session!.sessionId); + agentHost.setSessionState('rebase-worktree', 'copilotcli', { + provider: 'copilotcli', + title: 'Rebase Worktree', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + customizations: [{ + type: CustomizationType.Plugin, + id: 'plugin://worktree', + uri: 'plugin://worktree', + name: 'worktree plugin', + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: worktreeAgent, uri: worktreeAgent, name: 'sessions' }], + }], + }); + + assert.deepStrictEqual(session!.mode.get(), { id: worktreeAgent, kind: 'agent' }); + }); + + test('leaves the selected agent untouched when the agent list has no relocated twin', () => { + const provider = createProvider(disposables, agentHost); + fireSessionAdded(agentHost, 'rebase-none', { title: 'Rebase None', workingDirectory: 'file:///Users/me/vscode' }); + + const session = provider.getSessions().find(s => s.title.get() === 'Rebase None'); + assert.ok(session); + + const folderAgent = 'file:///Users/me/vscode/.github/agents/sessions.md'; + provider.setAgent?.(session!.sessionId, { uri: folderAgent, name: 'sessions' }); + + // An unrelated agent (different repo-relative file) must not be treated + // as a relocation of the selection. + provider.getSessionConfig(session!.sessionId); + agentHost.setSessionState('rebase-none', 'copilotcli', { + provider: 'copilotcli', + title: 'Rebase None', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + customizations: [{ + type: CustomizationType.Plugin, + id: 'plugin://other', + uri: 'plugin://other', + name: 'other plugin', + enabled: true, + load: { kind: CustomizationLoadStatus.Loaded }, + children: [{ type: CustomizationType.Agent, id: 'file:///Users/me/vscode.worktrees/rebase-none/.github/agents/other.md', uri: 'file:///Users/me/vscode.worktrees/rebase-none/.github/agents/other.md', name: 'other' }], + }], + }); + + assert.deepStrictEqual(session!.mode.get(), { id: folderAgent, kind: 'agent' }); + }); + + test('carries the picked custom agent onto the committed session when a new session graduates', async () => { + // Part 1 regression: when a new (untitled) session graduates into a real + // running session on first send, the picked agent must travel onto the + // committed session's `mode`. Otherwise the picker — which mirrors + // `session.mode` — resets to the default the moment the active session is + // swapped for the freshly committed one. + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + sendRequest: async (): Promise<ChatSendResult> => { + agentHost.addSession(createSession('graduated', { summary: 'Graduated Session' })); + return { kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }; + }, + }); + + const session = provider.createNewSession(URI.parse('file:///home/user/project'), provider.sessionTypes[0].id); + provider.setAgent?.(session.sessionId, { uri: 'agent://picked', name: 'picked' }); + + const chat = await provider.createNewChat(session.sessionId); + const committed = await provider.sendRequest(session.sessionId, chat.resource, { query: 'hello' }); + + assert.deepStrictEqual(committed.mode.get(), { id: 'agent://picked', kind: 'agent' }); + }); + // ---- getCustomAgents / onDidChangeCustomAgents ------- test('getCustomAgents collects agents from session customizations, coalesced by URI and sorted by name', async () => { @@ -1101,6 +1472,45 @@ suite('LocalAgentHostSessionsProvider', () => { ]); }); + test('getMcpServers dispatches MCP lifecycle requests', async () => { + const provider = createProvider(disposables, agentHost); + + fireSessionAdded(agentHost, 'mcp-lifecycle', { title: 'MCP Lifecycle' }); + const session = provider.getSessions().find(s => s.title.get() === 'MCP Lifecycle'); + assert.ok(session); + + const fakeState: SessionState = { + provider: 'copilotcli', + title: 'MCP Lifecycle', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + customizations: [{ + type: CustomizationType.McpServer, + id: 'mcp://docs', + uri: 'mcp://docs', + name: 'Docs', + enabled: true, + state: { kind: McpServerStatus.Stopped }, + }], + }; + provider.getSessionConfig(session!.sessionId); + agentHost.setSessionState('mcp-lifecycle', 'copilotcli', fakeState); + + const servers = provider.getMcpServers(session!.sessionId); + assert.strictEqual(servers.length, 1); + await servers[0].start(); + await servers[0].stop(); + + const actions = agentHost.dispatchedActions.slice(-2); + assert.deepStrictEqual(actions.map(({ action }) => action.type), [ + ActionType.SessionMcpServerStartRequested, + ActionType.SessionMcpServerStopRequested, + ]); + assert.deepStrictEqual(actions.map(({ action }) => (action as { id: string }).id), ['mcp://docs', 'mcp://docs']); + }); + test('getCustomAgents returns no agents when the session has no SessionState', () => { const provider = createProvider(disposables, agentHost); @@ -1306,6 +1716,170 @@ suite('LocalAgentHostSessionsProvider', () => { assert.deepStrictEqual(provider.getSessionConfig(session.sessionId), { schema: { type: 'object', properties: {} }, values: {} }); }); + // ---- Quick chats (workspace-less sessions) ------- + + test('declares quick chat support from the initial agent host setting', () => { + const provider = createProvider(disposables, agentHost, undefined, { agentHostEnabled: true }); + assert.strictEqual(provider.supportsQuickChats, true); + }); + + test('does not declare quick chat support when the agent host is disabled', () => { + const provider = createProvider(disposables, agentHost, undefined, { agentHostEnabled: false }); + assert.strictEqual(provider.supportsQuickChats, false); + }); + + test('createQuickChat returns a workspace-less untitled session', () => { + const provider = createProvider(disposables, agentHost); + const session = provider.createQuickChat(provider.sessionTypes[0].id); + + assert.deepStrictEqual({ + providerId: session.providerId, + status: session.status.get(), + workspace: session.workspace.get(), + sessionType: session.sessionType, + }, { + providerId: provider.id, + status: SessionStatus.Untitled, + workspace: undefined, + sessionType: provider.sessionTypes[0].id, + }); + }); + + test('createQuickChat eagerly creates the backend session with no working directory (inferred workspace-less)', async () => { + const provider = createProvider(disposables, agentHost); + provider.createQuickChat(provider.sessionTypes[0].id); + await timeout(0); // let eagerCreate complete + + // The provider no longer passes an explicit quick-chat flag; the host + // infers workspace-less from the absent `workingDirectory`. + const created = agentHost.createSessionConfigs.at(-1); + assert.strictEqual(created?.workingDirectory, undefined); + }); + + test('createQuickChat throws when no agents are advertised', () => { + agentHost.setAgents([]); + const provider = createProvider(disposables, agentHost); + assert.throws(() => provider.createQuickChat('copilotcli')); + }); + + test('restores a quick chat from listSessions as workspace-less despite a scratch working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // On reload the host re-advertises the quick chat tagged via + // `_meta.workspaceless`, but with the throwaway scratch cwd it assigned. + // The restored session must stay workspace-less so it groups under + // "Quick Chats" and skips workspace trust. + agentHost.addSession(createSession('quick-1', { + summary: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-1'), + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions()[0]; + assert.deepStrictEqual({ + title: session?.title.get(), + workspace: session?.workspace.get(), + }, { + title: 'Quick Chat', + workspace: undefined, + }); + })); + + test('restored quick chat reports supportsMultipleChats === false', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // A quick chat is a single-chat session regardless of session type: + // the `_meta.workspaceless` tag forces `supportsMultipleChats: false`. + agentHost.addSession(createSession('quick-1', { + summary: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-1'), + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions()[0]; + assert.deepStrictEqual(session?.capabilities.get(), { supportsMultipleChats: false, supportsFork: true, supportsRename: true, supportsDelete: true }); + })); + + test('restored quick chat collapses to a single chat even when state advertises peer chats', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // A quick chat is single-chat: even if a restored `SessionState` + // advertises peer chats, `supportsMultipleChats: false` collapses the + // catalog to the default chat. The state subscription's `_meta` (which + // the host copies from the summary) must keep the workspace-less tag. + agentHost.addSession(createSession('quick-multi', { + summary: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-multi'), + quickChat: true, + })); + + const provider = createProvider(disposables, agentHost); + provider.getSessions(); + await timeout(0); + + const session = provider.getSessions()[0]; + // Subscribe to session state so the restored snapshot reaches the adapter. + provider.getSessionConfig(session.sessionId); + + const sessionUri = AgentSession.uri('copilotcli', 'quick-multi').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + agentHost.setSessionState('quick-multi', 'copilotcli', { + provider: 'copilotcli', + title: 'Quick Chat', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + _meta: withSessionWorkspaceless(undefined, true), + chats: [ + { resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }, + { resource: buildChatUri(sessionUri, 'peer-1'), title: 'Peer One', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }, + { resource: buildChatUri(sessionUri, 'peer-2'), title: 'Peer Two', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }, + ], + }); + + assert.deepStrictEqual({ + workspace: session.workspace.get(), + supportsMultipleChats: session.capabilities.get().supportsMultipleChats, + chatFragments: session.chats.get().map(c => c.resource.fragment), + chatTitles: session.chats.get().map(c => c.title.get()), + }, { + workspace: undefined, + supportsMultipleChats: false, + chatFragments: [''], + chatTitles: ['Quick Chat'], + }); + })); + + test('committed quick chat announced via sessionAdded stays workspace-less despite a scratch working directory', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // Regression: when a quick-chat draft graduates, the host announces the + // committed session via a `sessionAdded` notification whose summary + // carries `_meta.workspaceless` — but also the scratch cwd the host + // assigned. The adapter's session-kind is fixed at construction, so the + // tag must reach it here (not just via the later listSessions/state + // channels), otherwise `workspace` leaks the scratch folder and the + // archive-on-delete fallback pre-fills a new session with it. + const provider = createProvider(disposables, agentHost); + await timeout(0); + + fireSessionAdded(agentHost, 'quick-committed', { + title: 'Quick Chat', + workingDirectory: URI.file('/tmp/copilot-scratch/quick-committed').toString(), + workspaceless: true, + }); + + const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === 'quick-committed'); + assert.deepStrictEqual({ + workspace: session?.workspace.get(), + isQuickChat: session?.isQuickChat?.get(), + }, { + workspace: undefined, + isQuickChat: true, + }); + })); + test('createNewSession clears session config when resolving config is unavailable', async () => { agentHost.failResolveSessionConfig = true; const provider = createProvider(disposables, agentHost); @@ -1785,7 +2359,7 @@ suite('LocalAgentHostSessionsProvider', () => { ], { defaultChat })); assert.deepStrictEqual({ - supportsMultipleChats: session.capabilities.supportsMultipleChats, + supportsMultipleChats: session.capabilities.get().supportsMultipleChats, chatFragments: session.chats.get().map(c => c.resource.fragment), mainIsDefault: session.mainChat.get() === session.chats.get()[0], peerTitle: session.chats.get()[1].title.get(), @@ -1797,6 +2371,128 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('peer chats map protocol interactivity to the provider-agnostic tri-state', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-ro'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-ro').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const readOnlyPeer = buildChatUri(sessionUri, 'peer-ro'); + const hiddenPeer = buildChatUri(sessionUri, 'peer-hidden'); + + agentHost.setSessionState('multi-ro', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(readOnlyPeer, 'Worker'), interactivity: ProtocolChatInteractivity.ReadOnly }, + { ...makeChatSummary(hiddenPeer, 'Hidden Worker'), interactivity: ProtocolChatInteractivity.Hidden }, + ], { defaultChat })); + + const chats = session.chats.get(); + assert.deepStrictEqual(chats.map(c => c.interactivity.get()), [ + ChatInteractivity.Full, + ChatInteractivity.ReadOnly, + ChatInteractivity.Hidden, + ]); + }); + + test('subagent (tool-origin) chats surface as read-only peers', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-sub'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-sub').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentChat = buildSubagentChatUri(sessionUri, 'tc-1'); + + agentHost.setSessionState('multi-sub', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(subagentChat, 'Code Reviewer'), origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tc-1' }, interactivity: ProtocolChatInteractivity.ReadOnly }, + ], { defaultChat })); + + const chats = session.chats.get(); + assert.deepStrictEqual({ + titles: chats.map(c => c.title.get()), + interactivity: chats.map(c => c.interactivity.get()), + subagentOrigin: chats[1]?.origin?.kind, + // The subagent records its parent chat (the default chat) so the + // "Agents" row can list it under the chat that spawned it. + subagentParentIsMain: !!chats[1]?.origin?.parentChat && isEqual(chats[1].origin.parentChat, chats[0].resource), + // A subagent worker chat is neither renameable nor deletable. + subagentCapabilities: getChatCapabilities(chats[1], session, undefined), + }, { + titles: ['Session', 'Code Reviewer'], + interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly], + subagentOrigin: ChatOriginKind.Tool, + subagentParentIsMain: true, + subagentCapabilities: { canRename: false, canDelete: false }, + }); + }); + + test('the main chat is renameable but never deletable via capabilities', () => { + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'main-caps'); + const sessionUri = AgentSession.uri('copilotcli', 'main-caps').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('main-caps', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(peerChat, 'Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], { defaultChat })); + + const chats = session.chats.get(); + assert.deepStrictEqual({ + // The main (default) chat: renameable, never deletable. + main: getChatCapabilities(chats[0], session, undefined), + // A regular user peer chat: fully manageable. + peer: getChatCapabilities(chats[1], session, undefined), + }, { + main: { canRename: true, canDelete: false }, + peer: { canRename: true, canDelete: true }, + }); + }); + + test('subagent chats surface as read-only peers even without multi-chat support, but user peers do not', () => { + agentHost.setAgents([ + { provider: 'copilotcli', displayName: 'Copilot', description: '', models: [] } as AgentInfo, + { provider: 'claude', displayName: 'Claude', description: '', models: [] } as AgentInfo, + ]); + const configService = new TestConfigurationService(); + configService.setUserConfiguration(ClaudePreferAgentHostAgentsSettingId, true); + const provider = createProvider(disposables, agentHost, undefined, { configurationService: configService, isSessionsWindow: true }); + fireSessionAdded(agentHost, 'claude-sub', { title: 'Claude', provider: 'claude' }); + const session = provider.getSessions().find(s => AgentSession.id(s.resource.toString()) === 'claude-sub'); + assert.ok(session); + provider.getSessionConfig(session!.sessionId); + + const sessionUri = AgentSession.uri('claude', 'claude-sub').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentChat = buildSubagentChatUri(sessionUri, 'tc-1'); + const userPeer = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('claude-sub', 'claude', { + provider: 'claude', + title: 'Claude', + status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + defaultChat, + chats: [ + makeChatSummary(defaultChat, ''), + { ...makeChatSummary(subagentChat, 'Code Reviewer'), origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId: 'tc-1' }, interactivity: ProtocolChatInteractivity.ReadOnly }, + { ...makeChatSummary(userPeer, 'User Peer'), origin: { kind: ProtocolChatOriginKind.User } }, + ], + }); + + const chats = session!.chats.get(); + assert.deepStrictEqual({ + supportsMultipleChats: session!.capabilities.get().supportsMultipleChats, + titles: chats.map(c => c.title.get()), + interactivity: chats.map(c => c.interactivity.get()), + }, { + supportsMultipleChats: false, + // The user peer is not surfaced (no multi-chat support); the subagent is. + titles: ['Claude', 'Code Reviewer'], + interactivity: [ChatInteractivity.Full, ChatInteractivity.ReadOnly], + }); + }); + test('a new peer chat is presented as Untitled until its first request is sent', () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-new'); @@ -1822,6 +2518,42 @@ suite('LocalAgentHostSessionsProvider', () => { }); }); + test('a peer catalog collapsed while capabilities were absent re-expands when they hydrate', () => { + // Simulate the race where a multi-chat SessionState is processed before + // the agent host's root state advertises `supportsMultipleChats`. + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: {} } as AgentInfo]); + + const provider = createProvider(disposables, agentHost); + const session = setupMultiChatSession(provider, 'multi-late-caps'); + const sessionUri = AgentSession.uri('copilotcli', 'multi-late-caps').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const peerChat = buildChatUri(sessionUri, 'peer-1'); + + agentHost.setSessionState('multi-late-caps', 'copilotcli', makeState([ + makeChatSummary(defaultChat, ''), + makeChatSummary(peerChat, 'Peer'), + ], { defaultChat })); + + const collapsed = { + supportsMultipleChats: session.capabilities.get().supportsMultipleChats, + chatFragments: session.chats.get().map(c => c.resource.fragment), + }; + + // Capabilities hydrate late; the catalog must re-expand without another + // session-state update. + agentHost.setAgents([{ provider: 'copilotcli', displayName: 'Copilot', description: '', models: [], capabilities: { multipleChats: { fork: true } } } as AgentInfo]); + + const hydrated = { + supportsMultipleChats: session.capabilities.get().supportsMultipleChats, + chatFragments: session.chats.get().map(c => c.resource.fragment), + }; + + assert.deepStrictEqual({ collapsed, hydrated }, { + collapsed: { supportsMultipleChats: false, chatFragments: [''] }, + hydrated: { supportsMultipleChats: true, chatFragments: ['', 'peer-1'] }, + }); + }); + test('forkChat forwards the source chat and turn to the host and surfaces a new peer chat', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { const provider = createProvider(disposables, agentHost); const session = setupMultiChatSession(provider, 'multi-fork'); @@ -2360,6 +3092,50 @@ suite('LocalAgentHostSessionsProvider', () => { assert.strictEqual(committed.resource.scheme, 'agent-host-codex', `expected the committed session to be the codex session, got ${committed.resource.toString()}`); }); + test('two concurrent same-type new-session sends each commit to their own session (no swap during a shared download window)', async () => { + // Regression: when the first send of a session type triggers a lengthy + // bring-up (e.g. the Claude SDK download) and a SECOND session of the + // same type is started and sent before it finishes, both sends park in + // `_waitForNewSession`. A committed backend session keeps the eager id + // its send created it with, so each send must graduate onto its OWN id. + // Matching purely by novelty + scheme would let the two waiters SWAP + // sessions — whichever materializes first is grabbed by the send that + // parked first, regardless of ownership — leaving the user on the wrong + // session. Here the SECOND session (B) materializes BEFORE the first + // (A), which is exactly the ordering that triggered the swap. + const provider = createProvider(disposables, agentHost, undefined, { + openSession: true, + sendRequest: async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as ChatSendResult extends { kind: 'sent'; data: infer D } ? D : never }), + }); + const sessionTypeId = provider.sessionTypes[0].id; + + const sessionA = provider.createNewSession(URI.parse('file:///home/user/a'), sessionTypeId); + const chatA = await provider.createNewChat(sessionA.sessionId); + const ownA = AgentSession.id(chatA.resource.toString()); + const sessionB = provider.createNewSession(URI.parse('file:///home/user/b'), sessionTypeId); + const chatB = await provider.createNewChat(sessionB.sessionId); + const ownB = AgentSession.id(chatB.resource.toString()); + + // Start both sends; each parks in `_waitForNewSession` (listSessions is + // empty because neither session has materialized yet). + const sendA = provider.sendRequest(sessionA.sessionId, chatA.resource, { query: 'A' }); + const sendB = provider.sendRequest(sessionB.sessionId, chatB.resource, { query: 'B' }); + await new Promise<void>(resolve => setTimeout(resolve, 10)); + + // The committed session keeps each send's own (eager) id. Materialize B + // FIRST, then A — the ordering that made A grab B's session. + fireSessionAdded(agentHost, ownB, { title: 'B' }); + await new Promise<void>(resolve => setTimeout(resolve, 10)); + fireSessionAdded(agentHost, ownA, { title: 'A' }); + + const [committedA, committedB] = await Promise.all([sendA, sendB]); + + assert.deepStrictEqual( + { a: AgentSession.id(committedA.resource.toString()), b: AgentSession.id(committedB.resource.toString()) }, + { a: ownA, b: ownB }, + ); + }); + test('sendRequest forwards resolved session config to chat service', async () => { const sendOptions: IChatSendRequestOptions[] = []; const provider = createProvider(disposables, agentHost, undefined, { @@ -2642,6 +3418,44 @@ suite('LocalAgentHostSessionsProvider', () => { sub2.dispose(); })); + test('surfaces a default open-PR icon immediately when a PR is detected before the live model loads', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // A GitHub service whose live PR model is never populated (`pullRequest` stays + // undefined), mirroring the window right after a PR is first detected but before + // the first live fetch completes. Without a fallback the session list row would + // keep the read/unread dot instead of a PR icon until that fetch lands. + const gitHubService = new class extends mock<IGitHubService>() { + private readonly _model = { pullRequest: constObservable(undefined) } as unknown as GitHubPullRequestModel; + override createPullRequestModelReference = () => new ImmortalReference(this._model); + }(); + + agentHost.addSession(createSession('pr-default-icon', { summary: 'PR Session', project: { uri: URI.parse('file:///repo'), displayName: 'repo' } })); + const provider = createProvider(disposables, agentHost, undefined, { gitHubService }); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions().find(s => s.title.get() === 'PR Session'); + assert.ok(session); + + // Force a session-state subscription and push GitHub state carrying a PR URL so + // the session detects the pull request while its live model is still empty. + provider.getSessionConfig(session!.sessionId); + agentHost.setSessionState('pr-default-icon', 'copilotcli', { + provider: 'copilotcli', title: 'PR Session', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, + activeClients: [], + chats: [], + _meta: { github: { owner: 'owner', repo: 'repo', pullRequestUrl: 'https://github.com/owner/repo/pull/42' } }, + }); + + const gitHubInfoObs = session!.workspace.get()!.folders[0]!.gitRepository!.gitHubInfo; + const sub = autorun(reader => { gitHubInfoObs.read(reader); }); + await timeout(0); + + const pullRequest = gitHubInfoObs.get()?.pullRequest; + assert.strictEqual(pullRequest?.number, 42, 'PR is detected from the GitHub state URL'); + assert.deepStrictEqual(pullRequest?.icon, computePullRequestIcon(GitHubPullRequestState.Open), 'a default open-PR icon is shown immediately while the live model is empty'); + sub.dispose(); + })); + // ---- replaceSessionConfig ------- test('replaceSessionConfig only replaces sessionMutable, non-readOnly values and preserves everything else', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { @@ -2944,9 +3758,61 @@ suite('LocalAgentHostSessionsProvider', () => { const updated = provider.getSessionConfig(session!.sessionId); assert.deepStrictEqual(updated?.values, { autoApprove: 'autoApprove', isolation: 'worktree' }); })); + + test('keeps a visible session subscribed so host-spawned subagent chats keep reaching the catalog', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + // Regression for the "Open Subagent" pill: a passively-watched session + // must stay subscribed so a host-spawned subagent's `chatAdded` keeps + // reaching the catalog past the idle-release window. + agentHost.addSession(createSession('subagent-live', { summary: 'Lead' })); + const visibleSessions = observableValue<readonly (IActiveSession | undefined)[]>('visible', []); + const provider = createProvider(disposables, agentHost, undefined, { visibleSessions }); + provider.getSessions(); + await timeout(0); + const session = provider.getSessions()[0]; + + // The session's view is on screen: its state subscription must be pinned. + visibleSessions.set([new class extends mock<IActiveSession>() { + override readonly resource = session.resource; + }()], undefined); + + const sessionUri = AgentSession.uri('copilotcli', 'subagent-live').toString(); + const defaultChat = buildDefaultChatUri(sessionUri); + const subagentOne = buildSubagentChatUri(sessionUri, 'tc-1'); + const subagentTwo = buildSubagentChatUri(sessionUri, 'tc-2'); + const toolChat = (resource: string, toolCallId: string, title: string): ChatSummary => ({ + resource, title, status: ProtocolSessionStatus.InProgress, modifiedAt: new Date(0).toISOString(), + origin: { kind: ProtocolChatOriginKind.Tool, chat: defaultChat, toolCallId }, + }); + const stateWith = (chats: ChatSummary[]): SessionState => ({ + provider: 'copilotcli', title: 'Lead', status: ProtocolSessionStatus.Idle, + lifecycle: SessionLifecycle.Ready, activeClients: [], defaultChat, chats, + }); + const defaultSummary: ChatSummary = { resource: defaultChat, title: '', status: ProtocolSessionStatus.Idle, modifiedAt: new Date(0).toISOString() }; + + agentHost.setSessionState('subagent-live', 'copilotcli', stateWith([defaultSummary, toolChat(subagentOne, 'tc-1', 'Add name to README')])); + assert.ok(session.chats.get().some(c => c.resource.fragment === 'subagent/tc-1'), 'first subagent should reach the catalog while visible'); + + // Advance well past the idle-release window; a passively-watched session + // used to drop its state listener here. + await timeout(120_000); + + // A second subagent spawns later in the same run; it must still reach the + // catalog because the visible session stayed subscribed. + agentHost.setSessionState('subagent-live', 'copilotcli', stateWith([ + defaultSummary, + toolChat(subagentOne, 'tc-1', 'Add name to README'), + toolChat(subagentTwo, 'tc-2', 'Add description to package.json'), + ])); + + assert.deepStrictEqual( + session.chats.get().map(c => c.resource.fragment).filter(f => f.startsWith('subagent/')).sort(), + ['subagent/tc-1', 'subagent/tc-2'], + 'both subagents should reach the catalog after the idle window while the session stays visible', + ); + })); }); -suite('LocalAgentHostSessionsProvider - active-session branch changeset subscription', () => { +suite.skip('LocalAgentHostSessionsProvider - active-session branch changeset subscription', () => { const disposables = new DisposableStore(); let agentHost: MockAgentHostService; let activeSession: ISettableObservable<IActiveSession | undefined>; @@ -2972,7 +3838,7 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip } function branchChangesKeyFor(rawId: string, sessionType: string = 'copilotcli'): string { - return `${AgentSession.uri(sessionType, rawId).toString()}/changeset/session`; + return `${AgentSession.uri(sessionType, rawId).toString()}/changeset/branch`; } // The adapter subscribes to its branch changeset lazily — only while the @@ -3081,7 +3947,6 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip }, }], }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // let the changeset throttle flush const changes = session.changes.get(); assert.deepStrictEqual(changes.map(change => { @@ -3175,7 +4040,6 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip files.push(makeChangesetFile(i, 0)); } agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // flush the changeset throttle let previous = session.changes.get(); assert.strictEqual(previous.length, FILE_COUNT, 'every file should surface as a change'); @@ -3184,7 +4048,6 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip const changedIndex = update % FILE_COUNT; files[changedIndex] = makeChangesetFile(changedIndex, update + 1); agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // flush the changeset throttle const next = session.changes.get(); @@ -3218,7 +4081,6 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip files.push(makeChangesetFile(i, 0)); } agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // flush the changeset throttle // Index 0 is never touched; only the last file "streams" updates. const untouchedChangeBefore = session.changes.get()[0]; @@ -3228,61 +4090,10 @@ suite('LocalAgentHostSessionsProvider - active-session branch changeset subscrip for (let update = 0; update < UPDATE_COUNT; update++) { files[lastIndex] = makeChangesetFile(lastIndex, update + 1); agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // flush the changeset throttle session.changes.get(); // force the derived chain to recompute } const untouchedChangeAfter = session.changes.get()[0]; assert.strictEqual(untouchedChangeAfter, untouchedChangeBefore, 'an unchanged file must reuse its change object across all updates'); })); - - // Performance-regression guard for the changeset update throttle - // - // While an agent streams edits, the host emits many envelopes per second. Each - // envelope fires the subscription's `onDidChange`; without throttling, each one - // would drive a full recompute of the `changes` list (and a relayout). The - // throttle must collapse a burst that arrives within one window into a single - // recompute carrying the final state. - // - // Reverting the throttle makes the burst recompute the list ~BURST times, so - // the `recomputes === 0` assertion (before the window elapses) fails. - test('coalesces a burst of changeset envelopes into a single changes recompute', () => runWithFakedTimers<void>({ useFakeTimers: true, maxTaskCount: 1_000 }, async () => { - const provider = createProvider(disposables, agentHost, undefined, { activeSession }); - const session = addAndObserve(provider, 'sess-A'); - activeSession.set(makeActive('sess-A'), undefined); - - const FILE_COUNT = 20; - const BURST = 50; - const key = branchChangesKeyFor('sess-A'); - - const files: ChangesetState['files'] = []; - for (let i = 0; i < FILE_COUNT; i++) { - files.push(makeChangesetFile(i, 0)); - } - agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // settle the initial state - - let recomputes = 0; - disposables.add(autorun(reader => { - session.changes.read(reader); - recomputes++; - })); - recomputes = 0; // ignore the autorun's initial run - - // Fire a burst of single-file updates with NO time passing in between, so - // they all land within one throttle window. - for (let i = 0; i < BURST; i++) { - files[0] = makeChangesetFile(0, i + 1); - agentHost.setChangesetState(key, { status: ChangesetStatus.Ready, files: [...files] }); - } - assert.strictEqual(recomputes, 0, `a burst of ${BURST} envelopes within one window must not recompute the list yet`); - - await timeout(CHANGESET_UPDATE_THROTTLE_MS); // flush the throttle - assert.strictEqual(recomputes, 1, 'the burst should collapse into exactly one recompute with the final state'); - - // And that single recompute carries the latest state. - const change0 = session.changes.get()[0]; - assert.ok(change0 && isIChatSessionFileChange2(change0)); - assert.strictEqual(change0.insertions, BURST, 'the coalesced update must reflect the final envelope'); - })); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md index 188e207bdb8758..bf78aeaba3ea0e 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/COPILOT_CHAT_SESSIONS_PROVIDER.md @@ -89,6 +89,10 @@ For multi-chat sessions (`capabilities.supportsMultipleChats === true`), `create The provider never opens the chat widget itself; widget opening is owned by the management service. +## Delete Flow + +For Copilot CLI sessions, `_deleteAgentSessions()` delegates to the extension-host command `agents.github.copilot.cli.deleteSessions` and passes both the session resource and current session label. The extension may later hit the Git extension's force-delete confirmation when removing a dirty worktree, so the label must be preserved in that command payload to identify which session owns the worktree. + ## New-Session Picker Contribution Model **File:** `src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts` diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts index 91d3d4dad8ff45..96ca41e214e1af 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsChangesets.ts @@ -35,7 +35,7 @@ class GitRepositoryChangesetResolver implements IChangesetResolver { async resolve(firstCheckpointRef: string, lastCheckpointRef: string | undefined): Promise<IChatSessionFileChange2[] | undefined> { const repositoryUri = this._repositoryUriObs.get(); if (!repositoryUri) { - return undefined; + return []; } const repository = await this._gitService.openRepository(repositoryUri); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index cfcd8597785917..0053bdc11467c9 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -23,14 +23,14 @@ import { AgentSessionProviders, AgentSessionTarget } from '../../../../../workbe import { IChatService, IChatSendRequestOptions } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js'; import { ChatSessionStatus, IChatSessionsService, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, GITHUB_REMOTE_FILE_SCHEME, IGitHubInfo, ISessionType, ISessionWorkspaceBrowseAction, ISessionFileChange, sessionFileChangesEqual, gitHubInfoEqual, sessionWorkspaceEqual, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, ISessionChangeset, IChatCheckpoints, ChatInteractivity } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; import { ISessionOptionGroup } from '../../../chat/browser/newSession.js'; import { IsolationMode } from './isolationPicker.js'; import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; -import { isBuiltinChatMode, IChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; +import { ChatMode, IChatMode, IChatModeService, isBuiltinChatMode } from '../../../../../workbench/contrib/chat/common/chatModes.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; @@ -52,6 +52,7 @@ import { structuralEquals } from '../../../../../base/common/equals.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; /** Claude Code session type — local agent powered by Claude. */ export const ClaudeCodeSessionType: ISessionType = { @@ -182,6 +183,7 @@ function buildChatFromSession(chat: Omit<ICopilotChatSession, 'mainChat'>, resou mode: chat.mode, isArchived: chat.isArchived, isRead: chat.isRead, + interactivity: constObservable(ChatInteractivity.Full), description: chat.description, lastTurnEnd: chat.lastTurnEnd, }; @@ -313,6 +315,7 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { @IGitService private readonly gitService: IGitService, @IGitHubService private readonly gitHubService: IGitHubService, @IStorageService private readonly storageService: IStorageService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this.sessionId = toSessionId(providerId, resource); @@ -497,6 +500,14 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { }; if (this._isolationMode === 'worktree' && this._branch) { config[SessionConfigKey.Branch] = this._branch; + + // Forward the user's `git.branchPrefix` (resource-scoped to the + // repository) so the agent host prepends it to the worktree branch + // it creates. Omit when unset/empty to preserve the default naming. + const branchPrefix = this.configurationService.getValue<string>('git.branchPrefix', { resource: this._repoUri }); + if (typeof branchPrefix === 'string' && branchPrefix.length > 0) { + config[SessionConfigKey.WorktreeBranchPrefix] = branchPrefix; + } } return config; } @@ -1438,28 +1449,43 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions private readonly _onDidGroupMembershipChange = this._register(new Emitter<{ sessionId: string }>()); private readonly _multiChatEnabled: boolean; - private _claudeEnabled: boolean; - private _preferAgentHostClaude: boolean; - private _hideExtensionHostCopilotCli: boolean; /** * Claude is offered by this (Copilot Chat sessions) provider only when the * underlying `claudeAgent.enabled` setting is on AND the user has not opted * the agent-host implementation in via `chat.agents.claude.preferAgentHost`. * When the latter is true, the agent host registers Claude itself and this - * provider stays out of the way so the picker shows a single entry. + * provider stays out of the way so the picker shows a single entry. Stepping + * aside only makes sense when the agent host is enabled to register Claude in + * its place, so the preference is not respected unless `chat.agentHost.enabled` + * is also on. */ private _isClaudeAvailable(): boolean { - return this._claudeEnabled && !this._preferAgentHostClaude; + const claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING) ?? false; + if (!claudeEnabled) { + return false; + } + const preferAgentHost = this.configurationService.getValue<boolean>(ClaudePreferAgentHostAgentsSettingId) ?? false; + if (this.agentHostEnablementService.enabled && preferAgentHost) { + return false; + } + return true; } /** * The Extension Host Copilot CLI is offered by this provider unless the user * has hidden it via `chat.agents.copilotCli.hideExtensionHost`, in which case * the Agents window picker only surfaces the Agent Host Copilot CLI entry. + * Hiding it only makes sense when the agent host is enabled to surface the + * Agent Host Copilot CLI in its place, so the setting is not respected unless + * `chat.agentHost.enabled` is also on. */ private _isCopilotCliAvailable(): boolean { - return !this._hideExtensionHostCopilotCli; + const hideExtensionHost = this.configurationService.getValue<boolean>(ChatConfiguration.CopilotCliHideExtensionHostAgents) ?? false; + if (this.agentHostEnablementService.enabled && hideExtensionHost) { + return false; + } + return true; } readonly browseActions: readonly ISessionWorkspaceBrowseAction[]; @@ -1475,40 +1501,26 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IAgentHostEnablementService private readonly agentHostEnablementService: IAgentHostEnablementService, @ILogService private readonly logService: ILogService, @IGitHubService private readonly gitHubService: IGitHubService, @ILabelService private readonly labelService: ILabelService, + @IChatModeService private readonly chatModeService: IChatModeService, @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, ) { super(); this._multiChatEnabled = this.configurationService.getValue<boolean>(COPILOT_MULTI_CHAT_SETTING) ?? true; - this._claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING); - this._preferAgentHostClaude = this.configurationService.getValue<boolean>(ClaudePreferAgentHostAgentsSettingId) ?? false; - this._hideExtensionHostCopilotCli = this.configurationService.getValue<boolean>(ChatConfiguration.CopilotCliHideExtensionHostAgents) ?? false; this._register(this.configurationService.onDidChangeConfiguration(e => { - const claudeEnabledChanged = e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING); - const preferAgentHostChanged = e.affectsConfiguration(ClaudePreferAgentHostAgentsSettingId); - const hideCopilotCliChanged = e.affectsConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents); - if (!claudeEnabledChanged && !preferAgentHostChanged && !hideCopilotCliChanged) { + const affectsSessionTypes = e.affectsConfiguration(CLAUDE_CODE_ENABLED_SETTING) + || e.affectsConfiguration(ClaudePreferAgentHostAgentsSettingId) + || e.affectsConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents); + if (!affectsSessionTypes) { return; } - const wasClaudeAvailable = this._isClaudeAvailable(); - const wasCopilotCliAvailable = this._isCopilotCliAvailable(); - if (claudeEnabledChanged) { - this._claudeEnabled = this.configurationService.getValue<boolean>(CLAUDE_CODE_ENABLED_SETTING); - } - if (preferAgentHostChanged) { - this._preferAgentHostClaude = this.configurationService.getValue<boolean>(ClaudePreferAgentHostAgentsSettingId) ?? false; - } - if (hideCopilotCliChanged) { - this._hideExtensionHostCopilotCli = this.configurationService.getValue<boolean>(ChatConfiguration.CopilotCliHideExtensionHostAgents) ?? false; - } - if (this._isClaudeAvailable() !== wasClaudeAvailable || this._isCopilotCliAvailable() !== wasCopilotCliAvailable) { - this._onDidChangeSessionTypes.fire(); - this._refreshSessionCache(); - } + this._onDidChangeSessionTypes.fire(); + this._refreshSessionCache(); })); this.browseActions = [ @@ -1638,6 +1650,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._chatToSession(session); } + createQuickChat(_sessionTypeId: string): ISession { + // This provider is workspace-bound and does not advertise + // `supportsQuickChats`; callers must gate on that capability. + throw new Error('CopilotChatSessionsProvider does not support quick chats'); + } + /** * Resolves the initial permission level for a brand-new session from * `chat.permissions.default`, clamped to `Default` when enterprise policy @@ -1760,6 +1778,89 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions this._findChatSession(sessionId)?.setModelId(modelId); } + setMode(sessionId: string, modeId: string): void { + const setSessionMode = (session: ICopilotChatSession): void => { + let mode: IChatMode | undefined; + switch (modeId) { + case ChatModeKind.Agent: + mode = ChatMode.Agent; + break; + case ChatModeKind.Edit: + mode = ChatMode.Edit; + break; + case ChatModeKind.Ask: + mode = ChatMode.Ask; + break; + default: { + const modes = this.chatModeService.createModes(session.resource); + try { + mode = modes.findModeById(modeId) ?? modes.findModeByName(modeId); + } finally { + modes.dispose(); + } + break; + } + } + + if (mode) { + session.setMode(mode); + } + }; + + const newSession = this._newSessions.get(sessionId); + if (newSession) { + setSessionMode(newSession); + return; + } + + this._ensureSessionCache(); + const session = this._findChatSession(sessionId); + if (session) { + setSessionMode(session); + } + } + + setPermissionLevel(sessionId: string, level: string): void { + const newSession = this._newSessions.get(sessionId); + if (newSession) { + if (isChatPermissionLevel(level)) { + newSession.setPermissionLevel(level); + } + return; + } + + this._ensureSessionCache(); + const session = this._findChatSession(sessionId); + if (session && isChatPermissionLevel(level)) { + session.setPermissionLevel(level); + } + } + + setIsolationMode(sessionId: string, mode: string): void { + if (mode !== 'worktree' && mode !== 'workspace') { + return; + } + const newSession = this._newSessions.get(sessionId); + if (newSession) { + newSession.setIsolationMode(mode); + return; + } + + this._ensureSessionCache(); + this._findChatSession(sessionId)?.setIsolationMode(mode); + } + + setBranch(sessionId: string, branch: string): void { + const newSession = this._newSessions.get(sessionId); + if (newSession) { + newSession.setBranch(branch); + return; + } + + this._ensureSessionCache(); + this._findChatSession(sessionId)?.setBranch(branch); + } + // -- Session Actions -- async archiveSession(sessionId: string): Promise<void> { @@ -1852,7 +1953,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions async deleteChat(sessionId: string, chatUri: URI, options?: IDeleteChatOptions): Promise<boolean> { const session = this._findSession(sessionId); - if (!session?.capabilities.supportsMultipleChats) { + if (!session?.capabilities.get().supportsMultipleChats) { throw new Error('Deleting individual chats is not supported when multi-chat is disabled'); } @@ -1920,10 +2021,10 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions } private async _deleteAgentSessions(agentSessions: IAgentSession[]): Promise<void> { - const cliSessionItems: { resource: URI }[] = []; + const cliSessionItems: { resource: URI; label: string }[] = []; for (const agentSession of agentSessions) { if (agentSession.providerType === CopilotCLISessionType.id) { - cliSessionItems.push({ resource: agentSession.resource }); + cliSessionItems.push({ resource: agentSession.resource, label: agentSession.label }); } else { await this.chatService.removeHistoryEntry(agentSession.resource); } @@ -2032,7 +2133,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions throw new Error(`Session '${sessionId}' not found`); } - if (!session.capabilities.supportsMultipleChats) { + if (!session.capabilities.get().supportsMultipleChats) { throw new Error('Multiple chats per session is not supported'); } @@ -2053,7 +2154,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions const { query, attachedContext } = options; - session.setTitle(query.split('\n')[0].substring(0, 100) || localize('new session', "New Session")); + session.setTitle((options.title || query.split('\n')[0]).substring(0, 100) || localize('new session', "New Session")); session.setStatus(SessionStatus.InProgress); this._sessionCache.set(session.resource.toString(), session); this._invalidateGroupingCaches(); @@ -2939,7 +3040,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))), chats: chatsObs, mainChat, - capabilities: { + capabilities: constObservable({ supportsMultipleChats: primaryChat.sessionType === CopilotCLISessionType.id && this._isMultiChatEnabled(), supportsRename: this._sessionTypeSupportsRename(primaryChat.sessionType), supportsDelete: this._sessionTypeSupportsDelete(primaryChat.sessionType), @@ -2947,7 +3048,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions // environment provisioning, so the agents-window dispatcher must // not re-run them. CLI / local sessions don't. runsWorktreeCreatedTasks: primaryChat.sessionType === CopilotCloudSessionType.id, - }, + }), }; this._sessionGroupCache.set(sessionId, session); return session; @@ -2980,12 +3081,12 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions lastTurnEnd: chat.lastTurnEnd, chats: chatsObs, mainChat, - capabilities: { + capabilities: constObservable({ supportsMultipleChats: false, supportsRename: this._sessionTypeSupportsRename(chat.sessionType), supportsDelete: this._sessionTypeSupportsDelete(chat.sessionType), runsWorktreeCreatedTasks: chat.sessionType === CopilotCloudSessionType.id, - }, + }), }; } @@ -3001,7 +3102,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return sessionType === CopilotCLISessionType.id; } - private _toChat(chat: ICopilotChatSession, resource?: URI): IChat { + private _toChat(chat: ICopilotChatSession, resource?: URI, interactivity: ChatInteractivity = ChatInteractivity.Full): IChat { return { resource: resource ?? chat.resource, createdAt: chat.createdAt, @@ -3014,6 +3115,7 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions mode: chat.mode, isArchived: chat.isArchived, isRead: chat.isRead, + interactivity: constObservable(interactivity), description: chat.description, lastTurnEnd: chat.lastTurnEnd, }; diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 2f5098ff4f7a7a..33395975d06e6c 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -39,6 +39,7 @@ import { ILabelService } from '../../../../../../platform/label/common/label.js' import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { extUri } from '../../../../../../base/common/resources.js'; import { CopilotCLISessionType } from '../../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; // ---- Helpers ---------------------------------------------------------------- @@ -120,12 +121,30 @@ class MockAgentSessionsModel { } } +interface IExecutedCommand { + readonly id: string; + readonly args: readonly unknown[]; +} + +interface ICreateProviderOptions { + readonly multiChatEnabled?: boolean; + readonly claudeEnabled?: boolean; + readonly preferAgentHost?: boolean; + readonly hideCopilotCli?: boolean; + readonly agentHostEnabled?: boolean; + readonly commandExecutions?: IExecutedCommand[]; +} + +function isCommandSessionItem(item: unknown): item is { readonly resource: URI; readonly label?: string } { + return typeof item === 'object' && item !== null && 'resource' in item && URI.isUri(item.resource); +} + // ---- Provider factory ------------------------------------------------------- function createProvider( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean; preferAgentHost?: boolean; hideCopilotCli?: boolean }, + opts?: ICreateProviderOptions, ): CopilotChatSessionsProvider { return createProviderWithConfig(disposables, model, opts).provider; } @@ -133,7 +152,7 @@ function createProvider( function createProviderWithConfig( disposables: DisposableStore, model: MockAgentSessionsModel, - opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean; preferAgentHost?: boolean; hideCopilotCli?: boolean }, + opts?: ICreateProviderOptions, ): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } { const instantiationService = disposables.add(new TestInstantiationService()); @@ -144,22 +163,24 @@ function createProviderWithConfig( configService.setUserConfiguration(ChatConfiguration.CopilotCliHideExtensionHostAgents, opts?.hideCopilotCli ?? false); instantiationService.stub(IConfigurationService, configService); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: opts?.agentHostEnabled ?? true }); instantiationService.stub(IStorageService, disposables.add(new TestStorageService())); instantiationService.stub(IFileDialogService, {}); instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }), }); instantiationService.stub(ICommandService, { - executeCommand: async (_id: string, ...args: any[]) => { + executeCommand: async (id: string, ...args: unknown[]) => { + opts?.commandExecutions?.push({ id, args }); // Simulate 'agents.github.copilot.cli.deleteSessions' removing sessions const items = args[0]; if (Array.isArray(items)) { for (const item of items) { - if (item?.resource) { + if (isCommandSessionItem(item)) { model.removeSession(item.resource); } } - } else if (items?.resource) { + } else if (isCommandSessionItem(items)) { model.removeSession(items.resource); } return undefined; @@ -202,6 +223,7 @@ function createProviderWithConfig( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: opts?.agentHostEnabled ?? true }); const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); return { provider, configService }; @@ -221,7 +243,7 @@ function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise<ChatSendResult>, - opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem']; configurationService?: TestConfigurationService }, + opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem']; configurationService?: TestConfigurationService; agentHostEnabled?: boolean }, ): CopilotChatSessionsProvider { const instantiationService = disposables.add(new TestInstantiationService()); @@ -275,6 +297,7 @@ function createProviderForSendTests( getUriLabel: (uri: URI) => uri.path, }); instantiationService.stub(IUriIdentityService, { extUri }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: opts?.agentHostEnabled ?? true }); return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); } @@ -324,6 +347,16 @@ suite('CopilotChatSessionsProvider', () => { assert.ok(provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id)); }); + test('preferAgentHost is not respected when chat.agentHost.enabled is false', () => { + // Yielding to the agent host's Claude only makes sense when the agent + // host is enabled to register it. With the agent host disabled the + // preference must be ignored so this provider keeps surfacing Claude; + // otherwise Claude would disappear entirely. + const provider = createProvider(disposables, model, { claudeEnabled: true, preferAgentHost: true, agentHostEnabled: false }); + assert.strictEqual(provider.sessionTypes.length, 3); + assert.ok(provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id)); + }); + test('onDidChangeSessionTypes fires when claude setting changes', () => { const { provider, configService } = createProviderWithConfig(disposables, model); assert.strictEqual(provider.sessionTypes.length, 3); @@ -397,6 +430,23 @@ suite('CopilotChatSessionsProvider', () => { assert.ok(!provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); }); + test('hideExtensionHost is not respected when chat.agentHost.enabled is false', () => { + // Hiding the Extension Host Copilot CLI only makes sense when the agent + // host is enabled to surface the Agent Host Copilot CLI in its place. With + // the agent host disabled the hide setting must be ignored so the entry + // stays visible. + const provider = createProvider(disposables, model, { hideCopilotCli: true, agentHostEnabled: false }); + assert.ok(provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); + }); + + test('chat.agentHost.enabled is read once when the provider is created', () => { + // With the hide setting on but the agent host initially disabled, the + // Copilot CLI entry is visible. Since enablement is fixed at startup, + // the provider always reflects the initial value. + const { provider } = createProviderWithConfig(disposables, model, { hideCopilotCli: true, agentHostEnabled: false }); + assert.ok(provider.sessionTypes.some(t => t.id === CopilotCLISessionType.id)); + }); + test('toggling claude setting refreshes sessions list', () => { const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' }); model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude })); @@ -578,6 +628,19 @@ suite('CopilotChatSessionsProvider', () => { providerSession.dispose(); }); + test('Copilot CLI session forwards git.branchPrefix as worktreeBranchPrefix for worktree isolation', async () => { + const configService = new TestConfigurationService(); + configService.setUserConfiguration('git.branchPrefix', 'users/alice/'); + const provider = createProviderForSendTests(disposables, model, async () => ({ kind: 'sent' as const, data: {} as IChatSendRequestData }), { configurationService: configService }); + const session = provider.createNewSession(URI.file('/test/project'), CopilotCLISessionType.id); + const providerSession = provider.getSession(session.sessionId)! as ICopilotChatSession & IDisposable & { getAgentHostSessionConfig(): Record<string, unknown> }; + providerSession.setIsolationMode('worktree'); + providerSession.setBranch('main'); + + assert.deepStrictEqual(providerSession.getAgentHostSessionConfig(), { isolation: 'worktree', branch: 'main', worktreeBranchPrefix: 'users/alice/' }); + providerSession.dispose(); + }); + // ---- Session actions ------- test('archiveSession sets archived state', () => { @@ -618,7 +681,7 @@ suite('CopilotChatSessionsProvider', () => { const sessions = provider.getSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, true); + assert.strictEqual(sessions[0].capabilities.get().supportsMultipleChats, true); }); test('copilot cloud sessions do not have supportsMultipleChats capability', () => { @@ -629,7 +692,7 @@ suite('CopilotChatSessionsProvider', () => { const sessions = provider.getSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + assert.strictEqual(sessions[0].capabilities.get().supportsMultipleChats, false); }); test('copilot CLI sessions do not have supportsMultipleChats when setting is disabled', () => { @@ -640,7 +703,7 @@ suite('CopilotChatSessionsProvider', () => { const sessions = provider.getSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + assert.strictEqual(sessions[0].capabilities.get().supportsMultipleChats, false); }); test('claude sessions do not have supportsMultipleChats capability', () => { @@ -651,7 +714,7 @@ suite('CopilotChatSessionsProvider', () => { const sessions = provider.getSessions(); assert.strictEqual(sessions.length, 1); - assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false); + assert.strictEqual(sessions[0].capabilities.get().supportsMultipleChats, false); }); // ---- Session listing & grouping ------- @@ -857,6 +920,29 @@ suite('CopilotChatSessionsProvider', () => { assert.strictEqual(remainingSessions[0].title.get(), 'Session 2'); }); + test('deleteSession passes Copilot CLI session label to delete command', async () => { + const resource = URI.from({ scheme: CopilotCLISessionType.id, path: '/session-1' }); + const commandExecutions: IExecutedCommand[] = []; + model.addSession(createMockAgentSession(resource, { providerType: CopilotCLISessionType.id, title: 'Fix Build' })); + + const provider = createProvider(disposables, model, { commandExecutions }); + const sessions = provider.getSessions(); + + await provider.deleteSession(sessions[0].sessionId); + + assert.deepStrictEqual(commandExecutions.map(command => ({ + id: command.id, + items: Array.isArray(command.args[0]) + ? command.args[0].map(item => isCommandSessionItem(item) ? { resource: item.resource.toString(), label: item.label } : undefined) + : undefined, + options: command.args[1], + })), [{ + id: 'agents.github.copilot.cli.deleteSessions', + items: [{ resource: resource.toString(), label: 'Fix Build' }], + options: { skipConfirmation: true }, + }]); + }); + test('deleteChat with single chat delegates to deleteSession', async () => { const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' }); model.addSession(createMockAgentSession(resource)); diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts index fdde3b78476e26..51f1b3401d1163 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessions.contribution.ts @@ -69,7 +69,7 @@ registerAction2(class extends ForkConversationAction { const session = sessionsManagementService.getSession(sourceSessionResource) ?? sessionsManagementService.getSessions().find(s => s.chats.get().some(c => c.resource.toString() === sourceSessionResource.toString())); - if (!session?.capabilities.supportsMultipleChats || !isAgentHostProviderId(session.providerId)) { + if (!session?.capabilities.get().supportsMultipleChats || !isAgentHostProviderId(session.providerId)) { return false; } diff --git a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts index f8ac18cd88571e..a56fd710205cfc 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/browser/localChatSessionsProvider.ts @@ -13,7 +13,7 @@ import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatService, IChatSendRequestOptions, IChatDetail, convertLegacyChatSessionTiming } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IChatSessionFileChange2, IChatSessionProviderOptionItem, SessionType } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, ISessionType, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, IChatCheckpoints } from '../../../../services/sessions/common/session.js'; +import { ISession, IChat, ISessionGitRepository, ISessionFolder, ISessionWorkspace, SessionStatus, ISessionType, ISessionFileChange, toSessionId, SESSION_WORKSPACE_GROUP_LOCAL, IChatCheckpoints, ChatInteractivity } from '../../../../services/sessions/common/session.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../../../workbench/contrib/chat/common/constants.js'; import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { IDeleteChatOptions, ISendRequestOptions, ISessionChangeEvent, ISessionModelPickerOptions, ISessionsProvider } from '../../../../services/sessions/common/sessionsProvider.js'; @@ -78,6 +78,7 @@ function buildChat(session: LocalSession): IChat { mode: session.mode, isArchived: session.isArchived, isRead: session.isRead, + interactivity: constObservable(ChatInteractivity.Full), description: session.description, lastTurnEnd: session.lastTurnEnd, }; @@ -717,6 +718,12 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr return this._toISession(session); } + createQuickChat(_sessionTypeId: string): ISession { + // This provider is workspace-bound and does not advertise + // `supportsQuickChats`; callers must gate on that capability. + throw new Error('LocalChatSessionsProvider does not support quick chats'); + } + deleteNewSession(sessionId: string): void { if (this._newSessions.has(sessionId)) { this._newSessions.deleteAndDispose(sessionId); @@ -1215,11 +1222,11 @@ export class LocalChatSessionsProvider extends Disposable implements ISessionsPr lastTurnEnd: chatsObs.map((chats, reader) => this._latestDate(chats, c => c.lastTurnEnd.read(reader))), chats: chatsObs, mainChat: primary.mainChat, - capabilities: { + capabilities: constObservable({ supportsMultipleChats: true, supportsRename: true, supportsDelete: true, - }, + }), }; } diff --git a/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts index 26feae9a26a560..ec6ad3f408eebc 100644 --- a/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/localChatSessions/test/browser/localChatSessionsProvider.test.ts @@ -311,7 +311,7 @@ suite('LocalChatSessionsProvider', () => { const provider = store.add(instantiationService.createInstance(LocalChatSessionsProvider)); const session = await commitNewSession(provider); - assert.strictEqual(session.capabilities.supportsMultipleChats, true); + assert.strictEqual(session.capabilities.get().supportsMultipleChats, true); assert.strictEqual(session.chats.get().length, 1); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index 661e19af02a8ed..5c4c08d860909d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -18,9 +18,8 @@ import { ActionType } from '../../../../../platform/agentHost/common/state/sessi import { ROOT_STATE_URI, customizationId, type Customization } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { AICustomizationManagementSection, AICustomizationSources, IAICustomizationWorkspaceService, type IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ICustomizationSyncProvider, type IHarnessDescriptor, type ICustomizationItemAction } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; -import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.js'; import { CustomizationType } from '../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -141,9 +140,6 @@ export function createRemoteAgentHarnessDescriptor( itemProvider: AgentCustomizationItemProvider, syncProvider: ICustomizationSyncProvider, ): IHarnessDescriptor { - const allSources = [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.plugin, AICustomizationSources.extension, AICustomizationSources.builtin]; - const filter: IStorageSourceFilter = { sources: allSources }; - return { id: harnessId, label: displayName, @@ -153,9 +149,6 @@ export function createRemoteAgentHarnessDescriptor( AICustomizationManagementSection.McpServers, ], hideGenerateButton: true, - getStorageSourceFilter(_type: PromptsType): IStorageSourceFilter { - return filter; - }, itemProvider, syncProvider, pluginActions: controller.pluginActions, diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts index 52e7591ca5f288..24489718b06b73 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts @@ -16,7 +16,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { agentHostUri } from '../../../../../platform/agentHost/common/agentHostFileSystemProvider.js'; import { AGENT_HOST_SCHEME, agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { AgentSession, type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; +import { type IAgentConnection, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, remoteAgentHostLogOutputChannelId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import type { ISessionGitState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -26,7 +26,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { ILabelService } from '../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IAgentHostActiveClientService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -37,64 +37,12 @@ import { buildAgentHostSessionWorkspace, readBranchProtectionPatterns } from '.. import { IGitHubInfo, ISession, ISessionType, ISessionWorkspace, ISessionWorkspaceBrowseAction, SESSION_WORKSPACE_GROUP_REMOTE } from '../../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IGitHubService } from '../../../github/browser/githubService.js'; -import { AgentHostSessionAdapter, BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; +import { BaseAgentHostSessionsProvider } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { remoteAgentHostSessionTypeId } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; /** Storage key prefix for cached session summaries, per remote address. */ const CACHED_SESSIONS_STORAGE_PREFIX = 'remoteAgentHost.cachedSessions.'; -/** Maximum number of cached session summaries persisted per host. */ -const CACHED_SESSIONS_MAX_PER_HOST = 100; - -/** - * Serialized shape of an {@link IAgentSessionMetadata} suitable for - * persisting via {@link IStorageService}. URIs are stored as strings - * and diffs are intentionally omitted (they are re-populated when the - * connection refreshes sessions). - */ -interface ISerializedSessionMetadata { - readonly session: string; - readonly startTime: number; - readonly modifiedTime: number; - readonly summary?: string; - readonly workingDirectory?: string; - readonly isRead?: boolean; - readonly isArchived?: boolean; - /** @deprecated Legacy name for `isArchived`. */ - readonly isDone?: boolean; - readonly project?: { readonly uri: string; readonly displayName: string }; -} - -function serializeMetadata(meta: IAgentSessionMetadata): ISerializedSessionMetadata { - return { - session: meta.session.toString(), - startTime: meta.startTime, - modifiedTime: meta.modifiedTime, - summary: meta.summary, - workingDirectory: meta.workingDirectory?.toString(), - isRead: meta.isRead, - isArchived: meta.isArchived, - project: meta.project ? { uri: meta.project.uri.toString(), displayName: meta.project.displayName } : undefined, - }; -} - -function deserializeMetadata(raw: ISerializedSessionMetadata): IAgentSessionMetadata | undefined { - try { - return { - session: URI.parse(raw.session), - startTime: raw.startTime, - modifiedTime: raw.modifiedTime, - summary: raw.summary, - workingDirectory: raw.workingDirectory ? URI.parse(raw.workingDirectory) : undefined, - isRead: raw.isRead, - isArchived: raw.isArchived ?? raw.isDone, - project: raw.project ? { uri: URI.parse(raw.project.uri), displayName: raw.project.displayName } : undefined, - }; - } catch { - return undefined; - } -} - function toLocalProjectUri(uri: URI, connectionAuthority: string): URI { return uri.scheme === Schemas.file ? toAgentHostUri(uri, connectionAuthority) : uri; } @@ -127,7 +75,7 @@ export interface IRemoteAgentHostSessionsProviderConfig { * - **sessionId** - `{providerId}:{resource}` - the provider-scoped ID used by * {@link ISessionsProvider} methods. * - Protocol operations (e.g. `disposeSession`) use the canonical agent - * session URI (`copilot:///abc123`), reconstructed via {@link AgentSession.uri}. + * session URI (`copilot:///abc123`), reconstructed via `AgentSession.uri`. */ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvider { @@ -173,20 +121,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid private readonly _disconnectOnDemand: (() => Promise<void>) | undefined; /** Storage key used for persisting {@link _sessionCache} snapshots. */ private readonly _storageKey: string; - /** - * Set when {@link _sessionCache} has changed since the last persist. - * The actual write happens on the next `onWillSaveState` signal from - * {@link IStorageService} so that bursts of notifications do not - * repeatedly re-serialize the whole cache. - */ - private _cacheDirty = false; - /** - * Snapshot of the source metadata for each adapter in {@link _sessionCache}, - * keyed by raw session ID. Captured in {@link createAdapter} and re-used by - * {@link _persistCache} to serialize sessions without having to reconstruct - * every `IAgentSessionMetadata` field from observables. - */ - private readonly _metaByRawId = new Map<string, IAgentSessionMetadata>(); /** * When `true`, the provider has been marked unreachable and sessions are * hidden from {@link getSessions}, even though {@link _sessionCache} and @@ -241,29 +175,7 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid listFolders: (query, token) => this._listRemoteFolders(query, token), }]; - this._loadCachedSessions(); - - this._register(this._onDidChangeSessions.event(e => { - if (this._unpublished) { - return; - } - if (e.added.length > 0 || e.removed.length > 0 || e.changed.length > 0) { - this._cacheDirty = true; - } - for (const removed of e.removed) { - const rawId = this._rawIdFromChatId(removed.sessionId); - if (rawId) { - this._metaByRawId.delete(rawId); - } - } - })); - - this._register(this._storageService.onWillSaveState(() => { - if (this._cacheDirty) { - this._persistCache(); - this._cacheDirty = false; - } - })); + this._enableSessionCachePersistence(this._storageKey); } // -- BaseAgentHostSessionsProvider hooks --------------------------------- @@ -272,9 +184,13 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid protected get authenticationPending(): IObservable<boolean> { return this._authenticationPending; } - protected override createAdapter(meta: IAgentSessionMetadata): AgentHostSessionAdapter { - this._metaByRawId.set(AgentSession.id(meta.session), meta); - return super.createAdapter(meta); + /** + * Suspend cache-change tracking while sessions are unpublished (offline) so + * the on-disk snapshot survives an unreachable host. See + * {@link unpublishCachedSessions}. + */ + protected override _shouldTrackSessionCacheChanges(): boolean { + return !this._unpublished; } protected _adapterOptions() { @@ -462,56 +378,6 @@ export class RemoteAgentHostSessionsProvider extends BaseAgentHostSessionsProvid } } - /** Load persisted session summaries into {@link _sessionCache}. */ - private _loadCachedSessions(): void { - const parsed = this._storageService.getObject(this._storageKey, StorageScope.APPLICATION); - if (!Array.isArray(parsed)) { - return; - } - for (const entry of parsed as readonly ISerializedSessionMetadata[]) { - const meta = deserializeMetadata(entry); - if (!meta) { - continue; - } - const rawId = AgentSession.id(meta.session); - if (this._sessionCache.has(rawId)) { - continue; - } - const cached = this.createAdapter(meta); - this._sessionCache.set(rawId, cached); - } - } - - /** - * Persist the current {@link _sessionCache} to storage, capping at - * {@link CACHED_SESSIONS_MAX_PER_HOST} most-recently-modified entries. - * Mutable fields are read from each adapter's observables and overlaid on - * top of the original metadata snapshot captured in {@link _metaByRawId}. - */ - private _persistCache(): void { - const entries: ISerializedSessionMetadata[] = []; - for (const [rawId, adapter] of this._sessionCache) { - const base = this._metaByRawId.get(rawId); - if (!base) { - continue; - } - entries.push(serializeMetadata({ - ...base, - summary: adapter.title.get() || base.summary, - modifiedTime: adapter.updatedAt.get().getTime(), - isRead: adapter.isRead.get(), - isArchived: adapter.isArchived.get(), - })); - } - if (entries.length === 0) { - this._storageService.remove(this._storageKey, StorageScope.APPLICATION); - return; - } - entries.sort((a, b) => b.modifiedTime - a.modifiedTime); - const limited = entries.slice(0, CACHED_SESSIONS_MAX_PER_HOST); - this._storageService.store(this._storageKey, JSON.stringify(limited), StorageScope.APPLICATION, StorageTarget.USER); - } - // -- Session-type sync --------------------------------------------------- protected _formatSessionTypeLabel(agentLabel: string): string { diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts index dc96a33e523866..2ac5fb94b0ae82 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostCustomizationHarness.test.ts @@ -21,7 +21,7 @@ import { PromptsType } from '../../../../../../workbench/contrib/chat/common/pro import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService } from '../../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { SYNCED_CUSTOMIZATION_SCHEME } from '../../../../../../workbench/services/agentHost/common/agentHostFileSystemService.js'; import { RemoteAgentPluginController } from '../../browser/remoteAgentHostCustomizationHarness.js'; import { CustomizationHarnessServiceBase, IHarnessDescriptor } from '../../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; @@ -158,6 +158,9 @@ function createTestCustomAgentsService(connection: MockAgentConnection, rootCust addMcpServer(_sessionResource: URI, _name: string, _config) { // no-op }, + authenticateMcpServer(_sessionResource: URI, _serverId: string) { + return Promise.resolve(false); + }, }; } @@ -722,7 +725,6 @@ suite('RemoteAgentHostCustomizationHarness', () => { id: harnessId, label: 'Remote Agent Host (test)', icon: ThemeIcon.fromId(Codicon.remote.id), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), itemProvider: provider, }; const harnessService = disposables.add(new CustomizationHarnessServiceBase([descriptor], harnessId, new MockPromptsService())); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts index 612b7545505412..37ae211eed59a1 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/remoteAgentHostSessionsProvider.test.ts @@ -23,6 +23,7 @@ import { IDialogService, IFileDialogService } from '../../../../../../platform/d import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IWorkspaceTrustManagementService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IChatWidget, IChatWidgetService } from '../../../../../../workbench/contrib/chat/browser/chat.js'; import { IChatService, type ChatSendResult, type IChatSendRequestOptions } from '../../../../../../workbench/contrib/chat/common/chatService/chatService.js'; @@ -216,6 +217,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne lookupLanguageModel: () => undefined, }); instantiationService.stub(IStorageService, overrides?.storageService ?? disposables.add(new InMemoryStorageService())); + instantiationService.stub(IProgressService, {}); instantiationService.stub(ILabelService, { getUriLabel: (uri: URI) => uri.path, }); @@ -226,6 +228,7 @@ function createProvider(disposables: DisposableStore, connection: MockAgentConne instantiationService.stub(IPullRequestIconCache, instantiationService.createInstance(PullRequestIconCache)); instantiationService.stub(ISessionsService, new class extends mock<ISessionsService>() { override readonly activeSession: IObservable<IActiveSession | undefined> = constObservable<IActiveSession | undefined>(undefined); + override readonly visibleSessions: IObservable<readonly (IActiveSession | undefined)[]> = constObservable<readonly (IActiveSession | undefined)[]>([]); }()); instantiationService.stub(IAgentHostActiveClientService, new class extends mock<IAgentHostActiveClientService>() { override getActiveClient = (_sessionType: string, clientId: string) => ({ clientId, tools: [], customizations: [] }); @@ -263,7 +266,7 @@ async function waitForSessionConfig(provider: RemoteAgentHostSessionsProvider, s }); } -function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string }): void { +function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: { provider?: string; title?: string; project?: { uri: string; displayName: string }; workingDirectory?: string; createdAt?: string; modifiedAt?: string }): void { const provider = opts?.provider ?? 'copilotcli'; const sessionUri = AgentSession.uri(provider, rawId); connection.fireNotification({ @@ -274,8 +277,8 @@ function fireSessionAdded(connection: MockAgentConnection, rawId: string, opts?: provider, title: opts?.title ?? `Session ${rawId}`, status: ProtocolSessionStatus.Idle, - createdAt: new Date().toISOString(), - modifiedAt: new Date().toISOString(), + createdAt: opts?.createdAt ?? new Date().toISOString(), + modifiedAt: opts?.modifiedAt ?? new Date().toISOString(), project: opts?.project, workingDirectory: opts?.workingDirectory, }, @@ -481,8 +484,9 @@ suite('RemoteAgentHostSessionsProvider', () => { const changes: ISessionChangeEvent[] = []; disposables.add(provider.onDidChangeSessions((e: ISessionChangeEvent) => changes.push(e))); - fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); - fireSessionAdded(connection, 'dup-sess', { title: 'Dup' }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(connection, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); + fireSessionAdded(connection, 'dup-sess', { title: 'Dup', createdAt: timestamp, modifiedAt: timestamp }); assert.strictEqual(changes.length, 1); }); @@ -878,6 +882,51 @@ suite('RemoteAgentHostSessionsProvider', () => { ); })); + test('authoritative session update persists materialized workspace metadata', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { + const storageService = disposables.add(new InMemoryStorageService()); + const provider = createProvider(disposables, connection, { storageService }); + const timestamp = new Date(0).toISOString(); + fireSessionAdded(connection, 'persist-upsert', { + title: 'Worktree Session', + project: { uri: 'file:///Users/me/project', displayName: 'project' }, + workingDirectory: 'file:///Users/me/project', + createdAt: timestamp, + modifiedAt: timestamp, + }); + fireSessionAdded(connection, 'persist-upsert', { + title: 'Worktree Session', + project: { uri: 'file:///Users/me/project', displayName: 'project' }, + workingDirectory: 'file:///Users/me/project.worktrees/session', + createdAt: timestamp, + modifiedAt: new Date(1000).toISOString(), + }); + const currentWorkspace = provider.getSessions()[0].workspace.get()!; + + await storageService.flush(); + + const restoredProvider = createProvider(disposables, new MockAgentConnection(), { storageService, noConnection: true }); + const restoredWorkspace = restoredProvider.getSessions()[0].workspace.get()!; + assert.deepStrictEqual({ + current: { + root: currentWorkspace.folders[0].root.path, + workingDirectory: currentWorkspace.folders[0].workingDirectory.path, + }, + restored: { + root: restoredWorkspace.folders[0].root.path, + workingDirectory: restoredWorkspace.folders[0].workingDirectory.path, + }, + }, { + current: { + root: '/Users/me/project', + workingDirectory: '/Users/me/project.worktrees/session', + }, + restored: { + root: '/Users/me/project', + workingDirectory: '/Users/me/project.worktrees/session', + }, + }); + })); + test('setConnection after unpublishCachedSessions restores cached sessions', () => runWithFakedTimers<void>({ useFakeTimers: true }, async () => { connection.addSession(createSession('restore-me', { summary: 'Restore Me' })); const provider = createProvider(disposables, connection); diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css index 04f547dcf785f9..5221910f525693 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/media/sessionInputBanners.css @@ -22,18 +22,26 @@ gap: 8px; min-width: 0; box-sizing: border-box; + position: relative; padding: 4px 6px 4px 10px; border: var(--vscode-strokeThickness) solid var(--vscode-input-border, transparent); border-radius: var(--vscode-cornerRadius-small, 4px); background-color: color-mix(in srgb, var(--vscode-focusBorder) 6%, var(--vscode-editorWidget-background)); font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); + /* Duration of the working/progress border comet animation and its accent + color. The color defaults to the same token used by the chat input's + animated border; the CI (accent-orange) banner overrides it below. */ + --session-input-banner-anim-duration: 4s; + --session-input-banner-working-border-color: var(--vscode-chat-inputWorkingBorderColor1); } /* Orange accent variant (used by the CI failures banner). */ .session-input-banner.accent-orange { border-color: var(--vscode-charts-orange); background-color: color-mix(in srgb, var(--vscode-charts-orange) 6%, var(--vscode-editorWidget-background)); + /* Match the animated border to the orange accent used for CI failures. */ + --session-input-banner-working-border-color: var(--vscode-charts-orange); } /* Leading icon. */ @@ -114,3 +122,84 @@ outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); outline-offset: -1px; } + +/* Animated "border beam" shown around the banner while an action is running + (e.g. the CI "Fix Checks" action, which fetches check annotations before + submitting a prompt). This mirrors the chat input's working border: a bright + comet travels around the perimeter leaving a short fading trail. The ring is + rendered as `::before` (sharp hairline) and `::after` (blurred glow) pseudo- + elements clipped to a hairline with a padding + inverted mask trick, so it + follows the banner's corner radius without disturbing its background. */ +@property --session-input-banner-anim-angle { + syntax: '<angle>'; + inherits: false; + initial-value: 135deg; +} + +@keyframes session-input-banner-working-border-spin { + from { + --session-input-banner-anim-angle: 135deg; + } + + to { + --session-input-banner-anim-angle: 495deg; + } +} + +.session-input-banner.working { + /* Let the comet ring (inset: -1px) show outside the card's edge. */ + overflow: visible; +} + +.session-input-banner.working::before, +.session-input-banner.working::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: inherit; + -webkit-mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + mask: + linear-gradient(#000 0 0) content-box, + linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + animation: session-input-banner-working-border-spin var(--session-input-banner-anim-duration) linear infinite; +} + +/* The beam: a tight bright arc (~40deg) with a short fade on an otherwise + transparent ring. As the angle rotates the bright spot travels like a comet. */ +.session-input-banner.working::before { + padding: 1px; + background: conic-gradient(from var(--session-input-banner-anim-angle), + transparent 0deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 90%, transparent) 20deg, + var(--session-input-banner-working-border-color) 30deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 60%, transparent) 50deg, + transparent 90deg, + transparent 360deg); + z-index: 2; +} + +/* Glow ring: a wider blurred conic sharing the beam's angle, forming a soft + halo that overlaps the beam line directly — no gap. */ +.session-input-banner.working::after { + padding: 2px; + background: conic-gradient(from var(--session-input-banner-anim-angle), + transparent 0deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 60%, transparent) 25deg, + color-mix(in srgb, var(--session-input-banner-working-border-color) 35%, transparent) 50deg, + transparent 90deg, + transparent 360deg); + filter: blur(1.5px); + z-index: 1; +} + +@media (prefers-reduced-motion: reduce) { + .session-input-banner.working::before, + .session-input-banner.working::after { + animation: none; + } +} diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts index 8747d3ea8908e5..81db26b1cfc368 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBannerWidget.ts @@ -8,6 +8,7 @@ import * as dom from '../../../../base/browser/dom.js'; import { Button } from '../../../../base/browser/ui/button/button.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; import type { ThemeIcon } from '../../../../base/common/themables.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; @@ -16,11 +17,22 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { chartsOrange } from '../../../../platform/theme/common/colors/chartsColors.js'; +/** + * Delay before the "working" border animation is shown after an async action + * starts. Actions that settle faster than this don't animate, avoiding a + * loading flicker for very fast work. + */ +const SHOW_WORKING_DELAY_MS = 50; + export interface ISessionInputBannerAction { readonly label: string; /** Renders the action with the prominent button colors. */ readonly primary?: boolean; - run(): void; + /** + * Runs the action. When a {@link Promise} is returned, the banner shows an + * animated "working" border and disables its buttons until it settles. + */ + run(): void | Promise<unknown>; } export interface ISessionInputBanner { @@ -45,6 +57,11 @@ export class SessionInputBannerWidget extends Disposable { readonly domNode: HTMLElement; + private readonly _buttons: Button[] = []; + + /** Guards against overlapping runs while an action is already in flight. */ + private _running = false; + constructor( banner: ISessionInputBanner, @IHoverService private readonly hoverService: IHoverService, @@ -89,7 +106,8 @@ export class SessionInputBannerWidget extends Disposable { button.element.classList.add('session-input-banner-action'); button.label = action.label; button.element.ariaLabel = `${banner.ariaLabel} ${action.label}`; - this._register(button.onDidClick(() => action.run())); + this._buttons.push(button); + this._register(button.onDidClick(() => { void this._runAction(action); })); } const dismiss = dom.append(this.domNode, dom.$('button.session-input-banner-dismiss')) as HTMLButtonElement; @@ -102,4 +120,63 @@ export class SessionInputBannerWidget extends Disposable { banner.dismiss(); })); } + + /** + * Runs an action. When it returns a promise (e.g. the CI "Fix Checks" + * action, which fetches check annotations before submitting a prompt), the + * banner disables its buttons for the duration and shows an animated + * "working" border so the delay is visible to the user. Buttons are disabled + * immediately, but the animation is only shown once the work has been running + * for {@link SHOW_WORKING_DELAY_MS} so very fast actions don't cause a + * loading flicker. Never rejects: action errors are swallowed here since this + * is invoked fire-and-forget from the click handler (the action is + * responsible for surfacing its own errors). + */ + private async _runAction(action: ISessionInputBannerAction): Promise<void> { + if (this._running) { + return; + } + let result: void | Promise<unknown>; + try { + result = action.run(); + } catch { + return; + } + if (!result) { + return; + } + this._running = true; + // Disable the buttons immediately while the action is pending, but delay + // showing the animated border so very fast actions don't flicker. + this._setButtonsEnabled(false); + const showAnimation = disposableTimeout(() => this.domNode.classList.add('working'), SHOW_WORKING_DELAY_MS); + try { + await result; + } catch { + // Swallow: the action logs/surfaces its own errors and this handler + // is fire-and-forget, so it must not produce an unhandled rejection. + } finally { + showAnimation.dispose(); + this.domNode.classList.remove('working'); + this._setButtonsEnabled(true); + this._running = false; + } + } + + /** + * Renders the in-flight "working" state: shows the animated border and + * disables the action buttons. Intended for fixtures/tests that need to + * display the loading appearance statically; production toggles this state + * via {@link _runAction} (which additionally delays the animation). + */ + setWorking(working: boolean): void { + this.domNode.classList.toggle('working', working); + this._setButtonsEnabled(!working); + } + + private _setButtonsEnabled(enabled: boolean): void { + for (const button of this._buttons) { + button.enabled = enabled; + } + } } diff --git a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts index 8fbc94e00e6bfe..25c33f140a034c 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/browser/sessionInputBanners.ts @@ -40,8 +40,6 @@ interface ICIBannerState { readonly completed: number; /** Number of checks still running or queued. */ readonly pending: number; - /** Whether the user already requested a CI fix for the current PR head commit. */ - readonly fixRequested: boolean; } interface ICommentsBannerState { @@ -104,6 +102,11 @@ export class SessionInputBanners extends Disposable { if (!ciModel) { return undefined; } + // Once the user has requested a CI fix for the current PR head commit, + // hide the entire banner until a new commit lands on the PR. + if (ciModel.fixRequested.read(reader)) { + return undefined; + } const checks = ciModel.checks.read(reader); const failed = getFailedChecks(checks).length; if (failed === 0) { @@ -111,7 +114,7 @@ export class SessionInputBanners extends Disposable { } const completed = checks.filter(check => check.status === GitHubCheckStatus.Completed).length; const pending = checks.length - completed; - return { sessionId: session.sessionId, failed, completed, pending, fixRequested: ciModel.fixRequested.read(reader) }; + return { sessionId: session.sessionId, failed, completed, pending }; }); private readonly _commentsState: IObservable<ICommentsBannerState | undefined> = derived(this, reader => { @@ -188,14 +191,14 @@ export class SessionInputBanners extends Disposable { ariaLabel: text, dismissTooltip: localize('ci.dismiss', "Hide for this session"), actions: [ - ...(state.fixRequested ? [] : [{ + { label: localize('ci.fixChecks', "Fix Checks"), primary: true, run: () => this._executeCommand(FIX_CI_CHECKS_COMMAND_ID), - }]), + }, { label: localize('ci.revealChecks', "Reveal Checks"), - run: () => this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID), + run: () => { void this._executeCommand(REVEAL_CI_CHECKS_COMMAND_ID); }, }, ], dismiss: () => this._dismiss(STORAGE_KEY_CI_DISMISSED, this._ciDismissed, state.sessionId), @@ -254,8 +257,12 @@ export class SessionInputBanners extends Disposable { } } - private _executeCommand(commandId: string): void { - this.commandService.executeCommand(commandId).catch(err => this.logService.error('[SessionInputBanners] command failed', commandId, err)); + private async _executeCommand(commandId: string): Promise<void> { + try { + await this.commandService.executeCommand(commandId); + } catch (err) { + this.logService.error('[SessionInputBanners] command failed', commandId, err); + } } private async _addressComments(sessionResource: URI): Promise<void> { diff --git a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts index a4a0840593bf23..44602c64f1e292 100644 --- a/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts +++ b/src/vs/sessions/contrib/sessionInputBanners/test/browser/sessionInputBanners.fixture.ts @@ -13,6 +13,11 @@ export default defineThemedFixtureGroup({ path: 'sessions/inputBanners/' }, { render: (context) => renderBanners(context, [ciBanner(2, 5, 3)]), }), + CIFailuresLoading: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (context) => renderBanners(context, [ciBanner(2, 5, 3)], 480, true), + }), + Comments: defineComponentFixture({ labels: { kind: 'screenshot' }, render: (context) => renderBanners(context, [commentsBanner(3, 'mixed')]), @@ -73,7 +78,7 @@ function commentsBanner(count: number, kind: 'pr' | 'agent' | 'mixed'): ISession }; } -function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480): void { +function renderBanners({ container, disposableStore, theme }: ComponentFixtureContext, banners: readonly ISessionInputBanner[], width = 480, working = false): void { container.style.width = `${width}px`; container.style.display = 'flex'; container.style.flexDirection = 'column'; @@ -85,6 +90,7 @@ function renderBanners({ container, disposableStore, theme }: ComponentFixtureCo for (const banner of banners) { const widget = disposableStore.add(instantiationService.createInstance(SessionInputBannerWidget, banner)); + widget.setWorking(working); container.appendChild(widget.domNode); } } diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts index b08800b8259a73..51d7a70c26f523 100644 --- a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -15,6 +15,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { DomScrollableElement } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; import { IAICustomizationItemsModel } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; @@ -23,6 +24,7 @@ import { CUSTOMIZATION_ITEMS } from './customizationsToolbar.contribution.js'; import { Menus } from '../../../browser/menus.js'; const $ = DOM.$; const CUSTOMIZATIONS_VERTICAL_PADDING = 6; +const CUSTOMIZATIONS_COLLAPSED_STORAGE_KEY = 'agentSessions.customizationsShortcuts.collapsed'; export interface IAICustomizationShortcutsWidgetOptions { readonly onDidChangeLayout?: () => void; @@ -66,9 +68,12 @@ export class AICustomizationShortcutsWidget extends Disposable { @IMcpService private readonly mcpService: IMcpService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IStorageService private readonly storageService: IStorageService, ) { super(); + this._collapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_STORAGE_KEY, StorageScope.PROFILE, false); + // Stable wrapper appended once to the parent. Re-renders replace the // wrapper's children only, so the widget keeps its position relative // to sibling parts (e.g. the agent-host-toolbar below it). Without @@ -93,9 +98,9 @@ export class AICustomizationShortcutsWidget extends Disposable { this._scrollableDomNode = undefined; this._rootVerticalPadding = 0; this._headerTotalCount = 0; - this._collapsed = false; DOM.clearNode(this._wrapper); this._render(this._wrapper, this._options); + this._setCollapsed(this._collapsed); } private _totalCount() { @@ -201,6 +206,7 @@ export class AICustomizationShortcutsWidget extends Disposable { private _toggleCollapsed(): void { this._setCollapsed(!this._collapsed); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_STORAGE_KEY, this._collapsed, StorageScope.PROFILE, StorageTarget.USER); this._onDidToggleCollapsed.fire(this._collapsed); this._onDidChangeHeight.fire(); } diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts new file mode 100644 index 00000000000000..5ede62e9ef42e1 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsIndicatorModel.ts @@ -0,0 +1,355 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { autorun, derived, IObservable, IReader, observableValue } from '../../../../base/common/observable.js'; +import { localize } from '../../../../nls.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprovalId } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../blockedSessions/browser/blockedSessions.js'; +import { getFirstApprovalAcrossChats, IApprovedSession } from './views/sessionsList.js'; + +/** + * The specific reason a homogeneous set of blocked sessions needs attention, + * used to render a more helpful requires-input message. `undefined` (a mix of + * reasons, or an indeterminate one) falls back to the generic message. + */ +export const enum RequiresInputKind { + /** All sessions are waiting to run a terminal command. */ + TerminalApproval, + /** All sessions are asking the user a question. */ + Question, + /** All sessions have failing CI checks. */ + FailingCI, + /** All sessions have unresolved pull request comments. */ + UnresolvedComments, +} + +/** + * Model behind the sessions title bar's "N sessions require input" indicator. + * + * It refines the raw {@link BlockedSessions} set into what the title bar should + * actually surface: it drops sessions the user can already see, applies optimistic + * dismissals for approvals the user just allowed, classifies the homogeneous + * requires-input reason, and decides when the attention blink should play. + * + * Blink detection deliberately keys off the underlying model's blocked-session + * ids (independent of visibility) so the blink fires only when a session + * *genuinely* becomes blocked — never merely because the user navigated to a + * different session, which changes the visible set but not the model. + * + * The DOM rendering of the indicator lives in the title bar widget; this class is + * DOM-free so it can be unit tested in isolation. + */ +export class BlockedSessionsIndicatorModel extends Disposable { + + /** Computes the raw set of blocked sessions (needs input / failing CI / comments). */ + private readonly _blockedSessionsModel: BlockedSessions; + + /** Tracks pending tool approvals per chat; distinguishes terminal vs question. */ + private readonly _approvalModel: AgentSessionApprovalModel; + + /** The approval model, shared with the dropdown list so both agree on each session's pending action. */ + get approvalModel(): AgentSessionApprovalModel { + return this._approvalModel; + } + + /** + * Sessions whose current pending approval the user just allowed, keyed by + * `sessionId` → the approved approval's identity. Such a session is optimistically + * hidden from the blocked set until its approval resolves into a NEW distinct + * block (or it stops being blocked), so an approved row disappears immediately + * instead of lingering until the provider updates the session status. + */ + private readonly _dismissedApprovals = observableValue<ReadonlyMap<string, string>>('dismissedApprovals', new Map()); + + /** + * Blocked sessions that are NOT currently visible on screen and not optimistically + * dismissed. A session the user can already see doesn't need the titlebar indicator + * or a dropdown row, so it is excluded from both the "N sessions require input" count + * and the list. + */ + readonly blockedSessions: IObservable<readonly IBlockedSession[]>; + + /** + * The homogeneous reason the blocked sessions need attention (all terminal + * approvals, all failing CI, etc.), or `undefined` when they are a mix — which + * drives whether a specific or the generic requires-input message is shown. + */ + readonly requiresInputKind: IObservable<RequiresInputKind | undefined>; + + /** + * Ids of the sessions the underlying model reports as blocked, kept in sync + * with the model (independent of which sessions happen to be visible). Used to + * detect when a *genuinely new* session becomes blocked so the attention blink + * only fires for real new blocks — and never merely because the user navigated + * to a different session, which changes the visible set but not the model. + */ + private _lastBlockedSessionIds: ReadonlySet<string> = new Set(); + + /** + * Ids of sessions that genuinely became blocked while not visible and whose + * attention blink hasn't played yet. Keyed by session id (rather than a single + * flag) so a blink queued while the pill is suppressed — e.g. during the transient + * "Approved N sessions" state — can't later fire for a session that has since + * become visible or stopped being blocked. {@link consumePendingBlink} only blinks + * while at least one pending id is still in the surfaced blocked set. + */ + private readonly _pendingBlinkSessionIds = new Set<string>(); + + private readonly _onDidRequestBlink = this._register(new Emitter<void>()); + /** + * Fires when a genuinely new, not-yet-visible session becomes blocked and the + * indicator should play its attention blink. Consumers should re-render and + * call {@link consumePendingBlink}. + */ + readonly onDidRequestBlink: Event<void> = this._onDidRequestBlink.event; + + constructor( + approvalModel: AgentSessionApprovalModel | undefined, + blockedSessions: BlockedSessions | undefined, + @ISessionsService private readonly _sessionsService: ISessionsService, + @IInstantiationService instantiationService: IInstantiationService, + @IProductService productService: IProductService, + ) { + super(); + + // The model owns the approval model and blocked-sessions model; the optional + // parameters are test seams so fixtures/tests can supply preset instances (only + // register — and thus dispose — the ones we created ourselves). + this._approvalModel = approvalModel ?? this._register(instantiationService.createInstance(AgentSessionApprovalModel)); + this._blockedSessionsModel = blockedSessions ?? this._register(instantiationService.createInstance(BlockedSessions)); + + // The blocked-sessions feature is only enabled outside of stable builds. + const enabled = productService.quality !== 'stable'; + + // A session that is currently visible on screen is not treated as blocked: + // exclude visible sessions from the requires-input indicator and the dropdown. + this.blockedSessions = derived(this, reader => { + if (!enabled) { + return []; + } + const visibleSessionIds = new Set<string>(); + for (const session of this._sessionsService.visibleSessions.read(reader)) { + if (session) { + visibleSessionIds.add(session.sessionId); + } + } + const dismissed = this._dismissedApprovals.read(reader); + return this._blockedSessionsModel.blockedSessionsWithReasons.read(reader) + .filter(blocked => !visibleSessionIds.has(blocked.session.sessionId) && !this._isApprovalDismissed(blocked, dismissed, reader)); + }); + + // The homogeneous reason across all blocked sessions (or `undefined` for a + // mix), refining `NeedsInput` into terminal-approval vs question via the + // approval model. Drives the specific requires-input message. + this.requiresInputKind = derived(this, reader => { + const blocked = this.blockedSessions.read(reader); + if (blocked.length === 0) { + return undefined; + } + let common: RequiresInputKind | undefined; + let hasCommon = false; + for (const entry of blocked) { + const kind = this._kindOf(entry, reader); + if (kind === undefined) { + return undefined; + } + if (!hasCommon) { + common = kind; + hasCommon = true; + } else if (common !== kind) { + return undefined; + } + } + return common; + }); + + // Drop optimistic dismissals once the session is no longer blocked or its + // pending approval has been superseded by a new, distinct one — so a stale + // dismissal can't keep hiding a genuinely new block. + this._register(autorun(reader => { + const dismissed = this._dismissedApprovals.read(reader); + if (dismissed.size === 0) { + return; + } + const blockedById = new Map(this._blockedSessionsModel.blockedSessionsWithReasons.read(reader).map(blocked => [blocked.session.sessionId, blocked] as const)); + let next: Map<string, string> | undefined; + for (const [sessionId, approvalId] of dismissed) { + const blocked = blockedById.get(sessionId); + let stale: boolean; + if (!blocked || blocked.reason !== BlockedSessionReason.NeedsInput) { + stale = true; + } else { + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + stale = approval !== undefined && agentSessionApprovalId(approval) !== approvalId; + } + if (stale) { + next ??= new Map(dismissed); + next.delete(sessionId); + } + } + if (next) { + this._dismissedApprovals.set(next, undefined); + } + })); + + // Detect genuinely new blocks to drive the attention blink. This watches the + // underlying model's blocked-session ids (via `read`), so it fires only when + // the set of sessions needing input actually changes — never when the user + // merely navigates to a different session (which changes the visible set, not + // the model). Visibility is read untracked so a newly-blocked session that is + // already on screen is recorded without blinking; a later navigation that + // surfaces it in the pill then won't blink either. + this._register(autorun(reader => { + if (!enabled) { + return; + } + const modelBlocked = this._blockedSessionsModel.blockedSessions.read(reader); + const currentIds = new Set(modelBlocked.map(session => session.sessionId)); + const previousIds = this._lastBlockedSessionIds; + this._lastBlockedSessionIds = currentIds; + + // Drop queued blinks for sessions that are no longer blocked, so a stale + // pending id can never blink after its session left the blocked set. + for (const id of this._pendingBlinkSessionIds) { + if (!currentIds.has(id)) { + this._pendingBlinkSessionIds.delete(id); + } + } + + const newlyBlocked = modelBlocked.filter(session => !previousIds.has(session.sessionId)); + if (newlyBlocked.length === 0) { + return; + } + + const visibleSessionIds = new Set<string>(); + // Untracked: a visibility change alone must not re-run this autorun (that is + // exactly the navigation case that should never blink); only a change to the + // underlying blocked set should. + for (const session of this._sessionsService.visibleSessions.read(undefined)) { + if (session) { + visibleSessionIds.add(session.sessionId); + } + } + let queued = false; + for (const session of newlyBlocked) { + if (!visibleSessionIds.has(session.sessionId)) { + this._pendingBlinkSessionIds.add(session.sessionId); + queued = true; + } + } + if (queued) { + this._onDidRequestBlink.fire(); + } + })); + } + + /** + * Whether a fresh attention blink is pending. Returns `true` only when a session + * queued as newly blocked is still in the surfaced (visible-filtered) blocked set, + * so a blink queued while the pill was suppressed can't fire for a session that has + * since become visible or unblocked. The pending queue is cleared as it is read so + * a subsequent render won't replay the animation. + */ + consumePendingBlink(): boolean { + if (this._pendingBlinkSessionIds.size === 0) { + return false; + } + const surfacedIds = new Set(this.blockedSessions.get().map(entry => entry.session.sessionId)); + let shouldBlink = false; + for (const id of this._pendingBlinkSessionIds) { + if (surfacedIds.has(id)) { + shouldBlink = true; + break; + } + } + this._pendingBlinkSessionIds.clear(); + return shouldBlink; + } + + /** + * Remember that the user allowed this exact approval so the session drops out of + * the blocked set immediately (see {@link _isApprovalDismissed}). + */ + dismissApproval(approved: IApprovedSession): void { + const next = new Map(this._dismissedApprovals.get()); + next.set(approved.session.sessionId, approved.approvalId); + this._dismissedApprovals.set(next, undefined); + } + + /** + * Build the requires-input pill label. A homogeneous set of blocked sessions + * gets a specific, more actionable message; a mix (or an unclassified session) + * falls back to the generic "N sessions require input". + */ + getRequiresInputLabel(count: number, kind: RequiresInputKind | undefined): string { + switch (kind) { + case RequiresInputKind.TerminalApproval: + return count === 1 + ? localize('oneSessionTerminalApproval', "1 session requires terminal approval") + : localize('nSessionsTerminalApproval', "{0} sessions require terminal approval", count); + case RequiresInputKind.Question: + return count === 1 + ? localize('oneSessionQuestion', "1 session has a question") + : localize('nSessionsQuestion', "{0} sessions have questions", count); + case RequiresInputKind.FailingCI: + return count === 1 + ? localize('oneSessionFailingCI', "1 session is failing CI") + : localize('nSessionsFailingCI', "{0} sessions are failing CI", count); + case RequiresInputKind.UnresolvedComments: + return count === 1 + ? localize('oneSessionUnresolvedComments', "1 session has unresolved comments") + : localize('nSessionsUnresolvedComments', "{0} sessions have unresolved comments", count); + default: + return count === 1 + ? localize('oneSessionRequiresInput', "1 session requires input") + : localize('nSessionsRequireInput', "{0} sessions require input", count); + } + } + + /** + * Whether a blocked session should stay hidden because the user just approved + * its pending action: hidden while that approval resolves (no current approval, + * status lagging) or is unchanged; a new, distinct approval re-surfaces it. + */ + private _isApprovalDismissed(blocked: IBlockedSession, dismissed: ReadonlyMap<string, string>, reader: IReader): boolean { + const dismissedId = dismissed.get(blocked.session.sessionId); + if (dismissedId === undefined || blocked.reason !== BlockedSessionReason.NeedsInput) { + return false; + } + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + return approval === undefined || agentSessionApprovalId(approval) === dismissedId; + } + + /** + * Classify a single blocked session into a specific requires-input kind, or + * `undefined` when it can't be classified (which forces the generic message). + */ + private _kindOf(blocked: IBlockedSession, reader: IReader): RequiresInputKind | undefined { + switch (blocked.reason) { + case BlockedSessionReason.FailingCI: + return RequiresInputKind.FailingCI; + case BlockedSessionReason.UnresolvedComments: + return RequiresInputKind.UnresolvedComments; + case BlockedSessionReason.NeedsInput: { + const approval = getFirstApprovalAcrossChats(this._approvalModel, blocked.session, reader); + switch (approval?.kind) { + case AgentSessionApprovalKind.Terminal: + return RequiresInputKind.TerminalApproval; + case AgentSessionApprovalKind.Question: + return RequiresInputKind.Question; + default: + return undefined; + } + } + default: + return undefined; + } + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts new file mode 100644 index 00000000000000..b87dd8520eab24 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/blockedSessionsList.ts @@ -0,0 +1,129 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/blockedSessionsList.css'; +import { $, append } from '../../../../base/browser/dom.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { URI } from '../../../../base/common/uri.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { Menus } from '../../../browser/menus.js'; +import { ISession } from '../../../services/sessions/common/session.js'; +import { IApprovedSession, SessionsFlatList } from './views/sessionsList.js'; +import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; + +/** Fixed width of the blocked-sessions list, in pixels. */ +const BLOCKED_LIST_WIDTH = 360; +/** Maximum number of rows shown before the list scrolls. */ +const BLOCKED_LIST_MAX_VISIBLE_ROWS = 8; +/** Maximum number of terminal-command lines shown in a session's approval prompt. */ +const BLOCKED_LIST_APPROVAL_ROW_MAX_LINES = 5; + +export interface IBlockedSessionsListOptions { + /** Invoked when a session row is activated (clicked or opened via keyboard). */ + readonly onSessionOpen: (resource: URI, preserveFocus: boolean, sideBySide: boolean) => void; + /** Width of the list, in pixels. Defaults to a fixed width when omitted. */ + readonly width?: number; + /** Approval model forwarded to the underlying list (see {@link ISessionsFlatListOptions.approvalModel}). */ + readonly approvalModel?: AgentSessionApprovalModel; +} + +/** + * A self-sizing, flat list of blocked sessions. + * + * Wraps {@link SessionsFlatList} with the fixed width and bounded height used by + * the blocked-sessions dropdown in the sessions titlebar. Hosts append it to a + * container, push sessions via {@link setSessions}, and listen to + * {@link onDidChangeContentHeight} to reposition the surrounding surface (e.g. a + * context view) as rows resolve their heights. + */ +export class BlockedSessionsList extends Disposable { + + private readonly _onDidChangeContentHeight = this._register(new Emitter<void>()); + /** Fires when the list resizes and the host should re-layout its container. */ + readonly onDidChangeContentHeight: Event<void> = this._onDidChangeContentHeight.event; + + private readonly _onDidApproveSession = this._register(new Emitter<IApprovedSession>()); + /** Fires when a session's pending action is approved from its "Allow" button. */ + readonly onDidApproveSession: Event<IApprovedSession> = this._onDidApproveSession.event; + + private readonly _rowsContainer: HTMLElement; + private readonly _list: SessionsFlatList; + private _width: number; + + constructor( + container: HTMLElement, + options: IBlockedSessionsListOptions, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + this._width = options.width ?? BLOCKED_LIST_WIDTH; + + const element = append(container, $('.agent-sessions-blocked-list')); + + // Header row: a title on the left and a toolbar of contributed actions on the + // right (e.g. the action that opens the full sessions picker). + const header = append(element, $('.agent-sessions-blocked-list-header')); + const title = append(header, $('.agent-sessions-blocked-list-title')); + title.textContent = localize('sessionsRequiringInput', "Sessions requiring input"); + const headerActions = append(header, $('.agent-sessions-blocked-list-header-actions')); + this._register(instantiationService.createInstance(MenuWorkbenchToolBar, headerActions, Menus.BlockedSessionsHeader, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'blockedSessionsList.header', + })); + + this._rowsContainer = append(element, $('.agent-sessions-blocked-list-rows')); + + this._list = this._register(instantiationService.createInstance(SessionsFlatList, this._rowsContainer, { + showSessionHover: true, + onSessionOpen: options.onSessionOpen, + approvalModel: options.approvalModel, + approvalRowMaxLines: BLOCKED_LIST_APPROVAL_ROW_MAX_LINES, + toolbarActions: false, + })); + + this._register(this._list.onDidChangeContentHeight(() => { + this._layout(); + this._onDidChangeContentHeight.fire(); + })); + + this._register(this._list.onDidApproveSession(approved => this._onDidApproveSession.fire(approved))); + } + + /** Replace the sessions shown in the list and resize to fit their content. */ + setSessions(sessions: readonly ISession[]): void { + this._list.setSessions(sessions); + this._layout(); + } + + /** Move keyboard focus into the list. */ + focus(): void { + this._list.focus(); + } + + /** + * Update the list width (e.g. when the anchoring widget reflows as the window + * resizes) and re-layout to the new width. + */ + setWidth(width: number): void { + if (this._width === width) { + return; + } + this._width = width; + this._layout(); + } + + private _layout(): void { + const maxHeight = BLOCKED_LIST_MAX_VISIBLE_ROWS * this._list.getRowHeight(); + const height = Math.min(this._list.getContentHeight(), maxHeight); + this._rowsContainer.style.width = `${this._width}px`; + this._rowsContainer.style.height = `${height}px`; + this._list.layout(height, this._width); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index 0fdd7ba11eae9b..db835074eaab50 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { localize } from '../../../../nls.js'; import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; -import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { ContextKeyExpr, ContextKeyExpression, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.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'; @@ -21,7 +21,7 @@ import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.j import { ILanguageModelToolsService } from '../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js'; import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE, countEnabledCustomizationTools, IAgentHostToolSetEnablementService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostToolSetEnablementService.js'; import { Menus } from '../../../browser/menus.js'; -import { agentIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, hookIcon, toolsIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; +import { agentIcon, automationIcon, instructionsIcon, mcpServerIcon, pluginIcon, skillIcon, hookIcon, toolsIcon } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.js'; import { ActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IAction } from '../../../../base/common/actions.js'; import { $, append } from '../../../../base/browser/dom.js'; @@ -31,6 +31,8 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js'; import { AICustomizationManagementSection } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { ChatAutomationsEnabledContext } from '../../../../workbench/contrib/chat/common/automations/automationsEnabled.js'; +import { IAutomationService } from '../../../../workbench/contrib/chat/common/automations/automationService.js'; import { ICustomizationHarnessService } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -45,6 +47,9 @@ export interface ICustomizationItemConfig { readonly isMcp?: boolean; readonly isPlugins?: boolean; readonly isTools?: boolean; + readonly isAutomations?: boolean; + /** Additional `when` clause beyond the standard harness-visibility gate. */ + readonly when?: ContextKeyExpression; } /** @@ -92,6 +97,14 @@ export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ section: AICustomizationManagementSection.Hooks, modelSection: AICustomizationManagementSection.Hooks, }, + { + id: 'sessions.customization.automations', + label: localize('automations', "Automations"), + icon: automationIcon, + section: AICustomizationManagementSection.Automations, + isAutomations: true, + when: ChatAutomationsEnabledContext, + }, { id: 'sessions.customization.mcpServers', label: localize('mcpServers', "MCP Servers"), @@ -161,6 +174,7 @@ export class CustomizationLinkViewItem extends ActionViewItem { @IMcpService private readonly _mcpService: IMcpService, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IAgentHostToolSetEnablementService private readonly _toolEnablementService: IAgentHostToolSetEnablementService, + @IAutomationService private readonly _automationService: IAutomationService, ) { super(undefined, action, { ...options, icon: false, label: false }); this._viewItemDisposables = this._register(new DisposableStore()); @@ -219,6 +233,9 @@ export class CustomizationLinkViewItem extends ActionViewItem { const toolSets = this._toolsService.toolSets.read(reader); return countEnabledCustomizationTools(toolSets, state, reader); } + if (this._config.isAutomations) { + return this._automationService.automations.read(reader).length; + } return 0; } @@ -309,6 +326,9 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo }, undefined)); const sectionVisibleWhen = ContextKeyExpr.has(customizationSectionVisibleKey(section)); + const combinedWhen = config.when + ? ContextKeyExpr.and(ChatContextKeys.enabled, sectionVisibleWhen, config.when) + : ContextKeyExpr.and(ChatContextKeys.enabled, sectionVisibleWhen); // Register the action with menu item this._register(registerAction2(class extends Action2 { @@ -320,7 +340,7 @@ export class CustomizationsToolbarContribution extends Disposable implements IWo id: Menus.SidebarCustomizations, group: 'navigation', order: index + 1, - when: ContextKeyExpr.and(ChatContextKeys.enabled, sectionVisibleWhen), + when: combinedWhen, } }); } diff --git a/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css new file mode 100644 index 00000000000000..4df537c4d9517f --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/media/blockedSessionsList.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* Blocked-sessions list, shown below the command center box via the context view. */ +.agent-sessions-blocked-list { + display: flex; + flex-direction: column; + /* A small gap so the dropdown reads as separate from the command center box. */ + margin-top: var(--vscode-spacing-size60); + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-editorWidget-border); + border-radius: var(--vscode-cornerRadius-medium); + box-shadow: 0 2px 8px var(--vscode-widget-shadow); + overflow: hidden; +} + +/* Header row: title on the left, a toolbar of contributed actions on the right. */ +.agent-sessions-blocked-list .agent-sessions-blocked-list-header { + display: flex; + align-items: center; + gap: var(--vscode-spacing-size40); + padding: var(--vscode-spacing-size40) var(--vscode-spacing-size40) var(--vscode-spacing-size40) var(--vscode-spacing-size100); + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-header-actions { + display: flex; + align-items: center; + flex-shrink: 0; + margin-left: auto; +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-rows { + overflow: hidden; +} + +.agent-sessions-blocked-list .agent-sessions-blocked-list-rows .sessions-list-control .monaco-list-row:has(.session-item) { + border-radius: 0; + margin: 0; + width: 100%; +} + +/* Requires-input state of the sessions titlebar widget: shown when the primary + side bar is hidden and at least one session is blocked. The command center box + (owned by SessionsTitleBarWidget) adopts an orange accent. */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input { + color: var(--vscode-charts-orange); + border-color: var(--vscode-charts-orange); + background-color: color-mix(in srgb, var(--vscode-charts-orange) 14%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input:hover { + color: var(--vscode-charts-orange); + border-color: var(--vscode-charts-orange); + background-color: color-mix(in srgb, var(--vscode-charts-orange) 22%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input:focus { + outline-color: var(--vscode-charts-orange); +} + +/* The requires-input pill fills the box and centers its label (no dimming). */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input .agent-sessions-titlebar-pill { + opacity: 1; + justify-content: center; +} + +.command-center .agent-sessions-titlebar-requires-input-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); +} + +/* Attention blink: pulses the orange fill twice when a new session becomes blocked. */ +@keyframes agent-sessions-titlebar-attention-blink { + 0%, + 100% { + background-color: color-mix(in srgb, var(--vscode-charts-orange) 14%, transparent); + } + + 50% { + background-color: color-mix(in srgb, var(--vscode-charts-orange) 45%, transparent); + } +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input.agent-sessions-titlebar-blink { + animation: agent-sessions-titlebar-attention-blink 450ms ease-in-out 2; +} + +@media (prefers-reduced-motion: reduce) { + .command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-requires-input.agent-sessions-titlebar-blink { + animation: none; + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css index f8e5781a9139ae..9ffd7166b5fdce 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsList.css @@ -344,6 +344,28 @@ color: var(--vscode-foreground); } +/* Muted placeholder row for the empty "Chats" section, aligned with chat titles. */ +.session-placeholder { + display: flex; + align-items: center; + /* Match .session-item: left padding (12px) + status icon (16px) + .session-main padding (6px). */ + padding: 0 12px; + font-size: var(--vscode-agents-fontSize-label2, 11px); + color: var(--vscode-descriptionForeground); + min-height: 26px; + cursor: default; + + .session-placeholder-label { + flex: 1 1 auto; + min-width: 0; + padding-left: 22px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + opacity: 0.8; + } +} + /* Folders show-more/show-less: align with workspace section headers. */ .session-show-more.session-show-more-folders { justify-content: flex-start; @@ -378,6 +400,12 @@ white-space: nowrap; } + .session-section-icon { + flex-shrink: 0; + margin-right: 6px; + font-size: var(--vscode-codiconFontSize, 16px); + } + .session-section-count { flex-shrink: 0; opacity: 0.7; diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 9c5d90629b698f..34763936453b50 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -140,3 +140,32 @@ transition: none; } } + +/* Approved state: a transient green confirmation shown after the user approves + one or more sessions' pending actions from the sessions list. It stays + clickable (activating whatever the underlying state would do). */ +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved { + color: var(--vscode-charts-green); + border-color: var(--vscode-charts-green); + background-color: color-mix(in srgb, var(--vscode-charts-green) 14%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved:hover { + color: var(--vscode-charts-green); + border-color: var(--vscode-charts-green); + background-color: color-mix(in srgb, var(--vscode-charts-green) 22%, transparent); +} + +.command-center .agent-sessions-titlebar-container.agent-sessions-titlebar-approved .agent-sessions-titlebar-pill { + opacity: 1; + justify-content: center; +} + +.command-center .agent-sessions-titlebar-approved-label { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: var(--vscode-agents-fontWeight-semiBold); +} diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index 3f4c782ca19dcf..f4b8d78d423c16 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -113,11 +113,14 @@ .agent-sessions-header-actions { display: flex; align-items: center; - gap: 4px; margin-left: auto; flex-shrink: 0; } + .agent-sessions-header-actions .actions-container { + gap: 4px; + } + /* Inline search input — replaces label + actions when active */ .agent-sessions-find-widget-container { flex: 1; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts new file mode 100644 index 00000000000000..ef00388d01e9c3 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/sessionActionFeedback.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IObservable, observableValue } from '../../../../base/common/observable.js'; + +/** + * Tracks short-lived, user-facing feedback for actions taken on individual + * session items in the sessions list (e.g. approving a tool invocation). + * + * When a session's pending action is approved, {@link approvedCount} briefly + * reflects how many sessions were approved within a rolling window; each new + * approval increments the count and restarts the window. The sessions titlebar + * widget owns an instance and surfaces this as a transient "Approved N sessions" + * message. + */ +export class SessionActionFeedback extends Disposable { + + /** How long the "Approved N sessions" message stays visible, in milliseconds. */ + private static readonly WINDOW_MS = 3000; + + private readonly _approvedCount = observableValue<number>('approvedCount', 0); + /** Number of sessions approved within the current window; `0` when idle. */ + readonly approvedCount: IObservable<number> = this._approvedCount; + + private readonly _resetScheduler = this._register(new RunOnceScheduler( + () => this._approvedCount.set(0, undefined), + SessionActionFeedback.WINDOW_MS, + )); + + /** Report that a session's pending action was approved. Restarts the window. */ + notifyApproved(): void { + this._approvedCount.set(this._approvedCount.get() + 1, undefined); + this._resetScheduler.schedule(); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts index f330fe0f06c34e..bde57c95880ac9 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionHoverContent.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../nls.js'; import { asCssVariable } from '../../../../platform/theme/common/colorUtils.js'; import { chatLinesAddedForeground, chatLinesRemovedForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; -import { ISession } from '../../../services/sessions/common/session.js'; +import { getUntitledSessionTitle, ISession } from '../../../services/sessions/common/session.js'; /** * Aggregated insertions/deletions across all of a session's changes, @@ -52,7 +52,7 @@ export function buildSessionHoverContent( const md = new MarkdownString('', { supportThemeIcons: true, supportHtml: true }); // Line 1: session icon + bold title - const title = session.title.get() || localize('agentSessions.newSession', "New Session"); + const title = session.title.get() || getUntitledSessionTitle(session.isQuickChat?.get() ?? false); if (session.icon) { md.appendMarkdown(`$(${session.icon.id}) `); } diff --git a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts index 8bec0b3898cd42..f43135bdf5f213 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessions.contribution.ts @@ -18,6 +18,8 @@ import { SessionConversationsMenuContribution, SessionNewChatActionViewItemContr import { SessionsView, SessionsViewId } from './views/sessionsView.js'; import './views/sessionsViewActions.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { Extensions as ConfigurationExtensions, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING } from './views/sessionsList.js'; const agentSessionsViewIcon = registerIcon('chat-sessions-icon', Codicon.commentDiscussionSparkle, localize('agentSessionsViewIcon', 'Icon for Agent Sessions View')); const AGENT_SESSIONS_VIEW_TITLE = localize2('agentSessions.view.label', "Sessions"); @@ -54,6 +56,19 @@ const sessionsViewPaneDescriptor: IViewDescriptor = { Registry.as<IViewsRegistry>(ViewContainerExtensions.ViewsRegistry).registerViews([sessionsViewPaneDescriptor], agentSessionsViewContainer); +Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).registerConfiguration({ + id: 'sessions', + properties: { + [SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING]: { + type: 'boolean', + tags: ['preview'], + description: localize('sessions.list.showEmptyDefaultGroups', "Controls whether the default groups (Pinned, Chats) are shown in the sessions list even when they are empty."), + default: true, + experiment: { mode: 'auto' } + }, + }, +}); + registerWorkbenchContribution2(SessionsTitleBarContribution.ID, SessionsTitleBarContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(NewSessionActionViewItemContribution.ID, NewSessionActionViewItemContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(SessionsTelemetryContribution.ID, SessionsTelemetryContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts index db945c9ae68987..3113dafe917059 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsActions.ts @@ -15,6 +15,7 @@ import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuRegistry, MenuId, registerAction2, MenuItemAction } from '../../../../platform/actions/common/actions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { InputFocusedContext } from '../../../../platform/contextkey/common/contextkeys.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; @@ -23,14 +24,14 @@ import { IWorkbenchContribution } from '../../../../workbench/common/contributio import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { EditorAreaFocusContext, IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../../workbench/common/contextkeys.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; -import { getQuickNavigateHandler } from '../../../../workbench/browser/quickaccess.js'; +import { getQuickNavigateHandler, inQuickPickContext } from '../../../../workbench/browser/quickaccess.js'; import { Menus } from '../../../browser/menus.js'; import { SessionsCategories } from '../../../common/categories.js'; -import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext } from '../../../common/contextkeys.js'; +import { CanGoBackContext, CanGoForwardContext, SessionProviderIdContext, MultipleSessionsVisibleContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsMaximizedContext, SessionIsStickyContext, SessionsFocusContext, SessionSupportsMultipleChatsContext, SessionsWelcomeVisibleContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, SessionsPickerVisibleContext, SessionActiveChatIsClosableContext, SessionActiveChatIsDeletableContext, SessionChatsPickerVisibleContext, SessionActiveChatHasSubagentsContext } from '../../../common/contextkeys.js'; import { ANY_AGENT_HOST_PROVIDER_RE } from '../../../common/agentHostSessionsProvider.js'; -import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { IActiveSession, ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; -import { ISession, SessionStatus } from '../../../services/sessions/common/session.js'; +import { ChatOriginKind, getChatCapabilities, getUntitledSessionTitle, IChat, ISession, SessionStatus } from '../../../services/sessions/common/session.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsListModelService } from '../../../services/sessions/browser/sessionsListModelService.js'; import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; @@ -60,7 +61,6 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { const quickInputService = accessor.get(IQuickInputService); const sessionsPartService = accessor.get(ISessionsPartService); const sessionsListModelService = accessor.get(ISessionsListModelService); - const keybindingService = accessor.get(IKeybindingService); const contextKeyService = accessor.get(IContextKeyService); const { recent, other } = sessionsService.getRecentlyOpenedSessions(); @@ -84,7 +84,7 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { let activeItem: ISessionPickItem | undefined; const toPickItem = (session: ISession): ISessionPickItem => { - const title = session.title.get() || localize('untitledSession', "New Session"); + const title = session.title.get() || getUntitledSessionTitle(session.isQuickChat?.get() ?? false); // Status icon, mirroring the sessions list and session header. Use the // list model service's read state (not session.isRead) so the icon @@ -145,16 +145,6 @@ registerAction2(class ShowSessionsPickerAction extends Action2 { // Match on the detail row too so sessions can be found by their folder. picker.matchOnDetail = true; - // Enable quick navigation: when invoked via keybinding the user can keep - // the modifier held and press the trigger key again to cycle through - // sessions, then release the modifier to open the focused one (mirroring - // the editor switcher). The keybindings of this command drive which - // modifier release accepts the active item. - const keybindings = keybindingService.lookupKeybindings(SHOW_SESSIONS_PICKER_COMMAND_ID); - if (keybindings.length > 0) { - picker.quickNavigate = { keybindings }; - } - // Default to the currently active session so it is selected on open. if (activeItem) { picker.activeItems = [activeItem]; @@ -407,6 +397,13 @@ registerAction2(class CloseAllSessionsAction extends Action2 { } }); +// -- Chat tab navigation, new chat, & close (within the active session's tab strip) -- + +// These chords sit just above the session-level navigation/close commands so +// they win while a multi-chat session is focused, falling back to the +// session-level commands when the tab strip is not shown. +const CHAT_TAB_KEYBINDING_WEIGHT = KeybindingWeight.SessionsContrib + 10; + // "New Chat" starts a new chat. Hidden once the session has more than one open // chat, since the chat tab strip then offers New Chat at the end of the tabs. const ADD_CHAT_TO_SESSION_ACTION_ID = 'sessions.chatCompositeBar.addChat'; @@ -417,26 +414,481 @@ registerAction2(class AddChatToSessionAction extends Action2 { id: ADD_CHAT_TO_SESSION_ACTION_ID, title: localize2('chatCompositeBar.addChat', "New Chat"), icon: Codicon.add, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Like Cmd/Ctrl+T in a browser — opens a new chat tab within the + // active session. Scoped so it does not steal the shortcut outside + // the agents window or when the session does not support multiple chats. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate()), + primary: KeyMod.CtrlCmd | KeyCode.KeyT, + }, menu: { id: Menus.SessionBarToolbar, group: 'navigation', order: 0, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionHasMultipleOpenChatsContext.negate()), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionShouldShowChatTabsContext.negate()), }, }); } - override async run(accessor: ServicesAccessor, session: IActiveSession | undefined): Promise<void> { + override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise<void> { + const sessionsService = accessor.get(ISessionsService); + const sessionsPartService = accessor.get(ISessionsPartService); + // From the menu: session is forwarded as context. From the keybinding: + // fall back to the active session. + const target = session ?? sessionsService.activeSession.get(); + if (!target) { + return; + } + await sessionsService.openNewChatInSession(target); + sessionsPartService.focusSession(target); + } +}); + +function navigateChatTab(accessor: ServicesAccessor, direction: 'next' | 'previous'): void { + const sessionsService = accessor.get(ISessionsService); + const sessionsPartService = accessor.get(ISessionsPartService); + const extUri = accessor.get(IUriIdentityService).extUri; + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const tabs = session.visibleChatTabs.get(); + if (tabs.length < 2) { + return; + } + const activeChat = session.activeChat.get(); + const currentIndex = activeChat ? tabs.findIndex(chat => extUri.isEqual(chat.resource, activeChat.resource)) : -1; + const from = currentIndex === -1 ? 0 : currentIndex; + const delta = direction === 'next' ? 1 : -1; + const target = tabs[(from + delta + tabs.length) % tabs.length]; + sessionsService.openChat(session, target.resource); + sessionsPartService.focusSession(session); +} + +registerAction2(class NavigateNextChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigateNextChat', + title: localize2('navigateNextChat', "Go to Next Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'next'); + } +}); + +registerAction2(class NavigatePreviousChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.navigatePreviousChat', + title: localize2('navigatePreviousChat', "Go to Previous Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft, + }, + }); + } + override run(accessor: ServicesAccessor): void { + navigateChatTab(accessor, 'previous'); + } +}); + +// The close-chat action is both a keybinding (Ctrl/Cmd+W closes the active chat) +// and a per-tab toolbar action contributed to {@link Menus.SessionChatTab}: the +// chat tab strip renders this menu and forwards the tab's {@link IChatTabContext} +// as the action argument so the button closes that specific tab. +export interface IChatTabContext { + readonly session: IActiveSession; + readonly chat: IChat; +} + +registerAction2(class CloseChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.closeChat', + title: localize2('closeActiveChat', "Close Chat"), + icon: Codicon.close, + // Hidden from the palette: closing a specific chat is contextual (the + // keybinding targets the active chat; the menu targets a tab). + f1: false, + category: SessionsCategories.Sessions, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Intercept Ctrl/Cmd+W (which otherwise closes the session) only + // while the active chat is a closeable non-main chat, so it closes + // the chat tab instead — like closing a tab vs the window. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionActiveChatIsClosableContext), + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, + }, + // Rendered as the tab's close button by the chat tab strip; the main + // chat's tab does not render this menu, so no per-tab gating is needed. + menu: { + id: Menus.SessionChatTab, + group: 'navigation', + order: 10, + }, + }); + } + override async run(accessor: ServicesAccessor, context?: IChatTabContext): Promise<void> { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const extUri = accessor.get(IUriIdentityService).extUri; + // From the tab menu: act on the forwarded tab's chat. From the keybinding: + // act on the active chat of the active session. + const session = context?.session ?? sessionsService.activeSession.get(); + if (!session) { + return; + } + const chat = context?.chat ?? session.activeChat.get(); + if (!chat || extUri.isEqual(chat.resource, session.mainChat.get().resource)) { + return; + } + // An untitled (in-composer) draft has nothing to reopen, so delete it + // outright; a committed chat is hidden (reopenable). + if (chat.status.get() === SessionStatus.Untitled) { + await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true }); + } else { + await sessionsService.closeChat(session, chat); + } + } +}); + +registerAction2(class CloseAllChatsAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.closeAllChats', + title: localize2('closeAllChats', "Close All Chats"), + f1: true, + category: SessionsCategories.Sessions, + // Enabled (palette + keybinding) only while the active session has more + // than one open chat, so the chord targets the focused session and + // stays inert for single-chat sessions. + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + when: ContextKeyExpr.and( + IsSessionsWindowContext, + // While a modal editor has focus, let VS Code's own + // closeEditorsInGroup (same chord) act on the editor group. + EditorAreaFocusContext.toNegated(), + SessionHasMultipleOpenChatsContext + ), + // Mirror VS Code's "Close All Editors in Group" chord (Ctrl/Cmd+K W): + // a session is the Agents-window analogue of an editor group. Note + // "Close All Sessions" already owns Ctrl/Cmd+K Ctrl/Cmd+W. + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyW), + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const extUri = accessor.get(IUriIdentityService).extUri; + const session = sessionsService.activeSession.get(); if (!session) { return; } + + const mainResource = session.mainChat.get().resource; + const chatsToClose = session.openChats.get().filter(chat => !extUri.isEqual(chat.resource, mainResource)); + for (const chat of chatsToClose) { + if (chat.status.get() === SessionStatus.Untitled) { + await sessionsManagementService.deleteChat(session, chat.resource, { skipConfirmation: true }); + } else { + await sessionsService.closeChat(session, chat); + } + } + } +}); + +registerAction2(class DeleteChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.deleteChat', + title: localize2('deleteActiveChat', "Delete Chat"), + f1: true, + category: SessionsCategories.Sessions, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Delete / Cmd+Backspace (Mac) — mirrors the file-delete keybinding + // in the Explorer. Scoped so it never fires while typing in an input + // (chat composer, rename field, etc.) or on the session's main chat. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), InputFocusedContext.toNegated(), SessionActiveChatIsDeletableContext), + primary: KeyCode.Delete, + mac: { + primary: KeyMod.CtrlCmd | KeyCode.Backspace, + secondary: [KeyCode.Delete], + }, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const sessionsService = accessor.get(ISessionsService); + const sessionsManagementService = accessor.get(ISessionsManagementService); + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const chat = session.activeChat.get(); + // The main chat and worker (subagent) chats report `canDelete: false`. + if (!chat || !getChatCapabilities(chat, session, undefined).canDelete) { + return; + } + await sessionsManagementService.deleteChat(session, chat.resource); + } +}); + +registerAction2(class ReopenLastClosedChatAction extends Action2 { + constructor() { + super({ + id: 'sessions.chatCompositeBar.reopenLastClosedChat', + title: localize2('chatCompositeBar.reopenLastClosedChat', "Reopen Last Closed Chat"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionSupportsMultipleChatsContext, + keybinding: { + weight: CHAT_TAB_KEYBINDING_WEIGHT, + // Like Cmd/Ctrl+Shift+T in a browser — reopens the most recently + // closed chat tab. Scoped to the agents window, outside editor area. + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionIsCreatedContext, SessionSupportsMultipleChatsContext), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyT, + }, + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { const sessionsService = accessor.get(ISessionsService); const sessionsPartService = accessor.get(ISessionsPartService); - await sessionsService.openNewChatInSession(session); + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const lastClosed = session.lastClosedChat; + if (!lastClosed) { + return; + } + await sessionsService.openChat(session, lastClosed.resource); sessionsPartService.focusSession(session); } }); +// A no-input quick pick (pure switcher) over the active session's open chats, +// each shown with a chat icon. Driven by Ctrl+Tab / Ctrl+Shift+Tab in +// editor-switcher (MRU) style: opens with quick navigate active, so holding the +// modifier and pressing Tab cycles and releasing accepts the focused chat. These +// are gated to sessions with more than one open chat at a higher weight than the +// session-history secondary on the same chord, so they fall back to session +// navigation otherwise. The same picker is also reachable from the palette ("Go +// to Chat in Session"), which additionally lists closed chats and skips drafts. + +export const SHOW_CHATS_PICKER_COMMAND_ID = 'sessions.showChatsPicker'; +const QUICK_SWITCH_NEXT_CHAT_ID = 'sessions.quickSwitchNextChat'; +const QUICK_SWITCH_PREVIOUS_CHAT_ID = 'sessions.quickSwitchPreviousChat'; +const CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID = 'sessions.chatsPicker.quickNavigateNext'; +const CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID = 'sessions.chatsPicker.quickNavigatePrevious'; + +// The open chords are gated to not fire while another quick pick is already +// showing (inQuickPickContext negated), so e.g. the editor's own Ctrl+Tab picker +// keeps the chord for its own navigation instead of this opening on top of it. +// The Ctrl+Tab MRU switcher cycles open chats only, so it is gated on more than +// one open tab. (The palette command, which also lists closed chats, is gated on +// more than one committed chat instead.) +const ChatsPickerScopeContext = ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), SessionHasMultipleOpenChatsContext, inQuickPickContext.negate()); + +function openChatsPicker(accessor: ServicesAccessor, mru?: { readonly backward: boolean }): void { + const sessionsService = accessor.get(ISessionsService); + const quickInputService = accessor.get(IQuickInputService); + const sessionsPartService = accessor.get(ISessionsPartService); + const contextKeyService = accessor.get(IContextKeyService); + const keybindingService = accessor.get(IKeybindingService); + + const session = sessionsService.activeSession.get(); + if (!session) { + return; + } + const extUri = accessor.get(IUriIdentityService).extUri; + + interface IChatPickItem extends IQuickPickItem { + readonly chat: IChat; + } + + const toItem = (chat: IChat): IChatPickItem => ({ + label: chat.title.get()?.trim() || localize('untitledChat', "Untitled Chat"), + description: fromNow(chat.updatedAt.get(), true, true), + iconClass: ThemeIcon.asClassName(Codicon.commentDiscussion), + chat, + }); + + // MRU mode cycles every open tab (including in-composer drafts) so the set of + // switchable chats matches the SessionHasMultipleOpenChatsContext gate. The + // searchable palette flow instead skips untitled drafts (no meaningful title, + // mirroring the Conversations submenu) and adds the closed chats below. + const openItems = (mru + ? session.visibleChatTabs.get() + : session.visibleChatTabs.get().filter(chat => chat.status.get() !== SessionStatus.Untitled) + ).map(toItem); + // Closed chats are hidden from the tab strip but still reopenable. They are + // only offered in the searchable palette flow — not the Ctrl+Tab MRU switcher, + // which mirrors the editor switcher and cycles open items only. + const closedItems = mru ? [] : session.closedChats.get() + .filter(chat => chat.status.get() !== SessionStatus.Untitled && chat.origin?.kind !== ChatOriginKind.Tool) + .map(toItem); + + // Navigation order: open chats first, then closed chats. + const pickItems = [...openItems, ...closedItems]; + if (pickItems.length === 0) { + return; + } + + const displayItems: (IChatPickItem | IQuickPickSeparator)[] = closedItems.length === 0 + ? openItems + : [ + { type: 'separator', label: localize('openChatsGroup', "Open") }, + ...openItems, + { type: 'separator', label: localize('closedChatsGroup', "Closed") }, + ...closedItems, + ]; + + const activeChat = session.activeChat.get(); + const activeIndex = Math.max(0, activeChat ? pickItems.findIndex(item => extUri.isEqual(item.chat.resource, activeChat.resource)) : -1); + // MRU style starts on the adjacent chat so a single tap+release switches to + // it; palette invocation (non-MRU) focuses the active chat. + const startIndex = mru ? (activeIndex + (mru.backward ? -1 : 1) + pickItems.length) % pickItems.length : activeIndex; + + const disposables = new DisposableStore(); + const picker = disposables.add(quickInputService.createQuickPick<IChatPickItem>({ useSeparators: true })); + picker.items = displayItems; + picker.activeItems = [pickItems[startIndex]]; + if (mru) { + // Editor-switcher style: no filter input, and quick navigate stays active so + // releasing the modifier accepts the focused chat. The modifier is taken + // from the quick-navigate keybinding's chord. + picker.hideInput = true; + picker.quickNavigate = { keybindings: keybindingService.lookupKeybindings(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID) }; + } else { + // Palette flow: a searchable list across the Open and Closed groups. + picker.placeholder = localize('searchChats', "Search chats by name"); + picker.matchOnDescription = true; + } + + // Expose a context key while the picker is open so the navigate keybindings + // (bound to the same chords) advance the selection instead of re-opening. + const pickerVisibleContext = SessionChatsPickerVisibleContext.bindTo(contextKeyService); + pickerVisibleContext.set(true); + disposables.add(toDisposable(() => pickerVisibleContext.reset())); + + disposables.add(picker.onDidAccept(() => { + const [selected] = picker.selectedItems; + if (selected) { + sessionsService.openChat(session, selected.chat.resource); + sessionsPartService.focusSession(session); + } + picker.hide(); + })); + disposables.add(picker.onDidHide(() => disposables.dispose())); + + picker.show(); +} + +registerAction2(class ShowChatsPickerAction extends Action2 { + constructor() { + super({ + id: SHOW_CHATS_PICKER_COMMAND_ID, + title: localize2('showChatsPicker', "Go to Chat in Session"), + f1: true, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleCommittedChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + when: ContextKeyExpr.and(IsSessionsWindowContext, EditorAreaFocusContext.toNegated(), inQuickPickContext.negate()), + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyO, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor); + } +}); + +// Ctrl+Tab / Ctrl+Shift+Tab open the picker in editor-switcher (MRU) mode. Hidden +// from the palette (f1: false) since they only make sense held; the chord wins +// over the session-history secondary via the higher weight while multi-chat. +registerAction2(class QuickSwitchNextChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_NEXT_CHAT_ID, + title: localize2('quickSwitchNextChat', "Quick Switch to Next Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: false }); + } +}); + +registerAction2(class QuickSwitchPreviousChatAction extends Action2 { + constructor() { + super({ + id: QUICK_SWITCH_PREVIOUS_CHAT_ID, + title: localize2('quickSwitchPreviousChat', "Quick Switch to Previous Chat"), + f1: false, + category: SessionsCategories.Sessions, + precondition: SessionHasMultipleOpenChatsContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib + 1, + when: ChatsPickerScopeContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, + }, + }); + } + override run(accessor: ServicesAccessor): void { + openChatsPicker(accessor, { backward: true }); + } +}); + +// While the picker is open, Ctrl+Tab / Ctrl+Shift+Tab cycle forward / backward. +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_NEXT_ID, true), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyCode.Tab }, +}); +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, + weight: KeybindingWeight.SessionsContrib + 50, + handler: getQuickNavigateHandler(CHATS_PICKER_QUICK_NAVIGATE_PREVIOUS_ID, false), + when: SessionChatsPickerVisibleContext, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, + mac: { primary: KeyMod.WinCtrl | KeyMod.Shift | KeyCode.Tab }, +}); + export class SessionNewChatActionViewItemContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessions.newChatActionViewItem'; @@ -471,19 +923,21 @@ export class SessionNewChatActionViewItemContribution extends Disposable impleme // shown: when the strip is hidden it lives in the session header toolbar; once the // session has more than one open chat (the tab strip is shown) it moves to the // chat tab bar action menu at the end of the strip instead (see -// Menus.SessionChatTabBar below). +// Menus.SessionChatTabBar below). It also surfaces when the active chat has +// subagents (a separate group at the bottom lists them), even if that is the only +// committed chat. MenuRegistry.appendMenuItem(Menus.SessionBarToolbar, { submenu: Menus.SessionConversations, title: localize2('chatCompositeBar.conversations', "Conversations"), icon: Codicon.commentDiscussion, group: 'navigation', order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext.negate()), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext.negate()), }); // Mirror of the header Conversations submenu, rendered at the end of the chat tab // bar action menu while the tab strip is shown (more than one open chat). The two -// `when` clauses are mutually exclusive on SessionHasMultipleOpenChatsContext so +// `when` clauses are mutually exclusive on SessionShouldShowChatTabsContext so // the Conversations menu only ever appears in one place at a time. MenuRegistry.appendMenuItem(Menus.SessionChatTabBar, { submenu: Menus.SessionConversations, @@ -491,7 +945,7 @@ MenuRegistry.appendMenuItem(Menus.SessionChatTabBar, { icon: Codicon.commentDiscussion, group: 'navigation', order: 10, - when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), SessionHasMultipleCommittedChatsContext, SessionHasMultipleOpenChatsContext), + when: ContextKeyExpr.and(SessionIsCreatedContext, SessionSupportsMultipleChatsContext, SessionIsArchivedContext.negate(), ContextKeyExpr.or(SessionHasMultipleCommittedChatsContext, SessionActiveChatHasSubagentsContext), SessionShouldShowChatTabsContext), }); /** @@ -533,17 +987,15 @@ export class SessionConversationsMenuContribution extends Disposable implements const allChats = session.chats.read(reader); const mainResource = session.mainChat.read(reader).resource; - const openChats = session.openChats.read(reader); + const visibleChatTabs = session.visibleChatTabs.read(reader); + const activeChatResource = session.activeChat.read(reader).resource; - allChats.forEach((chat, index) => { - // Skip untitled (in-composer) draft chats: they are transient "New - // Chat" drafts that can't be meaningfully closed/reopened, and listing - // them here (titled "New Chat") just duplicates the New Chat action. - if (chat.status.read(reader) === SessionStatus.Untitled) { - return; - } + const registerToggle = (chat: IChat, group: string, order: number) => { const chatResource = chat.resource; - const isOpen = openChats.some(c => extUri.isEqual(c.resource, chatResource)); + // Whether the chat is currently shown as a tab. For regular chats this + // mirrors `openChats`; for subagents it reflects the shown-subagent set, + // which is what open/close toggles. + const isShown = visibleChatTabs.some(c => extUri.isEqual(c.resource, chatResource)); const isMain = extUri.isEqual(chatResource, mainResource); const title = chat.title.read(reader) || localize('untitledChat', "Untitled Chat"); // Action IDs are global, so scope them to the session and a hash of the @@ -554,9 +1006,9 @@ export class SessionConversationsMenuContribution extends Disposable implements super({ id: `sessions.toggleChat.${session.sessionId}.${hash(chatResource.toString())}`, title, - toggled: isOpen ? ContextKeyExpr.true() : undefined, + toggled: isShown ? ContextKeyExpr.true() : undefined, precondition: isMain ? ContextKeyExpr.false() : undefined, - menu: { id: Menus.SessionConversations, group: '1_chats', order: index, when: scopedToSession }, + menu: { id: Menus.SessionConversations, group, order, when: scopedToSession }, }); } override async run(_accessor: ServicesAccessor, forwardedSession?: IActiveSession): Promise<void> { @@ -565,16 +1017,41 @@ export class SessionConversationsMenuContribution extends Disposable implements if (!targetChat) { return; } - if (target.openChats.get().some(c => extUri.isEqual(c.resource, chatResource))) { + if (target.visibleChatTabs.get().some(c => extUri.isEqual(c.resource, chatResource))) { await that._sessionsService.closeChat(target, targetChat); } else { - // Opening a closed chat also un-hides it in the tab strip. + // Opening a closed chat (or hidden subagent) un-hides it in the tab strip. await that._sessionsService.openChat(target, targetChat.resource); } } })); + }; + + allChats.forEach((chat, index) => { + // Skip untitled (in-composer) draft chats: they are transient "New + // Chat" drafts that can't be meaningfully closed/reopened, and listing + // them here (titled "New Chat") just duplicates the New Chat action. + if (chat.status.read(reader) === SessionStatus.Untitled) { + return; + } + // Subagent (tool-origin) chats are surfaced in their own group below, + // scoped to the currently-active chat. + if (chat.origin?.kind === ChatOriginKind.Tool) { + return; + } + registerToggle(chat, '1_chats', index); }); + // Subagents of the currently-active chat, shown as a separate group at the + // bottom (a separator divides them from the session's chats). This group + // changes as the active chat changes. + allChats + .filter(chat => + chat.origin?.kind === ChatOriginKind.Tool && + !!chat.origin.parentChat && + extUri.isEqual(chat.origin.parentChat, activeChatResource)) + .forEach((chat, index) => registerToggle(chat, '2_subagents', index)); + return store; } } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index 816a50d9228db6..b4278b72ca5c4e 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -4,25 +4,80 @@ *--------------------------------------------------------------------------------------------*/ import './media/sessionsTitleBarWidget.css'; -import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, reset } from '../../../../base/browser/dom.js'; -import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { $, addDisposableGenericMouseDownListener, addDisposableListener, EventType, getDomNodePagePosition, getWindow, isAncestor, reset } from '../../../../base/browser/dom.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { localize } from '../../../../nls.js'; import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { MenuRegistry, SubmenuItemAction } from '../../../../platform/actions/common/actions.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js'; import { Menus } from '../../../browser/menus.js'; import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { autorun } from '../../../../base/common/observable.js'; +import { onUnexpectedError } from '../../../../base/common/errors.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { URI } from '../../../../base/common/uri.js'; +import { AnchorAlignment, AnchorPosition, IAnchor } from '../../../../base/common/layout.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IContextViewService, IOpenContextView } from '../../../../platform/contextview/browser/contextView.js'; +import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; -import { SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; +import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; +import { SessionsBlockedSessionsVisibleContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { SHOW_SESSIONS_PICKER_COMMAND_ID } from './sessionsActions.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { getUntitledSessionTitle } from '../../../services/sessions/common/session.js'; +import { BlockedSessions } from '../../blockedSessions/browser/blockedSessions.js'; +import { BlockedSessionsList } from './blockedSessionsList.js'; +import { SessionActionFeedback } from './sessionActionFeedback.js'; +import { AgentSessionApprovalModel } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { BlockedSessionsIndicatorModel, RequiresInputKind } from './blockedSessionsIndicatorModel.js'; +import { openSessionToTheSide } from './views/sessionsView.js'; + +/** + * Internal command behind the blocked-sessions dropdown header's "Show All + * Sessions" action: it dismisses the dropdown (a transient context view) before + * opening the full sessions picker so the popup doesn't linger behind it. + */ +const SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID = 'sessions.blockedSessions.showAllSessions'; + +/** + * Internal command that dismisses the blocked-sessions dropdown. Bound to Escape + * (scoped to {@link SessionsBlockedSessionsVisibleContext}) so the dropdown can + * be closed from anywhere in the sessions window while it is open, not only when + * focus happens to be inside it. + */ +const HIDE_BLOCKED_SESSIONS_COMMAND_ID = 'sessions.blockedSessions.hide'; + +/** + * The currently-open blocked-sessions dropdown, if any. Shared at module scope so + * command handlers registered outside the widget instance (the Escape keybinding + * and the "Show All Sessions" action) can close this specific context view via its + * {@link IOpenContextView.close}, rather than blindly hiding whatever context view + * happens to be open. Owned by {@link SessionsTitleBarWidget}, which keeps it in + * sync when the dropdown opens and clears it when it hides. + */ +let openBlockedSessionsView: IOpenContextView | undefined; + +/** + * Minimum width of the blocked-sessions dropdown, in pixels. The dropdown is at + * least as wide as the command center box it hangs off, but never narrower than + * this so its rows have room to breathe. + */ +const BLOCKED_DROPDOWN_MIN_WIDTH = 550; + +/** + * Maximum width of the blocked-sessions dropdown as a fraction of the window + * width, so it never spans (nearly) the entire window on narrow layouts. + */ +const BLOCKED_DROPDOWN_MAX_WIDTH_RATIO = 0.9; + /** * Sessions Title Bar Widget - renders the active chat session * in the command center of the agent sessions workbench. @@ -31,43 +86,111 @@ import { ISessionsService } from '../../../services/sessions/browser/sessionsSer * - Kind icon at the beginning (provider type icon) * - Repository folder name and active branch/worktree name when available * + * When at least one session is blocked (needs input, has failing CI checks, or + * has unresolved pull request comments), the widget instead adopts an orange + * "N sessions require input" state and, on click, reveals those sessions as a + * flat list in a dropdown anchored below the command center box. A short blink + * animation plays whenever a new session becomes blocked. In every other case it + * behaves as the active-session pill and opens the sessions picker on click. + * + * The requires-input logic (which blocked sessions to surface, the homogeneous + * reason, labels and when to blink) is owned by {@link BlockedSessionsIndicatorModel}; + * this widget only renders it. + * * Session actions (changes, terminal, etc.) are rendered via the * SessionTitleActions menu toolbar next to this widget. - * - * On click, opens the sessions picker. */ export class SessionsTitleBarWidget extends BaseActionViewItem { private _container: HTMLElement | undefined; private readonly _dynamicDisposables = this._register(new DisposableStore()); + /** Owns the blink animation's `animationend` listener, kept across re-renders. */ + private readonly _blinkListener = this._register(new MutableDisposable()); + /** Cached render state to avoid unnecessary DOM rebuilds */ private _lastRenderState: string | undefined; /** Guard to prevent re-entrant rendering */ private _isRendering = false; + /** Model behind the "N sessions require input" indicator (blocked-session set, blink, labels). */ + private readonly _blockedIndicator: BlockedSessionsIndicatorModel; + + /** The currently open blocked-sessions dropdown, if any. */ + private _openContextView: IOpenContextView | undefined; + /** The blocked-sessions list rendered inside the open dropdown, if any. */ + private _blockedList: BlockedSessionsList | undefined; + + /** Tracks whether the blocked-sessions dropdown is open (drives the Escape keybinding). */ + private readonly _blockedSessionsVisibleContext: IContextKey<boolean>; + + /** Drives the transient "Approved N sessions" confirmation. Owned by the widget. */ + private readonly _sessionActionFeedback: SessionActionFeedback; + constructor( action: SubmenuItemAction, options: IBaseActionViewItemOptions | undefined, + sessionActionFeedback: SessionActionFeedback | undefined, + approvalModel: AgentSessionApprovalModel | undefined, + blockedSessions: BlockedSessions | undefined, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly sessionsService: ISessionsService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, @ICommandService private readonly commandService: ICommandService, + @IContextViewService private readonly contextViewService: IContextViewService, + @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IQuickInputService private readonly quickInputService: IQuickInputService, ) { super(undefined, action, options); - // Re-render when the active session's title or workspace changes + this._blockedSessionsVisibleContext = SessionsBlockedSessionsVisibleContext.bindTo(contextKeyService); + + // The widget owns the approval-feedback state; the optional parameter is a + // test seam so fixtures can supply a preset instance. + this._sessionActionFeedback = sessionActionFeedback ?? this._register(new SessionActionFeedback()); + + // The blocked-session indicator model owns the requires-input logic (the + // visible-filtered blocked set, the requires-input kind, optimistic approval + // dismissals, labels and blink detection). The optional `approvalModel` and + // `blockedSessions` are test seams forwarded to it so fixtures can preset them. + this._blockedIndicator = this._register(this.instantiationService.createInstance(BlockedSessionsIndicatorModel, approvalModel, blockedSessions)); + + // Replay the attention blink when the model reports a genuinely new, not-yet- + // visible block. Invalidate the cached render state so the identical pill is + // rebuilt with the blink class (see `_render`). + this._register(this._blockedIndicator.onDidRequestBlink(() => { + this._lastRenderState = undefined; + this._render(); + })); + + // Re-render when the active session's title, workspace, or quick-chat kind changes this._register(autorun(reader => { const sessionData = this.sessionsService.activeSession.read(reader); if (sessionData) { sessionData.title.read(reader); sessionData.workspace.read(reader); + sessionData.isQuickChat?.read(reader); } this._lastRenderState = undefined; this._render(); })); + // Re-render when the set of blocked sessions changes; it feeds the + // "N sessions require input" state. Keep an open dropdown in sync. + this._register(autorun(reader => { + const blocked = this._blockedIndicator.blockedSessions.read(reader); + this._sessionActionFeedback.approvedCount.read(reader); + this._blockedIndicator.requiresInputKind.read(reader); + if (this._openContextView && this._blockedList) { + this._blockedList.setSessions(blocked.map(entry => entry.session)); + this.contextViewService.layout(); + } + this._render(); + })); + // Re-render when sessions data changes (e.g., changes info updated) this._register(this.sessionsManagementService.onDidChangeSessions(() => { this._lastRenderState = undefined; @@ -79,6 +202,9 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._lastRenderState = undefined; this._render(); })); + + // Ensure any open dropdown is closed when the widget is disposed. + this._register(toDisposable(() => this._openContextView?.close())); } override render(container: HTMLElement): void { @@ -112,12 +238,35 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { this._isRendering = true; try { - const icon = this._getActiveSessionIcon(); - const sessionTitle = this._getSessionTitle() ?? localize('newSession', "New Session"); - const workspaceLabel = this._getRepositoryLabel(); - - // Build a render-state key from all displayed data - const renderState = `${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + const approvedCount = this._sessionActionFeedback.approvedCount.get(); + const blockedCount = this._blockedIndicator.blockedSessions.get().length; + const requiresInput = blockedCount > 0; + + // The transient "Approved N sessions" confirmation takes precedence over the + // requires-input state while it is showing. + const showApproved = approvedCount > 0; + const showRequiresInput = requiresInput && !showApproved; + + // The attention blink fires only when the indicator model reports a + // *genuinely new* blocked session while the requires-input state is shown — + // including the very first one. `consumePendingBlink` is short-circuited so + // the pending blink is only consumed when it actually plays; navigating + // between sessions (which changes the visible set, not the model) never blinks. + const shouldBlink = showRequiresInput && this._blockedIndicator.consumePendingBlink(); + + const requiresInputKind = this._blockedIndicator.requiresInputKind.get(); + + let renderState: string; + if (showApproved) { + renderState = `approved|${approvedCount}`; + } else if (showRequiresInput) { + renderState = `blocked|${blockedCount}|${requiresInputKind ?? 'mixed'}`; + } else { + const icon = this._getActiveSessionIcon(); + const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); + const workspaceLabel = this._getRepositoryLabel(); + renderState = `normal|${icon?.id ?? ''}|${sessionTitle ?? ''}|${workspaceLabel ?? ''}`; + } // Skip re-render if state hasn't changed if (this._lastRenderState === renderState) { @@ -125,6 +274,15 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { } this._lastRenderState = renderState; + // Close the open blocked-sessions dropdown only when there are no blocked + // sessions left to show. Note this keys off `requiresInput`, not + // `showRequiresInput`: approving a session shows the transient green state + // (suppressing `showRequiresInput`) but the dropdown must stay open while + // other sessions remain blocked — it just drops the approved row. + if (!requiresInput && this._openContextView) { + this._openContextView.close(); + } + // Clear existing content reset(this._container); this._dynamicDisposables.clear(); @@ -132,64 +290,345 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { // Set up container as the button directly this._container.removeAttribute('aria-hidden'); this._container.setAttribute('role', 'button'); - this._container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); this._container.tabIndex = 0; + // Preserve an in-progress blink when re-rendering the SAME requires-input + // pill without a new blink. Other autoruns (e.g. onDidChangeSessions) + // invalidate the cached render state and force a redundant rebuild of the + // identical pill; without this guard that rebuild would strip the freshly- + // added blink class and cut the animation short — which is why the first + // "1 session requires input" never appeared to animate. + if (!(showRequiresInput && !shouldBlink)) { + this._container.classList.remove('agent-sessions-titlebar-blink'); + } + this._container.classList.toggle('agent-sessions-titlebar-requires-input', showRequiresInput); + this._container.classList.toggle('agent-sessions-titlebar-approved', showApproved); + + if (showApproved) { + this._renderApproved(approvedCount); + } else if (showRequiresInput) { + this._renderRequiresInput(blockedCount, requiresInputKind, shouldBlink); + } else { + this._renderActiveSession(); + } + } finally { + this._isRendering = false; + } + } + + /** + * Render the active-session pill: icon + title + workspace. Clicking opens the + * sessions picker. + */ + private _renderActiveSession(): void { + const container = this._container!; + container.setAttribute('aria-label', localize('agentSessionsShowSessions', "Show Sessions")); - // Session pill: icon + title + workspace together - const sessionPill = $('div.agent-sessions-titlebar-pill'); + const icon = this._getActiveSessionIcon(); + const sessionTitle = this._getSessionTitle() ?? getUntitledSessionTitle(this.sessionsService.activeSession.get()?.isQuickChat?.get() ?? false); + const workspaceLabel = this._getRepositoryLabel(); - // Center group: icon + title + workspace name - const centerGroup = $('div.agent-sessions-titlebar-center'); + // Session pill: icon + title + workspace together + const sessionPill = $('div.agent-sessions-titlebar-pill'); - // Kind icon at the beginning - if (icon) { - const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); - centerGroup.appendChild(iconEl); - } + // Center group: icon + title + workspace name + const centerGroup = $('div.agent-sessions-titlebar-center'); - // Session title shown next to the icon - if (sessionTitle) { - const titleEl = $('div.agent-sessions-titlebar-title'); - titleEl.textContent = sessionTitle; - centerGroup.appendChild(titleEl); - } + // Kind icon at the beginning + if (icon) { + const iconEl = $('div.agent-sessions-titlebar-icon' + ThemeIcon.asCSSSelector(icon)); + centerGroup.appendChild(iconEl); + } + + // Session title shown next to the icon + if (sessionTitle) { + const titleEl = $('div.agent-sessions-titlebar-title'); + titleEl.textContent = sessionTitle; + centerGroup.appendChild(titleEl); + } - // Workspace name shown after the session title - if (workspaceLabel) { - const separatorEl = $('div.agent-sessions-titlebar-separator'); - centerGroup.appendChild(separatorEl); + // Workspace name shown after the session title + if (workspaceLabel) { + const separatorEl = $('div.agent-sessions-titlebar-separator'); + centerGroup.appendChild(separatorEl); + + const workspaceEl = $('div.agent-sessions-titlebar-workspace'); + workspaceEl.textContent = workspaceLabel; + centerGroup.appendChild(workspaceEl); + } + + sessionPill.appendChild(centerGroup); + + // Click handler on pill + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(sessionPill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); + })); - const workspaceEl = $('div.agent-sessions-titlebar-workspace'); - workspaceEl.textContent = workspaceLabel; - centerGroup.appendChild(workspaceEl); + container.appendChild(sessionPill); + + // Keyboard handler + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + this._showSessionsPicker(); } + })); + } + + /** + * Render the requires-input pill. Clicking toggles a dropdown that lists the + * blocked sessions below the command center box. + */ + private _renderRequiresInput(count: number, kind: RequiresInputKind | undefined, shouldBlink: boolean): void { + const container = this._container!; + const label = this._blockedIndicator.getRequiresInputLabel(count, kind); + container.setAttribute('aria-label', label); + + const pill = $('div.agent-sessions-titlebar-pill'); + const labelEl = $('div.agent-sessions-titlebar-requires-input-label'); + labelEl.textContent = label; + pill.appendChild(labelEl); + + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._toggleBlockedSessions(); + })); - sessionPill.appendChild(centerGroup); + container.appendChild(pill); - // Click handler on pill - this._dynamicDisposables.add(addDisposableGenericMouseDownListener(sessionPill, (e) => { + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - })); - this._dynamicDisposables.add(addDisposableListener(sessionPill, EventType.CLICK, (e) => { + this._toggleBlockedSessions(); + } + })); + + if (shouldBlink) { + this._triggerAttentionBlink(); + } + } + + /** + * Render the transient green "Approved N sessions" confirmation shown briefly + * after the user approves one or more sessions' pending actions from the list. + */ + private _renderApproved(count: number): void { + const container = this._container!; + const label = count === 1 + ? localize('oneSessionApproved', "Approved 1 session") + : localize('nSessionsApproved', "Approved {0} sessions", count); + container.setAttribute('aria-label', label); + + const pill = $('div.agent-sessions-titlebar-pill'); + const labelEl = $('div.agent-sessions-titlebar-approved-label'); + labelEl.textContent = label; + pill.appendChild(labelEl); + + // The confirmation is transient but stays clickable: clicking does whatever + // the widget's underlying (non-approved) state would do. + this._dynamicDisposables.add(addDisposableGenericMouseDownListener(pill, (e) => { + e.preventDefault(); + e.stopPropagation(); + })); + this._dynamicDisposables.add(addDisposableListener(pill, EventType.CLICK, (e) => { + e.preventDefault(); + e.stopPropagation(); + this._activateDefaultAction(); + })); + + container.appendChild(pill); + + this._dynamicDisposables.add(addDisposableListener(container, EventType.KEY_DOWN, (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); e.stopPropagation(); - this._showSessionsPicker(); - })); + this._activateDefaultAction(); + } + })); + } - this._container.appendChild(sessionPill); + /** + * Activate the widget as its non-approved state would: reveal the blocked + * sessions when the requires-input state applies, otherwise the sessions picker. + */ + private _activateDefaultAction(): void { + const requiresInput = this._blockedIndicator.blockedSessions.get().length > 0; + if (requiresInput) { + this._toggleBlockedSessions(); + } else { + this._showSessionsPicker(); + } + } + + /** + * Restart the attention blink animation on the command center box. Re-adding + * the class after a forced reflow guarantees the CSS animation replays even + * when the container element persists across renders. + */ + private _triggerAttentionBlink(): void { + const container = this._container; + if (!container) { + return; + } + container.classList.remove('agent-sessions-titlebar-blink'); + container.getBoundingClientRect(); // force reflow so the animation restarts + container.classList.add('agent-sessions-titlebar-blink'); + // Own the listener outside `_dynamicDisposables` (cleared on every render) so a + // redundant re-render can't drop it before the animation finishes. + this._blinkListener.value = addDisposableListener(container, 'animationend', () => { + container.classList.remove('agent-sessions-titlebar-blink'); + this._blinkListener.clear(); + }); + } + + /** + * Toggle the blocked-sessions dropdown open/closed. + */ + private _toggleBlockedSessions(): void { + if (this._openContextView) { + this._openContextView.close(); + return; + } + this._showBlockedSessions(); + } - // Keyboard handler - this._dynamicDisposables.add(addDisposableListener(this._container, EventType.KEY_DOWN, (e: KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - this._showSessionsPicker(); + /** + * Show the blocked sessions as a flat list in a dropdown anchored below the + * command center box. + */ + private _showBlockedSessions(): void { + const container = this._container; + if (!container) { + return; + } + if (this._blockedIndicator.blockedSessions.get().length === 0) { + return; + } + + // Match the dropdown width to the command center box it hangs off, but keep + // it within a sensible min/max so it stays readable on wide layouts and + // doesn't overflow on narrow ones. + const width = this._computeBlockedDropdownWidth(container); + + const store = new DisposableStore(); + this._openContextView = this.contextViewService.showContextView({ + getAnchor: () => this._getBlockedDropdownAnchor(container), + anchorAlignment: AnchorAlignment.LEFT, + anchorPosition: AnchorPosition.BELOW, + render: (viewContainer): IDisposable => { + const list = store.add(this.instantiationService.createInstance(BlockedSessionsList, viewContainer, { + width, + approvalModel: this._blockedIndicator.approvalModel, + onSessionOpen: (resource, preserveFocus, sideBySide) => { + this._openContextView?.close(); + this._openBlockedSession(resource, preserveFocus, sideBySide); + }, + })); + list.setSessions(this._blockedIndicator.blockedSessions.get().map(entry => entry.session)); + store.add(list.onDidChangeContentHeight(() => this.contextViewService.layout())); + store.add(list.onDidApproveSession(approved => { + this._blockedIndicator.dismissApproval(approved); + this._sessionActionFeedback.notifyApproved(); + })); + + // Keep the dropdown width matched to the command center box as the + // window resizes (the command center reflows to a new width, and the + // min/max clamp tracks the new window width). + store.add(this.layoutService.onDidLayoutActiveContainer(() => { + list.setWidth(this._computeBlockedDropdownWidth(container)); + this.contextViewService.layout(); + })); + + // Dismiss the dropdown when a quick pick opens on top of it (e.g. the + // sessions picker), so it doesn't linger behind the quick input. Close + // our specific context view rather than whatever happens to be open. + store.add(this.quickInputService.onShow(() => this._openContextView?.close())); + + this._blockedList = list; + return store; + }, + focus: () => this._blockedList?.focus(), + onDOMEvent: (e: Event) => { + // Dismiss on a click outside the dropdown. Clicks on the anchor are + // ignored here because the anchor toggles the dropdown itself. Escape + // is handled by a dedicated high-weight keybinding (see + // HIDE_BLOCKED_SESSIONS_COMMAND_ID) so it dismisses the dropdown even + // when focus is outside of it. + if (e.type === EventType.CLICK) { + const target = e.target as HTMLElement | null; + if (target + && !isAncestor(target, this.contextViewService.getContextViewElement()) + && !isAncestor(target, container)) { + this._openContextView?.close(); + } } - })); - } finally { - this._isRendering = false; + }, + onHide: () => { + this._blockedSessionsVisibleContext.set(false); + store.dispose(); + this._openContextView = undefined; + openBlockedSessionsView = undefined; + this._blockedList = undefined; + }, + }); + + openBlockedSessionsView = this._openContextView; + this._blockedSessionsVisibleContext.set(true); + } + + /** + * Compute the width of the blocked-sessions dropdown: at least as wide as the + * command center box (the anchor) and {@link BLOCKED_DROPDOWN_MIN_WIDTH}, but + * never wider than {@link BLOCKED_DROPDOWN_MAX_WIDTH_RATIO} of the window so it + * stays within the viewport on narrow layouts. + */ + private _computeBlockedDropdownWidth(container: HTMLElement): number { + const anchorWidth = getDomNodePagePosition(container).width; + const windowWidth = getWindow(container).innerWidth; + const minWidth = Math.max(anchorWidth, BLOCKED_DROPDOWN_MIN_WIDTH); + const maxWidth = windowWidth * BLOCKED_DROPDOWN_MAX_WIDTH_RATIO; + return Math.round(Math.min(minWidth, maxWidth)); + } + + /** + * Anchor the blocked-sessions dropdown so it is horizontally centered on the + * command center box. Because the dropdown can be wider than the box, we hand + * the context view a zero-width anchor positioned at the dropdown's target + * left edge (the box center minus half the dropdown width). + */ + private _getBlockedDropdownAnchor(container: HTMLElement): IAnchor { + const position = getDomNodePagePosition(container); + const width = this._computeBlockedDropdownWidth(container); + const centerX = position.left + position.width / 2; + return { + x: Math.round(centerX - width / 2), + y: position.top, + width: 0, + height: position.height, + }; + } + + private _openBlockedSession(resource: URI, preserveFocus: boolean, sideBySide: boolean): void { + if (sideBySide) { + const session = this.sessionsManagementService.getSession(resource); + if (session) { + openSessionToTheSide(this.sessionsService, session, { preserveFocus }).catch(onUnexpectedError); + return; + } } + this.sessionsService.openSession(resource, { preserveFocus }).catch(onUnexpectedError); } /** @@ -264,11 +703,44 @@ export class SessionsTitleBarContribution extends Disposable implements IWorkben when: IsAuxiliaryWindowContext.negate() })); + // The blocked-sessions dropdown header's "Show All Sessions" action dismisses + // the dropdown (a transient context view) before opening the full sessions + // picker, so the popup doesn't linger behind it. + this._register(CommandsRegistry.registerCommand(SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, accessor => { + openBlockedSessionsView?.close(); + return accessor.get(ICommandService).executeCommand(SHOW_SESSIONS_PICKER_COMMAND_ID); + })); + + // Contribute the action to the blocked-sessions dropdown header toolbar. + this._register(MenuRegistry.appendMenuItem(Menus.BlockedSessionsHeader, { + command: { + id: SHOW_ALL_SESSIONS_FROM_BLOCKED_LIST_COMMAND_ID, + title: localize('showAllSessions', "Show All Sessions"), + icon: Codicon.listSelection, + }, + group: 'navigation', + order: 1, + when: IsAuxiliaryWindowContext.negate() + })); + this._register(actionViewItemService.register(Menus.CommandCenter, Menus.TitleBarSessionTitle, (action, options) => { if (!(action instanceof SubmenuItemAction)) { return undefined; } - return instantiationService.createInstance(SessionsTitleBarWidget, action, options); + return instantiationService.createInstance(SessionsTitleBarWidget, action, options, undefined, undefined, undefined); }, undefined)); } } + +// Escape closes the blocked-sessions dropdown while it is open. Registered as a +// high-weight keybinding scoped to `SessionsBlockedSessionsVisibleContext` (rather +// than relying on focus being inside the dropdown) so it reliably wins over other +// Escape handlers, mirroring how the quick pick scopes its dismiss keybinding to an +// "is visible" context key. +KeybindingsRegistry.registerCommandAndKeybindingRule({ + id: HIDE_BLOCKED_SESSIONS_COMMAND_ID, + weight: KeybindingWeight.SessionsContrib + 100, + when: SessionsBlockedSessionsVisibleContext, + primary: KeyCode.Escape, + handler: () => openBlockedSessionsView?.close(), +}); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 708d4b2da3c6ab..51f8f344b3607f 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -5,6 +5,7 @@ import '../media/sessionsList.css'; import * as DOM from '../../../../../base/browser/dom.js'; +import { synchronizeCSSAnimations } from '../../../../../base/browser/animationSync.js'; import { Gesture } from '../../../../../base/browser/touch.js'; import { IListVirtualDelegate, ListDragOverEffectPosition, ListDragOverEffectType, NotSelectableGroupId } from '../../../../../base/browser/ui/list/list.js'; import { IListStyles } from '../../../../../base/browser/ui/list/listWidget.js'; @@ -35,8 +36,9 @@ import { ServiceCollection } from '../../../../../platform/instantiation/common/ import { WorkbenchObjectTree } from '../../../../../platform/list/browser/listService.js'; import { IStyleOverride, defaultButtonStyles, defaultFindWidgetStyles, defaultInputBoxStyles, defaultToggleStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { GITHUB_REMOTE_FILE_SCHEME, ISession, ISessionWorkspace, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { Action, ActionRunner, IAction, Separator, SubmenuAction } from '../../../../../base/common/actions.js'; @@ -83,6 +85,10 @@ export const SessionItemToolbarMenuId = new MenuId('SessionItemToolbar'); export const SessionItemContextMenuId = MenuId.SessionItemContextMenu; export const SessionSectionToolbarMenuId = new MenuId('SessionSectionToolbar'); export const SessionGroupToolbarMenuId = new MenuId('SessionGroupToolbar'); + +/** Controls whether empty default groups (Pinned, Chats) are shown in the sessions list. */ +export const SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING = 'sessions.list.showEmptyDefaultGroups'; + export const IsSessionPinnedContext = new RawContextKey<boolean>('sessionItem.isPinned', false); export const SessionItemHasBranchNameContext = new RawContextKey<boolean>('sessionItem.hasBranchName', false); /** Whether the focused session item currently belongs to a user group. */ @@ -134,7 +140,14 @@ export interface ISessionShowMore { readonly remainingCount: number; } -export type SessionListItem = ISession | ISessionSection | ISessionGroupItem | ISessionShowMore; +/** Synthetic muted row shown when a section (currently only "Chats") is empty. */ +export interface ISessionPlaceholder { + readonly placeholder: true; + readonly sectionId: string; + readonly label: string; +} + +export type SessionListItem = ISession | ISessionSection | ISessionGroupItem | ISessionShowMore | ISessionPlaceholder; function isSessionGroupItem(item: SessionListItem): item is ISessionGroupItem { return 'group' in item; @@ -148,13 +161,23 @@ function isSessionShowMore(item: SessionListItem): item is ISessionShowMore { return 'showMore' in item && (item as ISessionShowMore).showMore === true; } +function isSessionPlaceholder(item: SessionListItem): item is ISessionPlaceholder { + return 'placeholder' in item && (item as ISessionPlaceholder).placeholder === true; +} + function isSessionItem(item: SessionListItem): item is ISession { - return !isSessionGroupItem(item) && !isSessionSection(item) && !isSessionShowMore(item); + return !isSessionGroupItem(item) && !isSessionSection(item) && !isSessionShowMore(item) && !isSessionPlaceholder(item); } const SHOW_MORE_FOLDERS_LABEL = '__more_folders__'; const FOUR_DAYS_MS = 4 * 24 * 60 * 60 * 1000; +/** + * Default number of terminal-command lines shown in a session row's approval + * prompt. The blocked-sessions dropdown overrides this to show more lines. + */ +const DEFAULT_APPROVAL_ROW_MAX_LINES = 3; + //#endregion //#region Tree Delegate @@ -171,10 +194,12 @@ class SessionsTreeDelegate implements IListVirtualDelegate<SessionListItem> { private static readonly ITEM_HEIGHT_PHONE = 76; private static readonly SECTION_HEIGHT = 26; private static readonly SHOW_MORE_HEIGHT = 26; + private static readonly PLACEHOLDER_HEIGHT = 26; constructor( private readonly _approvalModel: AgentSessionApprovalModel | undefined, private readonly _isPhone: () => boolean, + private readonly _approvalRowMaxLines: number = DEFAULT_APPROVAL_ROW_MAX_LINES, ) { } getHeight(element: SessionListItem): number { @@ -184,12 +209,15 @@ class SessionsTreeDelegate implements IListVirtualDelegate<SessionListItem> { if (isSessionShowMore(element)) { return SessionsTreeDelegate.SHOW_MORE_HEIGHT; } + if (isSessionPlaceholder(element)) { + return SessionsTreeDelegate.PLACEHOLDER_HEIGHT; + } let height = this._isPhone() ? SessionsTreeDelegate.ITEM_HEIGHT_PHONE : SessionsTreeDelegate.ITEM_HEIGHT; if (this._approvalModel) { const approval = getFirstApprovalAcrossChats(this._approvalModel, element as ISession, undefined); if (approval) { - height += SessionItemRenderer.getApprovalRowHeight(approval.label); + height += SessionItemRenderer.getApprovalRowHeight(approval.label, this._approvalRowMaxLines); } } return height; @@ -209,6 +237,9 @@ class SessionsTreeDelegate implements IListVirtualDelegate<SessionListItem> { if (isSessionShowMore(element)) { return SessionShowMoreRenderer.TEMPLATE_ID; } + if (isSessionPlaceholder(element)) { + return SessionPlaceholderRenderer.TEMPLATE_ID; + } return SessionItemRenderer.TEMPLATE_ID; } } @@ -238,11 +269,17 @@ class SessionItemActionRunner extends ActionRunner { } } +// Keyframes name of the in-progress title shimmer (see `session-title-shimmer` +// in sessionsList.css). Used to phase-align the shimmer across rows. +const SESSION_TITLE_SHIMMER_ANIMATION_NAME = 'session-title-shimmer'; +const SESSION_TITLE_SHIMMER_ANIMATION_NAMES = new Set([SESSION_TITLE_SHIMMER_ANIMATION_NAME]); + interface ISessionItemTemplate { readonly container: HTMLElement; readonly statusIcon: SessionStatusIcon; readonly title: HighlightedLabel; - readonly titleToolbar: MenuWorkbenchToolBar; + readonly titleContainer: HTMLElement; + readonly titleToolbar: MenuWorkbenchToolBar | undefined; readonly detailsRow: HTMLElement; readonly approvalRow: HTMLElement; readonly approvalLabel: HTMLElement; @@ -252,24 +289,37 @@ interface ISessionItemTemplate { readonly elementDisposables: DisposableStore; } +/** Payload emitted when the user approves a session's pending action. */ +export interface IApprovedSession { + readonly session: ISession; + /** + * Identity of the approval that was allowed, so consumers can tell this exact + * approval apart from a later, distinct one on the same session. + */ + readonly approvalId: string; +} + class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, ISessionItemTemplate> { static readonly TEMPLATE_ID = 'session-item'; readonly templateId = SessionItemRenderer.TEMPLATE_ID; - private static readonly APPROVAL_ROW_MAX_LINES = 3; private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18; private static readonly _APPROVAL_ROW_OVERHEAD = 14; - static getApprovalRowHeight(label: string): number { - const lineCount = Math.min(label.split(/\r?\n/).length, SessionItemRenderer.APPROVAL_ROW_MAX_LINES); + static getApprovalRowHeight(label: string, maxLines: number = DEFAULT_APPROVAL_ROW_MAX_LINES): number { + const lineCount = Math.min(label.split(/\r?\n/).length, maxLines); return lineCount * SessionItemRenderer._APPROVAL_ROW_LINE_HEIGHT + SessionItemRenderer._APPROVAL_ROW_OVERHEAD; } private readonly _onDidChangeItemHeight = new Emitter<ISession>(); readonly onDidChangeItemHeight: Event<ISession> = this._onDidChangeItemHeight.event; + private readonly _onDidApproveSession = new Emitter<IApprovedSession>(); + /** Fires when the user approves a session's pending action via its "Allow" button. */ + readonly onDidApproveSession: Event<IApprovedSession> = this._onDidApproveSession.event; + constructor( - private readonly options: { grouping: () => SessionsGrouping; sorting: () => SessionsSorting; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable<readonly (IActiveSession | undefined)[]>; getMultiSelectedSessions: (session: ISession) => ISession[] }, + private readonly options: { grouping: () => SessionsGrouping; isPinned: (session: ISession) => boolean; isRead: (session: ISession) => boolean; visibleSessions: IObservable<readonly (IActiveSession | undefined)[]>; getMultiSelectedSessions: (session: ISession) => ISession[]; isInChatsSection: (session: ISession) => boolean; showHover: boolean; approvalRowMaxLines: number; toolbarActions: boolean }, private readonly approvalModel: AgentSessionApprovalModel | undefined, private readonly instantiationService: IInstantiationService, private readonly contextKeyService: IContextKeyService, @@ -278,8 +328,7 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, private readonly sessionsProvidersService: ISessionsProvidersService, // TEMPORARY — see the note on the `IAgentSessionsService` import above (#320480). private readonly agentSessionsService: IAgentSessionsService, - ) { - } + ) { } renderTemplate(container: HTMLElement): ISessionItemTemplate { const disposables = new DisposableStore(); @@ -291,7 +340,19 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, const statusIcon = disposables.add(this.instantiationService.createInstance(SessionStatusIcon, iconContainer)); const mainCol = DOM.append(container, $('.session-main')); const titleRow = DOM.append(mainCol, $('.session-title-row')); - const title = disposables.add(new HighlightedLabel(DOM.append(titleRow, $('.session-title')))); + const titleContainer = DOM.append(titleRow, $('.session-title')); + const title = disposables.add(new HighlightedLabel(titleContainer)); + // The shimmer's CSS animation restarts from zero whenever it (re)starts — + // e.g. selecting then deselecting an in-progress row re-adds the animation + // via the `:not(.selected)` selector, and rows already shimmering at first + // render each started on their own clock. Anchor every (re)start to the + // shared document timeline so all rows stay perfectly in phase. This fires + // once per start (not per frame), so it is effectively free. + disposables.add(DOM.addDisposableListener(titleContainer, DOM.EventType.ANIMATION_START, (e: AnimationEvent) => { + if (e.target === titleContainer && e.animationName === SESSION_TITLE_SHIMMER_ANIMATION_NAME) { + synchronizeCSSAnimations(titleContainer, { animationNames: SESSION_TITLE_SHIMMER_ANIMATION_NAMES }); + } + })); const titleToolbarContainer = DOM.append(titleRow, $('.session-title-toolbar')); // The list opens a session on click and on Gesture `tap` (touch). // DOM event propagation stops only cover mouse/pointer events; the @@ -312,13 +373,18 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, const contextKeyService = disposables.add(this.contextKeyService.createScoped(container)); const scopedInstantiationService = disposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); - const actionRunner = disposables.add(new SessionItemActionRunner(this.options.getMultiSelectedSessions)); - const titleToolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, titleToolbarContainer, SessionItemToolbarMenuId, { - menuOptions: { shouldForwardArgs: true }, - actionRunner, - })); + // When toolbar actions are disabled (e.g. the blocked-sessions dropdown) the row + // renders no inline action toolbar at all. + let titleToolbar: MenuWorkbenchToolBar | undefined; + if (this.options.toolbarActions) { + const actionRunner = disposables.add(new SessionItemActionRunner(this.options.getMultiSelectedSessions)); + titleToolbar = disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, titleToolbarContainer, SessionItemToolbarMenuId, { + menuOptions: { shouldForwardArgs: true }, + actionRunner, + })); + } - return { container, statusIcon, title, titleToolbar, detailsRow, approvalRow, approvalLabel, approvalButtonContainer, contextKeyService, disposables, elementDisposables }; + return { container, statusIcon, title, titleContainer, titleToolbar, detailsRow, approvalRow, approvalLabel, approvalButtonContainer, contextKeyService, disposables, elementDisposables }; } renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionItemTemplate): void { @@ -339,17 +405,20 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, // moved into the provider — see the note on the import above. this.agentSessionsService.model.observeSession(element.resource); - // Rich hover on the row showing folder, branch, diff stats and provider. - // Shown to the right of the row, similar to the extensions list. - template.elementDisposables.add(this.hoverService.setupDelayedHover(template.container, () => ({ - content: buildSessionHoverContent(element, this.sessionsProvidersService), - appearance: { showPointer: true }, - position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, - persistence: { hideOnHover: false }, - }), { groupId: 'sessions-list' })); + if (this.options.showHover) { + // Rich hover on the row showing folder, branch, diff stats and provider. + template.elementDisposables.add(this.hoverService.setupDelayedHover(template.container, () => ({ + content: buildSessionHoverContent(element, this.sessionsProvidersService), + appearance: { showPointer: true }, + position: { hoverPosition: HoverPosition.RIGHT, forcePosition: true }, + persistence: { hideOnHover: false }, + }), { groupId: 'sessions-list' })); + } // Toolbar context - template.titleToolbar.context = element; + if (template.titleToolbar) { + template.titleToolbar.context = element; + } // Context keys const isPinned = this.options.isPinned(element); @@ -390,6 +459,9 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, // owned by the SessionStatusIcon widget; here we just feed it the latest state. // Row recycling re-feeds the widget, which cross-fades to the new session's icon. template.statusIcon.setStatus(sessionStatus, isRead, isArchived, gitHubInfo?.pullRequest?.icon); + // The title shimmer (toggled by the `in-progress` class) is phase-aligned + // across rows via an `animationstart` handler on the title element, so no + // per-state work is needed here. template.container.classList.toggle('in-progress', sessionStatus === SessionStatus.InProgress); template.container.classList.toggle('needs-input', sessionStatus === SessionStatus.NeedsInput); template.container.classList.toggle('unread', !isRead && !isArchived); @@ -416,7 +488,7 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, const hideDetails = sessionStatus === SessionStatus.InProgress || sessionStatus === SessionStatus.NeedsInput; if (!hideDetails) { - timeDate = this.options.sorting() === SessionsSorting.Updated ? element.updatedAt.read(reader) : element.createdAt; + timeDate = element.updatedAt.read(reader); } // Clear and rebuild details row DOM.clearNode(template.detailsRow); @@ -426,10 +498,18 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, const isWorkspaceSession = workspace && workspace.folders.length > 0 && workspace?.folders[0]?.gitRepository?.workTreeUri === undefined; - const icon = workspace?.isVirtualWorkspace ? Codicon.cloudCompact : isWorkspaceSession ? Codicon.folderCompact : Codicon.worktreeCompact; - const typeIconEl = DOM.append(template.detailsRow, $('span.session-details-icon')); - DOM.append(typeIconEl, $(`span${ThemeIcon.asCSSSelector(icon)}`)); - parts.push(typeIconEl); + // The chat icon means "quick chat" and must come from the + // `isQuickChat` tag, never from `workspace === undefined` (which is + // also transiently true for a still-resolving workspace session). + const icon = isQuickChatSession(element) ? Codicon.commentCompact : workspace?.isVirtualWorkspace ? Codicon.cloudCompact : isWorkspaceSession ? Codicon.folderCompact : Codicon.worktreeCompact; + // The per-row chat icon is redundant under the "Chats" section, whose + // header already carries one; keep it elsewhere (Pinned / groups). + const suppressChatIcon = isQuickChatSession(element) && this.options.isInChatsSection(element); + if (!suppressChatIcon) { + const typeIconEl = DOM.append(template.detailsRow, $('span.session-details-icon')); + DOM.append(typeIconEl, $(`span${ThemeIcon.asCSSSelector(icon)}`)); + parts.push(typeIconEl); + } } // Workspace badge — show when not grouped by workspace, @@ -563,9 +643,9 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, template.approvalRow.classList.toggle('visible', visible); if (info) { - // Render up to 3 lines as separate code blocks + // Render up to `maxLines` lines as separate code blocks const lines = info.label.split('\n'); - const maxLines = SessionItemRenderer.APPROVAL_ROW_MAX_LINES; + const maxLines = this.options.approvalRowMaxLines; const visibleLines = lines.slice(0, maxLines); if (lines.length > maxLines) { visibleLines[maxLines - 1] = `${visibleLines[maxLines - 1]} \u2026`; @@ -579,13 +659,14 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, template.approvalLabel.textContent = ''; buttonStore.add(this.markdownRendererService.render(labelContent, {}, template.approvalLabel)); - // Hover with full content as a code block - const fullContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); - buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, { - content: fullContent, - style: HoverStyle.Pointer, - position: { hoverPosition: HoverPosition.BELOW }, - })); + if (this.options.showHover) { + const fullContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); + buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, { + content: fullContent, + style: HoverStyle.Pointer, + position: { hoverPosition: HoverPosition.BELOW }, + })); + } template.approvalButtonContainer.textContent = ''; const button = buttonStore.add(new Button(template.approvalButtonContainer, { @@ -594,7 +675,13 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, ...defaultButtonStyles })); button.label = localize('allowAction', "Allow"); - buttonStore.add(button.onDidClick(() => info.confirm())); + buttonStore.add(button.onDidClick(() => { + // Capture the approval's identity BEFORE confirming: `confirm()` may + // synchronously clear the pending approval, so we can't read it after. + const approvalId = agentSessionApprovalId(info); + info.confirm(); + this._onDidApproveSession.fire({ session: element, approvalId }); + })); } if (wasVisible !== visible) { @@ -634,6 +721,7 @@ class SessionItemRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, interface ISessionSectionTemplate { readonly container: HTMLElement; + readonly icon: HTMLElement; readonly label: HTMLElement; readonly count: HTMLElement; readonly toolbar: MenuWorkbenchToolBar; @@ -659,6 +747,8 @@ class SessionSectionRenderer implements ITreeRenderer<SessionListItem, FuzzyScor const disposables = new DisposableStore(); container.classList.add('session-section'); + const icon = DOM.append(container, $('span.session-section-icon')); + icon.setAttribute('aria-hidden', 'true'); const label = DOM.append(container, $('span.session-section-label')); const count = DOM.append(container, $('span.session-section-count')); const toolbarContainer = DOM.append(container, $('.session-section-toolbar')); @@ -671,7 +761,7 @@ class SessionSectionRenderer implements ITreeRenderer<SessionListItem, FuzzyScor menuOptions: { shouldForwardArgs: true }, })); - return { container, label, count, toolbar, chevron, contextKeyService, disposables }; + return { container, icon, label, count, toolbar, chevron, contextKeyService, disposables }; } renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: ISessionSectionTemplate): void { @@ -682,6 +772,15 @@ class SessionSectionRenderer implements ITreeRenderer<SessionListItem, FuzzyScor this.templatesByElement.set(element, template); this.templatesById.set(element.id, template); template.container.classList.remove(SESSION_HEADER_DROP_TARGET_CLASS); + + // Leading icon for the "Pinned" and "Chats" (quick chats) section headers. + // Templates are reused across rows, so recompute the icon every render. + const sectionIcon = element.id === QUICK_CHATS_SECTION_ID ? Codicon.commentDiscussion + : element.id === 'pinned' ? Codicon.pinned + : undefined; + template.icon.className = sectionIcon ? `session-section-icon ${ThemeIcon.asClassName(sectionIcon)}` : 'session-section-icon'; + template.icon.style.display = sectionIcon ? '' : 'none'; + template.label.textContent = element.label; if (this.hideSectionCount) { template.count.textContent = ''; @@ -929,6 +1028,26 @@ class SessionShowMoreRenderer implements ITreeRenderer<SessionListItem, FuzzySco disposeTemplate(_template: HTMLElement): void { } } +class SessionPlaceholderRenderer implements ITreeRenderer<SessionListItem, FuzzyScore, HTMLElement> { + static readonly TEMPLATE_ID = 'session-placeholder'; + readonly templateId = SessionPlaceholderRenderer.TEMPLATE_ID; + + renderTemplate(container: HTMLElement): HTMLElement { + container.classList.add('session-placeholder'); + return DOM.append(container, $('span.session-placeholder-label')); + } + + renderElement(node: ITreeNode<SessionListItem, FuzzyScore>, _index: number, template: HTMLElement): void { + const element = node.element; + if (!isSessionPlaceholder(element)) { + return; + } + template.textContent = element.label; + } + + disposeTemplate(_template: HTMLElement): void { } +} + //#region Accessibility class SessionsAccessibilityProvider { @@ -955,9 +1074,12 @@ class SessionsAccessibilityProvider { : localize('showMoreWorkspacesAria', "Show {0} more workspaces", element.remainingCount) : localize('showMoreAria', "Show {0} more sessions", element.remainingCount); } + if (isSessionPlaceholder(element)) { + return element.label; + } const title = element.title.get(); - const created = fromNow(element.createdAt, true); - return localize('sessionItemAria', "{0}, created {1}", title, created); + const updated = fromNow(element.updatedAt.get(), true); + return localize('sessionItemAria', "{0}, updated {1}", title, updated); } } @@ -1035,6 +1157,9 @@ class SessionsListDragAndDrop extends Disposable implements ITreeDragAndDrop<Ses if (isSessionShowMore(element)) { return null; } + if (isSessionPlaceholder(element)) { + return null; + } return element.resource.toString(); } @@ -1414,8 +1539,12 @@ export class SessionsList extends Disposable implements ISessionsList { private _editingGroupId: string | undefined; private _groupRenderer!: SessionGroupRenderer; private _sectionRenderer!: SessionSectionRenderer; + private _sessionsProvidersService!: ISessionsProvidersService; private _dropTargetHeader: ISessionDropTargetHeader | undefined; + /** Resources of sessions currently rendered under the "Chats" section. */ + private readonly _chatsSectionSessionIds = new Set<string>(); + /** * Snapshot of the currently-rendered reorderable top-level headers (groups * and, in workspace mode, workspace sections) in display order, by reorder @@ -1448,6 +1577,7 @@ export class SessionsList extends Disposable implements ISessionsList { @IKeybindingService private readonly keybindingService: IKeybindingService, @ICommandService private readonly commandService: ICommandService, @IWorkbenchAssignmentService private readonly assignmentService: IWorkbenchAssignmentService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); @@ -1474,10 +1604,34 @@ export class SessionsList extends Disposable implements ISessionsList { const markdownRendererService = instantiationService.invokeFunction(accessor => accessor.get(IMarkdownRendererService)); const hoverService = instantiationService.invokeFunction(accessor => accessor.get(IHoverService)); const sessionsProvidersService = instantiationService.invokeFunction(accessor => accessor.get(ISessionsProvidersService)); + this._sessionsProvidersService = sessionsProvidersService; + // Re-render so the always-visible "Chats" section appears/disappears when a + // quick-chat-capable provider is (de)registered (e.g. agent host toggled), + // or when a registered provider toggles a capability at runtime (e.g. its + // `supportsQuickChats` flips with agent-host enablement). + const providerCapabilityListeners = this._register(new DisposableStore()); + const subscribeProviderCapabilities = () => { + providerCapabilityListeners.clear(); + for (const provider of sessionsProvidersService.getProviders()) { + if (provider.onDidChangeCapabilities) { + providerCapabilityListeners.add(provider.onDidChangeCapabilities(() => this.update())); + } + } + }; + subscribeProviderCapabilities(); + this._register(sessionsProvidersService.onDidChangeProviders(() => { + subscribeProviderCapabilities(); + this.update(); + })); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING)) { + this.update(); + } + })); // TEMPORARY (#320480): see the note on the `IAgentSessionsService` import. const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); const sessionRenderer = new SessionItemRenderer( - { grouping: this.options.grouping, sorting: this.options.sorting, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s) }, + { grouping: this.options.grouping, isPinned: s => this.isSessionPinned(s), isRead: s => this.isSessionRead(s), visibleSessions: this._sessionsService.visibleSessions, getMultiSelectedSessions: s => this.getMultiSelectedSessions(s), isInChatsSection: s => this._chatsSectionSessionIds.has(s.resource.toString()), showHover: true, approvalRowMaxLines: DEFAULT_APPROVAL_ROW_MAX_LINES, toolbarActions: true }, approvalModel, instantiationService, contextKeyService, @@ -1488,6 +1642,7 @@ export class SessionsList extends Disposable implements ISessionsList { ); const showMoreRenderer = new SessionShowMoreRenderer(); + const placeholderRenderer = new SessionPlaceholderRenderer(); const sectionRenderer = new SessionSectionRenderer(true /* hideSectionCount */, instantiationService, contextKeyService); this._sectionRenderer = sectionRenderer; const groupRenderer = new SessionGroupRenderer({ @@ -1512,6 +1667,7 @@ export class SessionsList extends Disposable implements ISessionsList { sectionRenderer, groupRenderer, showMoreRenderer, + placeholderRenderer, ], { accessibilityProvider: new SessionsAccessibilityProvider(), @@ -1537,6 +1693,9 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionShowMore(element)) { return `show-more:${element.kind}:${element.mode}:${element.sectionId}`; } + if (isSessionPlaceholder(element)) { + return `placeholder:${element.sectionId}`; + } return element.resource.toString(); }, getGroupId: (element: SessionListItem) => { @@ -1549,6 +1708,9 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionShowMore(element)) { return NotSelectableGroupId; } + if (isSessionPlaceholder(element)) { + return NotSelectableGroupId; + } // Use a distinct group for archived (done) sessions so that // multi-selection cannot span the workspace and done sections. return element.isArchived.get() ? 2 : 1; @@ -1578,6 +1740,9 @@ export class SessionsList extends Disposable implements ISessionsList { if (isSessionShowMore(element)) { return element.sectionLabel; } + if (isSessionPlaceholder(element)) { + return element.label; + } return element.title.get(); } }, @@ -1605,6 +1770,9 @@ export class SessionsList extends Disposable implements ISessionsList { this.update(); return; } + if (isSessionPlaceholder(element)) { + return; + } if (!isSessionSection(element) && !isSessionGroupItem(element)) { this.markRead(element); // A deliberate left mouse click on a session should move keyboard @@ -1686,10 +1854,20 @@ export class SessionsList extends Disposable implements ISessionsList { updateFindPatternState(); })); - this._register(this._sessionsManagementService.onDidChangeSessions(() => { + this._register(this._sessionsManagementService.onDidChangeSessions(e => { if (this.visible) { this.refresh(); } + // A removed session may have been the last one in its workspace. + // Garbage-collect manual order / promotion entries for identities + // that no longer exist. This runs only on removals (never on + // additions or the initial load) so that asynchronous session + // loading on a window reload can never prune the user's manual + // ordering of workspaces relative to groups before their sessions + // have loaded. + if (e.removed.length > 0) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionsListModelService.onDidChange(() => { @@ -1698,10 +1876,17 @@ export class SessionsList extends Disposable implements ISessionsList { } })); - this._register(this._sessionGroupsService.onDidChange(() => { + this._register(this._sessionGroupsService.onDidChange(e => { if (this.visible) { this.update(); } + // Garbage-collect manual order / promotion entries when groups are + // deleted or evicted. Group changes are user-driven and happen after + // sessions have loaded, so pruning here is safe (unlike at render + // time during the asynchronous initial load). + if (e.groupsChanged) { + this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); + } })); this._register(this._sessionSectionOrderService.onDidChange(() => { @@ -1801,10 +1986,6 @@ export class SessionsList extends Disposable implements ISessionsList { const sorting = this.options.sorting(); const sortKeyForGrouping = (s: ISession, srt: SessionsSorting) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt)); - // Garbage-collect manual order/promotion entries for groups and - // workspaces that no longer exist (does not affect the visible order). - this._sessionSectionOrderService.retain(this.liveSectionOrderIds()); - // Pull regular (non-pinned, non-archived) grouped sessions out of the // normal date/workspace sectioning so they render under their group. // Pinned and archived sessions keep their precedence and stay in their @@ -1851,7 +2032,31 @@ export class SessionsList extends Disposable implements ISessionsList { const sections = groupSessionsForList(forSections, grouping, sorting, session => this.isSessionPinned(session), (s, srt) => this._sessionsListModelService.getSortKey(s, sortingToMode(srt))); - const hasTodaySessions = sections.some(s => s.id === 'today' && s.sessions.length > 0); + // Track which sessions render under the "Chats" section so their per-row + // chat icon can be suppressed (the section header already carries one). + this._chatsSectionSessionIds.clear(); + for (const s of sections.find(section => section.id === QUICK_CHATS_SECTION_ID)?.sessions ?? []) { + this._chatsSectionSessionIds.add(s.resource.toString()); + } + + const hasRecentSessions = sections.some(s => s.id === 'recent' && s.sessions.length > 0); + + // Keep the "Pinned" and "Chats" default sections visible even when empty so + // they stay discoverable, unless the user opts out via the setting. + const showEmptyDefaultGroups = this.configurationService.getValue<boolean>(SESSIONS_LIST_SHOW_EMPTY_DEFAULT_GROUPS_SETTING); + + // Keep the "Pinned" section always visible (even with no pinned sessions) + // so it is discoverable, mirroring the always-visible "Chats" section. + if (showEmptyDefaultGroups && !sections.some(s => s.id === 'pinned')) { + sections.push({ id: 'pinned', label: localize('pinned', "Pinned"), sessions: [] }); + } + + // Keep the "Chats" section always visible (even with no quick chats) so its + // header — leading chat icon, label, and the "+" create action — is always + // reachable. Only when a provider can actually serve quick chats. + if (showEmptyDefaultGroups && this._someProviderSupportsQuickChats() && !sections.some(s => s.id === QUICK_CHATS_SECTION_ID)) { + sections.push({ id: QUICK_CHATS_SECTION_ID, label: localize('chatsSection', "Chats"), sessions: [] }); + } // Partition workspace sections into "primary" (meets criteria) and "more" // when grouping by workspace. An active find pattern bypasses partitioning @@ -1928,12 +2133,20 @@ export class SessionsList extends Disposable implements ISessionsList { const limitSessions = isWorkspaceGroup && !this.hasFindPattern && this.workspaceGroupCapped; - const sectionChildren = renderSessionChildren(section.sessions, section.id, section.label, limitSessions); + let sectionChildren = renderSessionChildren(section.sessions, section.id, section.label, limitSessions); + + // The always-visible "Chats" and "Pinned" sections show a muted + // placeholder row when they have no sessions yet. + if (section.id === QUICK_CHATS_SECTION_ID && section.sessions.length === 0) { + sectionChildren = [{ element: { placeholder: true as const, sectionId: section.id, label: localize('noChats', "No chats") } }]; + } else if (section.id === 'pinned' && section.sessions.length === 0) { + sectionChildren = [{ element: { placeholder: true as const, sectionId: section.id, label: localize('noPinnedSessions', "No pinned sessions") } }]; + } // Default collapse state for older time sections let defaultCollapsed: boolean | ObjectTreeElementCollapseState = ObjectTreeElementCollapseState.PreserveOrExpanded; - if (grouping === SessionsGrouping.Date && hasTodaySessions) { - const olderSections = ['yesterday', 'thisWeek', 'older', 'archived']; + if (grouping === SessionsGrouping.Date && hasRecentSessions) { + const olderSections = ['older', 'archived']; if (olderSections.includes(section.id)) { defaultCollapsed = ObjectTreeElementCollapseState.PreserveOrCollapsed; } @@ -1942,6 +2155,13 @@ export class SessionsList extends Disposable implements ISessionsList { defaultCollapsed = ObjectTreeElementCollapseState.PreserveOrCollapsed; } + // The always-visible "Pinned" and "Chats" sections start collapsed on + // first open; the user's later choice is persisted and honored via + // getSavedCollapseState. + if (section.id === 'pinned' || section.id === QUICK_CHATS_SECTION_ID) { + defaultCollapsed = ObjectTreeElementCollapseState.PreserveOrCollapsed; + } + return { element: section as SessionListItem, collapsible: true, @@ -1964,6 +2184,13 @@ export class SessionsList extends Disposable implements ISessionsList { children.push(renderSection(pinnedSection)); } + // Quick chats render as a single "Chats" entry directly below Pinned (above + // the workspace/date groups) in both grouping modes. + const quickChatsSection = sections.find(s => s.id === QUICK_CHATS_SECTION_ID); + if (quickChatsSection) { + children.push(renderSection(quickChatsSection)); + } + const renderGroupById = (id: string): void => { const groupItem = groupItemsById.get(id.slice('group:'.length)); if (groupItem) { @@ -1982,7 +2209,7 @@ export class SessionsList extends Disposable implements ISessionsList { renderGroupById(id); } for (const section of sections) { - if (section.id === 'pinned' || section.id === 'archived') { + if (section.id === 'pinned' || section.id === 'archived' || section.id === QUICK_CHATS_SECTION_ID) { continue; } children.push(renderSection(section)); @@ -2230,9 +2457,25 @@ export class SessionsList extends Disposable implements ISessionsList { if (groupSessions.length === 0) { return; } + this._sessionsListModelService.unpinSessions(groupSessions); const group = this._sessionGroupsService.createGroup(localize('newGroupName', "New Group"), groupSessions.map(s => s.sessionId)); this._editingGroupId = group.id; this.update(); + this.revealGroup(group.id); + } + + /** Scroll the group's header into view so its inline name editor is visible. */ + private revealGroup(groupId: string): void { + const root = this.tree.getNode(); + for (const node of root.children) { + const element = node.element; + if (element && isSessionGroupItem(element) && element.group.id === groupId) { + if (this.tree.hasElement(element) && this.tree.getRelativeTop(element) === null) { + this.tree.reveal(element, 0.5); + } + return; + } + } } /** Begin inline renaming of the group's header. */ @@ -2246,9 +2489,8 @@ export class SessionsList extends Disposable implements ISessionsList { addSessionsToGroup(sessions: ISession[], groupId: string, target?: ISession, position?: 'before' | 'after'): void { const groupSessions = sessions.filter(session => !session.isArchived.get()); - for (const session of groupSessions) { - this._sessionGroupsService.addToGroup(session.sessionId, groupId); - } + this._sessionsListModelService.unpinSessions(groupSessions); + this._sessionGroupsService.addToGroup(groupSessions.map(s => s.sessionId), groupId); if (target && position) { this.reorderSessions(groupSessions, target, position); } @@ -2299,14 +2541,15 @@ export class SessionsList extends Disposable implements ISessionsList { * The set of top-level reorder identities that currently exist (every group, * plus every workspace label present across all sessions, regardless of * grouping mode or capping). Used to garbage-collect stale manual order and - * promotion entries. + * promotion entries. Reads sessions fresh from the management service so it + * reflects the latest loaded state even when the list is not visible. */ private liveSectionOrderIds(): Set<string> { const ids = new Set<string>(); for (const group of this._sessionGroupsService.getGroups()) { ids.add(`group:${group.id}`); } - for (const session of this.sessions) { + for (const session of this._sessionsManagementService.getSessions()) { ids.add(`workspace:${sessionWorkspaceLabel(session)}`); } return ids; @@ -2341,7 +2584,7 @@ export class SessionsList extends Disposable implements ISessionsList { private onContextMenu(e: ITreeContextMenuEvent<SessionListItem | null>): void { const element = e.element; - if (!element || isSessionSection(element) || isSessionShowMore(element)) { + if (!element || isSessionSection(element) || isSessionShowMore(element) || isSessionPlaceholder(element)) { return; } @@ -2361,8 +2604,8 @@ export class SessionsList extends Disposable implements ISessionsList { [SessionItemInGroupContext.key, inGroup], [SessionTypeContext.key, element.sessionType], [SessionProviderIdContext.key, element.providerId], - [SessionSupportsRenameContext.key, element.capabilities.supportsRename ?? false], - [SessionSupportsDeleteContext.key, element.capabilities.supportsDelete ?? false], + [SessionSupportsRenameContext.key, element.capabilities.get().supportsRename ?? false], + [SessionSupportsDeleteContext.key, element.capabilities.get().supportsDelete ?? false], ]; const menu = this.menuService.createMenu(SessionItemContextMenuId, this.contextKeyService.createOverlay(contextOverlay)); @@ -2477,6 +2720,11 @@ export class SessionsList extends Disposable implements ISessionsList { return this._sessionsListModelService.isSessionPinned(session); } + /** Whether any registered provider can create quick chats (gates the always-visible "Chats" section). */ + private _someProviderSupportsQuickChats(): boolean { + return this._sessionsProvidersService.getProviders().some(p => !!p.supportsQuickChats); + } + // -- Read/Unread -- markRead(session: ISession): void { @@ -2692,7 +2940,7 @@ export class SessionsList extends Disposable implements ISessionsList { //#region Approval Helpers -function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { +export function getFirstApprovalAcrossChats(approvalModel: AgentSessionApprovalModel, session: ISession, reader: IReader | undefined,): IAgentSessionApprovalInfo | undefined { let oldest: IAgentSessionApprovalInfo | undefined; for (const chat of session.chats.read(reader)) { const approval = approvalModel.getApproval(chat.resource).read(reader); @@ -2840,6 +3088,17 @@ export function computeReorderSortChanges(input: IReorderSortInput): { set: Map< return { set, clear }; } +/** Fixed section id for workspace-less "quick chat" sessions. */ +export const QUICK_CHATS_SECTION_ID = 'quickchats'; + +/** + * Whether a session is a workspace-less "quick chat", per the session's own + * {@link ISession.isQuickChat} flag (absent means `false`). + */ +export function isQuickChatSession(session: ISession): boolean { + return session.isQuickChat?.get() ?? false; +} + export function groupSessionsForList( sessions: ISession[], grouping: SessionsGrouping, @@ -2849,15 +3108,19 @@ export function groupSessionsForList( ): ISessionSection[] { const sorted = sortSessions(sessions, sorting, getSortKey); - // Archived always wins over pinned so done sessions stay grouped together. + // Archived wins over pinned (done sessions stay grouped); pinned wins over the + // quick-chats bucket so a pinned quick chat still surfaces in Pinned. const pinned: ISession[] = []; const archived: ISession[] = []; + const quickChats: ISession[] = []; const regular: ISession[] = []; for (const session of sorted) { if (session.isArchived.get()) { archived.push(session); } else if (isSessionPinned(session)) { pinned.push(session); + } else if (isQuickChatSession(session)) { + quickChats.push(session); } else { regular.push(session); } @@ -2868,6 +3131,12 @@ export function groupSessionsForList( sections.push({ id: 'pinned', label: localize('pinned', "Pinned"), sessions: pinned }); } + // Quick chats render as a single "Chats" entry directly below Pinned (above + // the workspace/date groups), regardless of grouping mode. + if (quickChats.length > 0) { + sections.push({ id: QUICK_CHATS_SECTION_ID, label: localize('chatsSection', "Chats"), sessions: quickChats }); + } + sections.push(...(grouping === SessionsGrouping.Workspace ? groupByWorkspace(regular) : groupByDate(regular, sorting, getSortKey))); @@ -2916,27 +3185,26 @@ export function groupByWorkspace(sessions: ISession[]): ISessionSection[] { return result; } +/** Maximum number of sessions shown in the "Recent" date section. */ +const RECENT_SESSIONS_LIMIT = 10; + export function groupByDate(sessions: ISession[], sorting: SessionsSorting, getSortKey?: (session: ISession, sorting: SessionsSorting) => number): ISessionSection[] { const key = getSortKey ?? defaultSortKey; const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); - const startOfYesterday = startOfToday - 86_400_000; const startOfWeek = startOfToday - 7 * 86_400_000; - const today: ISession[] = []; - const yesterday: ISession[] = []; - const week: ISession[] = []; + const recent: ISession[] = []; const older: ISession[] = []; + // `sessions` arrive sorted most-recent-first, so the first sessions within + // the last 7 days (capped at RECENT_SESSIONS_LIMIT) form the "Recent" + // section; everything else falls into "Older". for (const session of sessions) { const time = key(session, sorting); - if (time >= startOfToday) { - today.push(session); - } else if (time >= startOfYesterday) { - yesterday.push(session); - } else if (time >= startOfWeek) { - week.push(session); + if (time >= startOfWeek && recent.length < RECENT_SESSIONS_LIMIT) { + recent.push(session); } else { older.push(session); } @@ -2949,12 +3217,170 @@ export function groupByDate(sessions: ISession[], sorting: SessionsSorting, getS } }; - addGroup('today', localize('today', "Today"), today); - addGroup('yesterday', localize('yesterday', "Yesterday"), yesterday); - addGroup('thisWeek', localize('lastSevenDays', "Last 7 Days"), week); + addGroup('recent', localize('recent', "Recent"), recent); addGroup('older', localize('older', "Older"), older); return sections; } //#endregion + +//#region Flat List + +export interface ISessionsFlatListOptions { + readonly overrideStyles?: IStyleOverride<IListStyles>; + readonly showSessionHover?: boolean; + /** Called when a session row is opened (clicked / activated). */ + onSessionOpen(resource: URI, preserveFocus: boolean, sideBySide: boolean): void; + /** + * Approval model tracking pending tool confirmations for the shown sessions. + * When omitted the list creates and owns its own; injectable so tests and + * fixtures can supply pending approvals without a live chat session. + */ + readonly approvalModel?: AgentSessionApprovalModel; + /** + * Maximum number of terminal-command lines shown in a session's approval + * prompt. Defaults to the same limit as the main sessions list; the + * blocked-sessions dropdown passes a larger value. + */ + readonly approvalRowMaxLines?: number; + /** + * Whether each session row renders its inline action toolbar (pin, mark as done, + * etc.). Defaults to `true`; set to `false` for surfaces where those actions + * don't apply (e.g. the blocked-sessions dropdown), which renders no toolbar. + */ + readonly toolbarActions?: boolean; +} + +/** + * A lightweight, flat sessions list that renders session rows exactly like the + * main {@link SessionsList} but without any sections, groups or workspace + * headers. Only the sessions passed to {@link setSessions} are shown. Used by + * surfaces that need a focused, sectionless view of a specific set of sessions + * (e.g. the titlebar "N blocked" hover). + */ +export class SessionsFlatList extends Disposable { + + private static readonly ROW_HEIGHT = 54; + + private readonly _onDidChangeContentHeight = this._register(new Emitter<void>()); + readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; + private readonly _onDidApproveSession = this._register(new Emitter<IApprovedSession>()); + /** Fires when a session's pending action is approved from its "Allow" button. */ + readonly onDidApproveSession: Event<IApprovedSession> = this._onDidApproveSession.event; + private readonly tree: WorkbenchObjectTree<SessionListItem, FuzzyScore>; + private readonly _delegate: SessionsTreeDelegate; + private _sessions: readonly ISession[] = []; + + constructor( + container: HTMLElement, + private readonly options: ISessionsFlatListOptions, + @ISessionsService private readonly _sessionsService: ISessionsService, + @ISessionsListModelService private readonly _sessionsListModelService: ISessionsListModelService, + @IInstantiationService instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, + @IHoverService hoverService: IHoverService, + @ISessionsProvidersService sessionsProvidersService: ISessionsProvidersService, + ) { + super(); + + // Wrap in `.sessions-list-control` so the row styles scoped to that class + // (needs-input/pinned row highlights) apply exactly like the main list. + const listRoot = DOM.append(container, $('.sessions-list-control')); + const approvalModel = this.options.approvalModel ?? this._register(instantiationService.createInstance(AgentSessionApprovalModel)); + + // TEMPORARY (#320480): the row renderer reaches into a Copilot-provider + // internal to lazily resolve expensive session properties. Resolved via + // the instantiation service so this file's single suppressed import stays + // the only reference. See the note on the `IAgentSessionsService` import. + const agentSessionsService = instantiationService.invokeFunction(accessor => accessor.get(IAgentSessionsService)); + + const sessionRenderer = new SessionItemRenderer( + { + grouping: () => SessionsGrouping.Date, + isPinned: s => this._sessionsListModelService.isSessionPinned(s), + isRead: s => this._sessionsListModelService.isSessionRead(s), + visibleSessions: this._sessionsService.visibleSessions, + getMultiSelectedSessions: s => [s], + showHover: this.options.showSessionHover ?? true, + isInChatsSection: s => false, + approvalRowMaxLines: this.options.approvalRowMaxLines ?? DEFAULT_APPROVAL_ROW_MAX_LINES, + toolbarActions: this.options.toolbarActions ?? true, + }, + approvalModel, + instantiationService, + contextKeyService, + markdownRendererService, + hoverService, + sessionsProvidersService, + agentSessionsService, + ); + + this._delegate = new SessionsTreeDelegate(approvalModel, () => false, this.options.approvalRowMaxLines ?? DEFAULT_APPROVAL_ROW_MAX_LINES); + + this.tree = this._register(instantiationService.createInstance( + WorkbenchObjectTree<SessionListItem, FuzzyScore>, + 'SessionsFlatList', + listRoot, + this._delegate, + [sessionRenderer], + { + accessibilityProvider: new SessionsAccessibilityProvider(), + identityProvider: { + getId: (element: SessionListItem) => (element as ISession).resource.toString(), + }, + horizontalScrolling: false, + multipleSelectionSupport: false, + indent: 0, + overrideStyles: this.options.overrideStyles, + renderIndentGuides: RenderIndentGuides.None, + twistieAdditionalCssClass: () => 'force-no-twistie', + } + )); + + this._register(this.tree.onDidOpen(e => { + const element = e.element; + if (!element || !isSessionItem(element)) { + return; + } + this._sessionsListModelService.markRead(element); + const isLeftClick = DOM.isMouseEvent(e.browserEvent) && e.browserEvent.button === 0; + const preserveFocus = isLeftClick ? false : (e.editorOptions.preserveFocus ?? false); + this.options.onSessionOpen(element.resource, preserveFocus, e.sideBySide); + })); + + this._register(sessionRenderer.onDidChangeItemHeight(session => { + if (this.tree.hasElement(session)) { + this.tree.updateElementHeight(session, this._delegate.getHeight(session)); + this._onDidChangeContentHeight.fire(); + } + })); + + this._register(sessionRenderer.onDidApproveSession(approved => this._onDidApproveSession.fire(approved))); + } + + setSessions(sessions: readonly ISession[]): void { + this._sessions = sessions; + this.tree.setChildren(null, sessions.map(session => ({ element: session }))); + } + + /** The total pixel height required to render all current rows without scrolling. */ + getContentHeight(): number { + return this._sessions.reduce((total, session) => total + this._delegate.getHeight(session), 0); + } + + getRowHeight(): number { + return SessionsFlatList.ROW_HEIGHT; + } + + layout(height: number, width: number): void { + this.tree.layout(height, width); + } + + focus(): void { + this.tree.domFocus(); + } +} + +//#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts index edc7f23f4fbdcc..d7293d45757117 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsView.ts @@ -93,7 +93,7 @@ export class SessionsView extends ViewPane { private readonly filterContextKeys = new Map<string, { key: IContextKey<boolean>; getDefault: () => boolean }>(); private currentBodyHeight = 0; private currentBodyWidth = 0; - private didInitializeCustomizationsPaneSize = false; + private didInitializePaneSizes = false; constructor( options: IViewPaneOptions, @@ -272,18 +272,6 @@ export class SessionsView extends ViewPane { } })); - // When the active session changes, select it in the list - this._register(autorun(reader => { - const activeSession = this.sessionsService.activeSession.read(reader); - if (activeSession) { - if (!sessionsControl.reveal(activeSession.resource)) { - sessionsControl.clearFocus(); - } - } else { - sessionsControl.clearFocus(); - } - })); - // Mobile filter chips (phone layout only) — created after sessionsControl // so we can wire it as the filter host. if (filterChipsContainer) { @@ -296,6 +284,18 @@ export class SessionsView extends ViewPane { })); } + // When the active session changes, reveal it in the sessions list. + this._register(autorun(reader => { + const activeSession = this.sessionsService.activeSession.read(reader); + if (activeSession) { + if (!sessionsControl.reveal(activeSession.resource)) { + sessionsControl.clearFocus(); + } + } else { + sessionsControl.clearFocus(); + } + })); + const customizationsSection = DOM.append(this.sidebarSplitViewContainer, $('.agent-sessions-customizations-section')); const customizationsSizeChange = this._register(new Emitter<void>()); @@ -569,8 +569,8 @@ export class SessionsView extends ViewPane { this.sidebarSplitViewContainer.style.height = `${height}px`; } this.sidebarSplitView.layout(height); - if (!this.didInitializeCustomizationsPaneSize) { - this.didInitializeCustomizationsPaneSize = true; + if (!this.didInitializePaneSizes) { + this.didInitializePaneSizes = true; this.sidebarSplitView.resizeView(1, this.getCustomizationsPaneHeight()); } } diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 21ccc7bf938c48..03ceec43c00bed 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -5,7 +5,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; -import { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { KeyChord, KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; import { isMobile, isWeb } from '../../../../../base/common/platform.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; @@ -20,7 +20,7 @@ import { IViewsService } from '../../../../../workbench/services/views/common/vi import { CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID } from '../../../../browser/workbench.js'; import { EditorsVisibleContext, EditorAreaFocusContext, IsSessionsWindowContext } from '../../../../../workbench/common/contextkeys.js'; import { SessionsCategories } from '../../../../common/categories.js'; -import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext } from '../../../../common/contextkeys.js'; +import { SessionSupportsDeleteContext, SessionSupportsRenameContext, IsNewChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext, IsQuickChatSessionContext } from '../../../../common/contextkeys.js'; import { SessionItemToolbarMenuId, SessionItemContextMenuId, SessionSectionToolbarMenuId, SessionGroupToolbarMenuId, SessionSectionTypeContext, IsSessionPinnedContext, SessionsGrouping, SessionsSorting, ISessionSection, ISessionGroupItem } from './sessionsList.js'; import { ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; import { ISessionGroupsService } from '../../../../services/sessions/browser/sessionGroupsService.js'; @@ -29,6 +29,7 @@ import { Menus } from '../../../../browser/menus.js'; import { ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js'; import { ISessionsListModelService } from '../../../../services/sessions/browser/sessionsListModelService.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { ActiveSessionContextKeys } from '../../../changes/common/changes.js'; import { hasActiveSessionFailedCIChecks } from '../../../changes/browser/checksActions.js'; import { ISessionsPartService } from '../../../../services/sessions/browser/sessionsPartService.js'; @@ -472,6 +473,58 @@ registerAction2(class NewSessionForWorkspaceAction extends Action2 { } }); +const NEW_QUICK_CHAT_COMMAND_ID = 'sessionsView.newQuickChat'; + +// Gate on AI features being enabled and the local agent host (which serves +// quick chats) being available. +const QuickChatEnabledContext = ContextKeyExpr.and( + ChatContextKeys.enabled, + AGENT_HOST_ENABLED_CONTEXT_KEY, +); + +registerAction2(class NewQuickChatAction extends Action2 { + constructor() { + super({ + id: NEW_QUICK_CHAT_COMMAND_ID, + title: localize2('newQuickChat', "New Quick Chat"), + icon: Codicon.add, + category: SessionsCategories.Sessions, + f1: true, + precondition: QuickChatEnabledContext, + keybinding: { + weight: KeybindingWeight.SessionsContrib, + primary: KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyN), + when: ContextKeyExpr.and(QuickChatEnabledContext, IsSessionsWindowContext, EditorAreaFocusContext.negate()), + }, + menu: [ + { + // Sole create affordance for quick chats: the "+" on the + // always-visible in-list "Chats" section header. Opens the + // composer; the session type is chosen via its inline picker. + id: SessionSectionToolbarMenuId, + group: 'navigation', + order: 0, + when: ContextKeyExpr.and(QuickChatEnabledContext, ContextKeyExpr.equals(SessionSectionTypeContext.key, 'quickchats')), + }, + ] + }); + } + override run(accessor: ServicesAccessor): void { + // Opens the composer with the default (last-used or first) quick-chat + // session type; the user changes it via the inline composer picker. + const sessionsService = accessor.get(ISessionsService); + const activeQuickChat = sessionsService.openQuickChat(); + + // On mobile web, the sidebar drawer covers the viewport; close it so the + // new quick chat composer becomes visible after creation. + if (isWeb && isMobile) { + accessor.get(ICommandService).executeCommand(CLOSE_MOBILE_SIDEBAR_DRAWER_COMMAND_ID); + } + + accessor.get(ISessionsPartService).focusSession(activeQuickChat); + } +}); + const ConfirmArchiveStorageKey = 'sessions.confirmArchive'; function getArchiveSectionConfirmationMessage(context: ISessionSection): string { @@ -500,7 +553,12 @@ registerAction2(class ArchiveSectionAction extends Action2 { id: SessionSectionToolbarMenuId, group: 'navigation', order: 0, - when: ContextKeyExpr.notEquals(SessionSectionTypeContext.key, 'archived'), + // Not on Done itself, and not on the "Chats" (quick chats) section — + // quick chats have no archive/Done action. + when: ContextKeyExpr.and( + ContextKeyExpr.notEquals(SessionSectionTypeContext.key, 'archived'), + ContextKeyExpr.notEquals(SessionSectionTypeContext.key, 'quickchats'), + ), }] }); } @@ -843,7 +901,7 @@ registerAction2(class DeleteSessionAction extends Action2 { if (!context) { return; } - const sessions = (Array.isArray(context) ? context : [context]).filter(session => session.capabilities.supportsDelete); + const sessions = (Array.isArray(context) ? context : [context]).filter(session => session.capabilities.get().supportsDelete); if (sessions.length === 0) { return; } @@ -1021,6 +1079,7 @@ registerAction2(class MarkSessionAsDoneAction extends Action2 { when: ContextKeyExpr.and( IsSessionsWindowContext, SessionIsArchivedContext.negate(), + IsQuickChatSessionContext.negate(), ActiveSessionContextKeys.HasGitRepository.isEqualTo(true), ActiveSessionContextKeys.HasGitOperationInProgress.negate(), hasActiveSessionFailedCIChecks.negate(), diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts index 743925521eb2ea..b6e504b58317b2 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -19,7 +19,7 @@ import { IAgentHostToolSetEnablementService, IToolEnablementState } from '../../ import { IAICustomizationItemsModel, ItemsModelSection } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import { getChatSessionType } from '../../../../../workbench/contrib/chat/common/model/chatUri.js'; -import { AICustomizationManagementSection, AICustomizationSources } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAICustomizationListItem } from '../../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.js'; import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem, ICustomizationItemConfig } from '../../browser/customizationsToolbar.contribution.js'; @@ -27,6 +27,7 @@ import { IEditorService } from '../../../../../workbench/services/editor/common/ import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; import { Menus } from '../../../../browser/menus.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { IAutomationService } from '../../../../../workbench/contrib/chat/common/automations/automationService.js'; import { URI } from '../../../../../base/common/uri.js'; // Ensure color registrations are loaded @@ -162,7 +163,6 @@ function createMockHarnessService(hiddenSections: readonly string[] = []): ICust label: 'Fixture', icon: ThemeIcon.fromId('vm'), hiddenSections, - getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), }; return new class extends mock<ICustomizationHarnessService>() { override readonly activeSessionResource = observableValue('mockActiveSessionResource', URI.parse(`${descriptor.id}:///session`)); @@ -211,6 +211,9 @@ function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: reg.defineInstance(IAgentHostToolSetEnablementService, new class extends mock<IAgentHostToolSetEnablementService>() { override observe() { return observableValue<IToolEnablementState>('mockToolEnablement', { toolSets: new Map(), tools: new Map() }); } }()); + reg.defineInstance(IAutomationService, new class extends mock<IAutomationService>() { + override readonly automations = observableValue<readonly never[]>('mockAutomations', []); + }()); }, }); diff --git a/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts new file mode 100644 index 00000000000000..e209cc83cacc7e --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/blockedSessionsIndicatorModel.test.ts @@ -0,0 +1,261 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { autorun, constObservable, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../base/common/observable.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 { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, agentSessionApprovalId, IAgentSessionApprovalInfo } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; +import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../../blockedSessions/browser/blockedSessions.js'; +import { BlockedSessionsIndicatorModel, RequiresInputKind } from '../../browser/blockedSessionsIndicatorModel.js'; + +suite('BlockedSessionsIndicatorModel', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createModel(options?: { quality?: string }): { + model: BlockedSessionsIndicatorModel; + blockedModel: TestBlockedSessions; + approvalModel: TestApprovalModel; + sessionsService: TestSessionsService; + } { + const blockedModel = new TestBlockedSessions(); + const approvalModel = new TestApprovalModel(); + const sessionsService = new TestSessionsService(); + const productService = { quality: options?.quality ?? 'insider' } as unknown as IProductService; + const instantiationService = new class extends mock<IInstantiationService>() { }(); + const model = store.add(new BlockedSessionsIndicatorModel( + approvalModel as unknown as AgentSessionApprovalModel, + blockedModel as unknown as BlockedSessions, + sessionsService as unknown as ISessionsService, + instantiationService, + productService, + )); + // Keep the derived live so it recomputes on visibility/dismissal changes. + store.add(autorun(reader => { model.blockedSessions.read(reader); })); + return { model, blockedModel, approvalModel, sessionsService }; + } + + function blockedIds(model: BlockedSessionsIndicatorModel): string[] { + return model.blockedSessions.get().map(entry => entry.session.sessionId); + } + + test('excludes visible sessions from the blocked set', () => { + const { model, blockedModel, sessionsService } = createModel(); + const s1 = new TestSession('s1'); + const s2 = new TestSession('s2'); + blockedModel.setBlocked([needsInput(s1), needsInput(s2)]); + sessionsService.setVisible([s1]); + assert.deepStrictEqual(blockedIds(model), ['s2']); + }); + + test('blinks when a new, not-yet-visible session becomes blocked', () => { + const { model, blockedModel } = createModel(); + blockedModel.setBlocked([needsInput(new TestSession('s1'))]); + assert.strictEqual(model.consumePendingBlink(), true); + }); + + test('does not blink when a new block is already visible', () => { + const { model, blockedModel, sessionsService } = createModel(); + const s1 = new TestSession('s1'); + sessionsService.setVisible([s1]); + blockedModel.setBlocked([needsInput(s1)]); + assert.strictEqual(model.consumePendingBlink(), false); + }); + + test('does not blink when merely navigating between sessions', () => { + const { model, blockedModel, sessionsService } = createModel(); + const s1 = new TestSession('s1'); + blockedModel.setBlocked([needsInput(s1)]); + // The initial block blinks and is consumed. + assert.strictEqual(model.consumePendingBlink(), true); + + // Navigating to s1 hides it from the blocked set — no new block, no blink. + sessionsService.setVisible([s1]); + assert.deepStrictEqual({ blocked: blockedIds(model), blink: model.consumePendingBlink() }, { blocked: [], blink: false }); + + // Navigating away re-surfaces s1 in the blocked set but still must not blink. + sessionsService.setVisible([]); + assert.deepStrictEqual({ blocked: blockedIds(model), blink: model.consumePendingBlink() }, { blocked: ['s1'], blink: false }); + }); + + test('blinks again when an additional, not-yet-visible session becomes blocked', () => { + const { model, blockedModel } = createModel(); + const s1 = new TestSession('s1'); + const s2 = new TestSession('s2'); + blockedModel.setBlocked([needsInput(s1)]); + assert.strictEqual(model.consumePendingBlink(), true); + blockedModel.setBlocked([needsInput(s1), needsInput(s2)]); + assert.strictEqual(model.consumePendingBlink(), true); + }); + + test('does not blink when a queued block becomes visible before the blink plays', () => { + // Simulates a blink queued while the pill is suppressed (e.g. the transient + // "Approved N sessions" state): if the session becomes visible before the pill + // shows, the queued blink must not fire on the later render. + const { model, blockedModel, sessionsService } = createModel(); + const s1 = new TestSession('s1'); + blockedModel.setBlocked([needsInput(s1)]); + // Blink is queued but NOT consumed yet (pill suppressed); the session then + // becomes visible before the pill renders. + sessionsService.setVisible([s1]); + assert.strictEqual(model.consumePendingBlink(), false); + }); + + test('does not blink when a queued block unblocks before the blink plays', () => { + const { model, blockedModel } = createModel(); + const s1 = new TestSession('s1'); + blockedModel.setBlocked([needsInput(s1)]); + // The session stops being blocked before the queued blink is consumed. + blockedModel.setBlocked([]); + assert.strictEqual(model.consumePendingBlink(), false); + }); + + test('consumePendingBlink clears the pending blink', () => { + const { model, blockedModel } = createModel(); + blockedModel.setBlocked([needsInput(new TestSession('s1'))]); + assert.deepStrictEqual([model.consumePendingBlink(), model.consumePendingBlink()], [true, false]); + }); + + test('reports a homogeneous requires-input kind', () => { + const { model, blockedModel, approvalModel } = createModel(); + const s1 = new TestSession('s1'); + const s2 = new TestSession('s2'); + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal)); + approvalModel.setApproval(s2.resource, approval(AgentSessionApprovalKind.Terminal)); + blockedModel.setBlocked([needsInput(s1), needsInput(s2)]); + assert.strictEqual(model.requiresInputKind.get(), RequiresInputKind.TerminalApproval); + }); + + test('reports no kind for a mix of reasons', () => { + const { model, blockedModel, approvalModel } = createModel(); + const s1 = new TestSession('s1'); + const s2 = new TestSession('s2'); + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal)); + approvalModel.setApproval(s2.resource, approval(AgentSessionApprovalKind.Question)); + blockedModel.setBlocked([needsInput(s1), needsInput(s2)]); + assert.strictEqual(model.requiresInputKind.get(), undefined); + }); + + test('classifies failing-CI and unresolved-comments reasons', () => { + const { model, blockedModel } = createModel(); + const ci = new TestSession('ci'); + blockedModel.setBlocked([{ session: ci as unknown as ISession, reason: BlockedSessionReason.FailingCI }]); + const failingCI = model.requiresInputKind.get(); + const comments = new TestSession('comments'); + blockedModel.setBlocked([{ session: comments as unknown as ISession, reason: BlockedSessionReason.UnresolvedComments }]); + const unresolved = model.requiresInputKind.get(); + assert.deepStrictEqual([failingCI, unresolved], [RequiresInputKind.FailingCI, RequiresInputKind.UnresolvedComments]); + }); + + test('builds the requires-input label per kind and count', () => { + const { model } = createModel(); + assert.deepStrictEqual({ + terminalOne: model.getRequiresInputLabel(1, RequiresInputKind.TerminalApproval), + terminalMany: model.getRequiresInputLabel(3, RequiresInputKind.TerminalApproval), + questionOne: model.getRequiresInputLabel(1, RequiresInputKind.Question), + failingCIMany: model.getRequiresInputLabel(2, RequiresInputKind.FailingCI), + commentsOne: model.getRequiresInputLabel(1, RequiresInputKind.UnresolvedComments), + genericOne: model.getRequiresInputLabel(1, undefined), + genericMany: model.getRequiresInputLabel(4, undefined), + }, { + terminalOne: '1 session requires terminal approval', + terminalMany: '3 sessions require terminal approval', + questionOne: '1 session has a question', + failingCIMany: '2 sessions are failing CI', + commentsOne: '1 session has unresolved comments', + genericOne: '1 session requires input', + genericMany: '4 sessions require input', + }); + }); + + test('dismissing an approval hides the session until a distinct approval appears', () => { + const { model, blockedModel, approvalModel } = createModel(); + const s1 = new TestSession('s1'); + const first = approval(AgentSessionApprovalKind.Terminal, new Date(1000)); + approvalModel.setApproval(s1.resource, first); + blockedModel.setBlocked([needsInput(s1)]); + assert.deepStrictEqual(blockedIds(model), ['s1']); + + // The user allows the pending approval — the session drops out immediately. + model.dismissApproval({ session: s1 as unknown as ISession, approvalId: agentSessionApprovalId(first) }); + assert.deepStrictEqual(blockedIds(model), []); + + // A new, distinct approval re-surfaces the session. + approvalModel.setApproval(s1.resource, approval(AgentSessionApprovalKind.Terminal, new Date(2000))); + assert.deepStrictEqual(blockedIds(model), ['s1']); + }); + + test('reports nothing and never blinks when disabled (stable quality)', () => { + const { model, blockedModel } = createModel({ quality: 'stable' }); + blockedModel.setBlocked([needsInput(new TestSession('s1'))]); + assert.deepStrictEqual({ blocked: blockedIds(model), blink: model.consumePendingBlink() }, { blocked: [], blink: false }); + }); +}); + +function needsInput(session: TestSession): IBlockedSession { + return { session: session as unknown as ISession, reason: BlockedSessionReason.NeedsInput }; +} + +function approval(kind: AgentSessionApprovalKind, since: Date = new Date()): IAgentSessionApprovalInfo { + return { kind, label: 'npm run build', languageId: undefined, since, confirm: () => { } }; +} + +class TestSession { + readonly resource: URI; + readonly chats: IObservable<readonly { readonly resource: URI }[]>; + + constructor(readonly sessionId: string) { + this.resource = URI.parse(`test-session:/${sessionId}`); + this.chats = constObservable([{ resource: this.resource }]); + } +} + +class TestBlockedSessions { + readonly blockedSessionsWithReasons = observableValue<readonly IBlockedSession[]>('withReasons', []); + readonly blockedSessions = observableValue<readonly ISession[]>('blocked', []); + + setBlocked(blocked: readonly IBlockedSession[]): void { + transaction(tx => { + this.blockedSessionsWithReasons.set(blocked, tx); + this.blockedSessions.set(blocked.map(entry => entry.session), tx); + }); + } +} + +class TestApprovalModel { + private readonly _approvals = new Map<string, ISettableObservable<IAgentSessionApprovalInfo | undefined>>(); + + getApproval(resource: URI): IObservable<IAgentSessionApprovalInfo | undefined> { + return this._obs(resource.toString()); + } + + setApproval(resource: URI, info: IAgentSessionApprovalInfo | undefined): void { + this._obs(resource.toString()).set(info, undefined); + } + + private _obs(key: string): ISettableObservable<IAgentSessionApprovalInfo | undefined> { + let obs = this._approvals.get(key); + if (!obs) { + obs = observableValue<IAgentSessionApprovalInfo | undefined>(`approval.${key}`, undefined); + this._approvals.set(key, obs); + } + return obs; + } +} + +class TestSessionsService { + readonly visibleSessions = observableValue<readonly (IActiveSession | undefined)[]>('visible', []); + + setVisible(sessions: readonly TestSession[]): void { + this.visibleSessions.set(sessions as unknown as readonly IActiveSession[], undefined); + } +} diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts index cd154f15da86c0..859674477083e6 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsLifecycleTracker.test.ts @@ -48,7 +48,7 @@ function createSession(id: string, opts: ICreateSessionOptions = {}): ISession { lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue<readonly IChat[]>(`chats-${id}`, []), mainChat: constObservable<IChat>(undefined!), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts index 70ef73efe24b00..c9026504dc90c4 100644 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts +++ b/src/vs/sessions/contrib/sessions/test/browser/sessionsList.test.ts @@ -5,11 +5,11 @@ import assert from 'assert'; import { Codicon } from '../../../../../base/common/codicons.js'; -import { observableValue } from '../../../../../base/common/observable.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChat, ISession, SessionStatus } from '../../../../services/sessions/common/session.js'; -import { computeReorderSortChanges, groupByWorkspace, groupSessionsForList, limitSessionsForList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; +import { computeReorderSortChanges, groupByDate, groupByWorkspace, groupSessionsForList, limitSessionsForList, sortSessions, SessionsGrouping, SessionsSorting } from '../../browser/views/sessionsList.js'; function createSession(id: string, opts: { workspaceLabel?: string; @@ -34,6 +34,7 @@ function createSession(id: string, opts: { requiresWorkspaceTrust: false, isVirtualWorkspace: false, } : undefined), + isQuickChat: observableValue(`isQuickChat-${id}`, opts.workspaceLabel === undefined), title: observableValue(`title-${id}`, id), updatedAt: observableValue(`updatedAt-${id}`, updatedAt), status: observableValue(`status-${id}`, SessionStatus.Completed), @@ -48,7 +49,7 @@ function createSession(id: string, opts: { lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue<readonly IChat[]>(`chats-${id}`, []), mainChat: observableValue<IChat>(`mainChat-${id}`, undefined!), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } @@ -131,6 +132,58 @@ suite('Sessions - SessionsList Helpers', () => { }); }); + suite('groupByDate', () => { + + const DAY_MS = 86_400_000; + + // `groupByDate` expects sessions pre-sorted most-recent-first. + function minutesAgo(minutes: number): Date { + return new Date(Date.now() - minutes * 60_000); + } + + function daysAgo(days: number): Date { + return new Date(Date.now() - days * DAY_MS); + } + + test('sessions within the last 7 days go to "Recent", older ones to "Older"', () => { + const sessions = [ + createSession('recent-1', { createdAt: minutesAgo(5) }), + createSession('recent-2', { createdAt: daysAgo(3) }), + createSession('old-1', { createdAt: daysAgo(10) }), + createSession('old-2', { createdAt: daysAgo(30) }), + ]; + + const sections = groupByDate(sessions, SessionsSorting.Created); + + assert.deepStrictEqual(sections.map(s => ({ id: s.id, sessions: s.sessions.map(session => session.sessionId) })), [ + { id: 'recent', sessions: ['recent-1', 'recent-2'] }, + { id: 'older', sessions: ['old-1', 'old-2'] }, + ]); + }); + + test('"Recent" is capped at 10 sessions; the overflow within 7 days falls into "Older"', () => { + const sessions = Array.from({ length: 13 }, (_, i) => + createSession(`s${i}`, { createdAt: minutesAgo(i + 1) })); + + const sections = groupByDate(sessions, SessionsSorting.Created); + + assert.deepStrictEqual(sections.map(s => ({ id: s.id, sessions: s.sessions.map(session => session.sessionId) })), [ + { id: 'recent', sessions: ['s0', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 's9'] }, + { id: 'older', sessions: ['s10', 's11', 's12'] }, + ]); + }); + + test('empty sections are omitted', () => { + const sessions = [ + createSession('only-old', { createdAt: daysAgo(20) }), + ]; + + const sections = groupByDate(sessions, SessionsSorting.Created); + + assert.deepStrictEqual(sections.map(s => s.id), ['older']); + }); + }); + suite('sortSessions', () => { test('sorts by createdAt descending when sorting is Created', () => { @@ -273,6 +326,54 @@ suite('Sessions - SessionsList Helpers', () => { { id: 'pinned', sessions: ['first', 'second'] }, ]); }); + + test('workspace-less sessions form a Chats section directly below Pinned (above groups)', () => { + const pinned = createSession('pinned', { workspaceLabel: 'Alpha', createdAt: new Date('2024-06-03') }); + const quick = createSession('quick', { createdAt: new Date('2024-06-02') }); + const regular = createSession('regular', { workspaceLabel: 'Beta', createdAt: new Date('2024-06-01') }); + const archived = createSession('archived', { workspaceLabel: 'Gamma', isArchived: true, createdAt: new Date('2024-05-01') }); + const sections = groupSessionsForList( + [pinned, quick, regular, archived], + SessionsGrouping.Workspace, + SessionsSorting.Created, + session => session.sessionId === pinned.sessionId, + ); + + assert.deepStrictEqual(sections.map(section => ({ id: section.id, sessions: section.sessions.map(s => s.sessionId) })), [ + { id: 'pinned', sessions: ['pinned'] }, + { id: 'quickchats', sessions: ['quick'] }, + { id: 'workspace:Beta', sessions: ['regular'] }, + { id: 'archived', sessions: ['archived'] }, + ]); + }); + + test('pinned quick chat stays in Pinned, not Quick Chats', () => { + const quick = createSession('quick', { createdAt: new Date('2024-06-01') }); + const sections = groupSessionsForList( + [quick], + SessionsGrouping.Workspace, + SessionsSorting.Created, + () => true, + ); + + assert.deepStrictEqual(sections.map(section => section.id), ['pinned']); + }); + + test('Chats section sits directly below Pinned when grouping by date', () => { + const pinned = createSession('pinned', { createdAt: new Date('2024-06-03') }); + const quick = createSession('quick', { createdAt: new Date('2024-06-02') }); + const regular = createSession('regular', { workspaceLabel: 'Beta', createdAt: new Date('2024-06-01') }); + const sections = groupSessionsForList( + [pinned, quick, regular], + SessionsGrouping.Date, + SessionsSorting.Created, + session => session.sessionId === pinned.sessionId, + ); + + assert.strictEqual(sections[0].id, 'pinned'); + assert.strictEqual(sections[1].id, 'quickchats'); + assert.deepStrictEqual(sections[1].sessions.map(s => s.sessionId), ['quick']); + }); }); suite('computeReorderSortChanges', () => { diff --git a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts index 90bf4a97dfe9c4..29b136b71a0423 100644 --- a/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts +++ b/src/vs/sessions/contrib/terminal/browser/sessionsTerminalContribution.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../base/common/codicons.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { autorun, derived, IReader } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { ServicesAccessor } from '../../../../editor/browser/editorExtensions.js'; @@ -16,7 +17,7 @@ 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 { ICommandDetectionCapability, 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, LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../common/agentHostSessionsProvider.js'; @@ -25,6 +26,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; import { ISession } from '../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; +import { ISessionTerminalCounts, ISessionTerminalsProvider, ISessionTerminalsService } from '../../../services/sessions/browser/sessionTerminalsService.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'; @@ -49,7 +51,7 @@ interface ISessionTerminalInfo { * workspace-backed agent sessions. Returns `undefined` for sessions without a * workspace (e.g. Cloud), or when no path is available. */ -function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader): ISessionTerminalInfo | undefined { +export function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader): ISessionTerminalInfo | undefined { if (!session) { return undefined; } @@ -77,7 +79,7 @@ function getSessionTerminalInfo(session: ISession | undefined, reader?: IReader) * - Terminals for archived/removed sessions are hidden/closed using their tracked * session id association while keeping the active terminal protected. */ -export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution { +export class SessionsTerminalContribution extends Disposable implements IWorkbenchContribution, ISessionTerminalsProvider { static readonly ID = 'workbench.contrib.sessionsTerminal'; @@ -85,6 +87,29 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben private _activeSessionId: string | undefined; private readonly _sessionTerminals = new Map<string, Set<number>>(); + /** + * Fires when the per-session terminal counts may have changed, so + * {@link ISessionTerminalsService} consumers re-read them. + */ + private readonly _onDidChangeTerminals = this._register(new Emitter<void>()); + readonly onDidChangeTerminals: Event<void> = this._onDidChangeTerminals.event; + + /** + * Per-terminal listeners (child-process busy/idle state, executed text and + * command detection) that affect the session terminal counts. Keyed by + * instance id and disposed when the terminal goes away. + */ + private readonly _terminalListeners = this._register(new DisposableMap<number>()); + + /** + * Instance ids of terminals that have had at least one command sent in them. + * "Command sent" is a sticky, historical fact (a completed command leaves no + * live signal), so it is recorded here once observed — via executed text, + * command detection, an existing command history, or a child process having + * started — rather than recomputed. Drives the "{n} terminals" pill count. + */ + private readonly _terminalsWithCommands = new Set<number>(); + /** * Session ids already processed as archived. The archive cleanup runs only * on the not-archived → archived transition: the provider keeps archived @@ -105,9 +130,23 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @IViewsService viewsService: IViewsService, @IContextKeyService contextKeyService: IContextKeyService, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, ) { super(); + // Expose this contribution as the terminals provider so the session + // header meta row can show a "{n} terminals" pill without depending on the + // terminal contribution directly. + this._register(sessionTerminalsService.registerProvider(this)); + + // Observe existing and future terminals so the session terminal counts + // update as commands are sent and as processes start/stop. + for (const instance of this._terminalService.instances) { + if (!instance.shellLaunchConfig.hideFromUser) { + this._trackTerminal(instance); + } + } + // Seed with sessions that are already archived (e.g. restored archived // from a previous window) so they are not treated as newly archived on // their first change event. @@ -192,12 +231,15 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._logService.trace(`[SessionsTerminal] Transferred ${terminalIds.size} terminal(s) from session ${from.sessionId} to ${to.sessionId}`); } this._sessionTerminals.delete(from.sessionId); + this._onDidChangeTerminals.fire(); })); // Clean up tracked terminal ids when terminals are externally disposed // (e.g. user closes a terminal tab) so the map doesn't hold stale entries. this._register(this._terminalService.onDidDisposeInstance(instance => { this._removeTerminalFromTrackedSessions(instance.instanceId); + this._terminalListeners.deleteAndDispose(instance.instanceId); + this._terminalsWithCommands.delete(instance.instanceId); })); // Hide restored terminals from a previous window session that don't @@ -208,6 +250,7 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben if (instance.shellLaunchConfig.hideFromUser) { return; } + this._trackTerminal(instance); if (instance.shellLaunchConfig.attachPersistentProcess && this._activeKey) { instance.getInitialCwd().then(cwd => { if (cwd.toLowerCase() !== this._activeKey) { @@ -219,6 +262,14 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben this._logService.trace(`[SessionsTerminal] Hid restored terminal ${availableInstance.instanceId} (cwd: ${cwd})`); } }); + } else if (this._activeSessionId) { + // A freshly created (non-restored) terminal in the agents window + // belongs to the active session — its default cwd is kept at the + // active session's working directory. Associate it so the session + // header terminal count reflects it (and so it is cleaned up with + // the session). Restored terminals are excluded above: they are + // matched to their session by cwd instead. + this._trackTerminalsForSession(this._activeSessionId, [instance]); } })); @@ -418,6 +469,104 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben for (const instance of instances) { terminalIds.add(instance.instanceId); } + this._onDidChangeTerminals.fire(); + } + + /** + * Observes the given terminal so the session terminal counts stay current: + * - {@link ITerminalInstance.onDidChangeHasChildProcesses} flips a terminal + * between active (running) and idle. + * - executed text, command detection and a started child process each mark + * the terminal as having had a command sent (see {@link _markTerminalHasCommand}). + * + * Any terminal that already has command history, an executing command or a + * running process when first observed (e.g. one restored from a previous + * window) is seeded as having had a command. + */ + private _trackTerminal(instance: ITerminalInstance): void { + const store = new DisposableStore(); + const id = instance.instanceId; + + // A terminal becoming busy/idle changes the active count; a process + // starting also means a command is running, so it has had a command sent. + store.add(instance.onDidChangeHasChildProcesses(() => { + if (instance.hasChildProcesses) { + this._markTerminalHasCommand(id); + } + this._onDidChangeTerminals.fire(); + })); + + // Text executed programmatically (Run actions, agent task runner) counts + // as a command sent. + store.add(instance.onDidExecuteText(() => this._markTerminalHasCommand(id))); + + // Manual commands (typed + Enter) are surfaced via command detection when + // shell integration is available. The capability may be added after the + // instance is created, so also subscribe once it appears. + const trackCommandDetection = (capability: ICommandDetectionCapability) => { + if (capability.commands.length > 0 || capability.executingCommand) { + this._markTerminalHasCommand(id); + } + store.add(capability.onCommandStarted(() => this._markTerminalHasCommand(id))); + }; + const commandDetection = instance.capabilities.get(TerminalCapability.CommandDetection); + if (commandDetection) { + trackCommandDetection(commandDetection); + } + store.add(instance.capabilities.onDidAddCommandDetectionCapability(trackCommandDetection)); + + // Seed from current state for terminals that already ran something. + if (instance.hasChildProcesses) { + this._markTerminalHasCommand(id); + } + + this._terminalListeners.set(id, store); + } + + /** + * Records that the given terminal has had at least one command sent in it and + * notifies consumers. No-op if already recorded. + */ + private _markTerminalHasCommand(instanceId: number): void { + if (this._terminalsWithCommands.has(instanceId)) { + return; + } + this._terminalsWithCommands.add(instanceId); + this._onDidChangeTerminals.fire(); + } + + /** + * The terminal counts for the given session: the number of tracked terminals + * that have had a command sent in them ({@link ISessionTerminalCounts.total}), + * and of those, the ones currently running something ({@link ISessionTerminalCounts.active}). + * + * This is read from reactive contexts (a `derived` in the header pill and an + * `autorun` in `SessionView`), so it is intentionally non-mutating: it skips + * stale/hidden tracked ids without pruning the tracking map. Cleanup of stale + * entries happens on the actual lifecycle events instead (e.g. + * {@link _removeTerminalFromTrackedSessions} on `onDidDisposeInstance`). + */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts { + const terminalIds = this._sessionTerminals.get(sessionId); + if (!terminalIds) { + return { total: 0, active: 0 }; + } + let total = 0; + let active = 0; + for (const instanceId of terminalIds) { + if (!this._terminalsWithCommands.has(instanceId)) { + continue; + } + const instance = this._terminalService.getInstanceFromId(instanceId); + if (!instance || instance.isDisposed || instance.shellLaunchConfig.hideFromUser) { + continue; + } + total++; + if (instance.hasChildProcesses) { + active++; + } + } + return { total, active }; } private _getTrackedTerminalsForSession(sessionId: string): ITerminalInstance[] { @@ -461,12 +610,18 @@ export class SessionsTerminalContribution extends Disposable implements IWorkben } private _removeTerminalFromTrackedSessions(instanceId: number): void { + let changed = false; for (const [sessionId, terminalIds] of this._sessionTerminals) { - terminalIds.delete(instanceId); + if (terminalIds.delete(instanceId)) { + changed = true; + } if (terminalIds.size === 0) { this._sessionTerminals.delete(sessionId); } } + if (changed) { + this._onDidChangeTerminals.fire(); + } } private _getAvailableTerminal(instance: ITerminalInstance, action: string): ITerminalInstance | undefined { diff --git a/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts b/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts new file mode 100644 index 00000000000000..a8ae4860e9fc4f --- /dev/null +++ b/src/vs/sessions/contrib/terminal/browser/terminalMetaActions.ts @@ -0,0 +1,175 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { structuralEquals } from '../../../../base/common/equals.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { autorun, derivedOpts, IObservable, observableSignalFromEvent } from '../../../../base/common/observable.js'; +import { localize, localize2 } from '../../../../nls.js'; +import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { Action2, MenuItemAction, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution, getWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; +import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; +import { IPathService } from '../../../../workbench/services/path/common/pathService.js'; +import { Menus } from '../../../browser/menus.js'; +import { SessionHeaderMetaActionViewItem } from '../../../browser/parts/sessionHeaderMetaActionViewItem.js'; +import { SessionHasTerminalsContext } from '../../../common/contextkeys.js'; +import { ISessionContext } from '../../../services/sessions/browser/sessionContext.js'; +import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; +import { EMPTY_SESSION_TERMINAL_COUNTS, ISessionTerminalCounts, ISessionTerminalsService } from '../../../services/sessions/browser/sessionTerminalsService.js'; +import { IActiveSession } from '../../../services/sessions/common/sessionsManagement.js'; +import { getSessionTerminalInfo, SessionsTerminalContribution } from './sessionsTerminalContribution.js'; + +// --- Open Terminals action + +class OpenSessionTerminalsAction extends Action2 { + static readonly ID = 'workbench.agentSessions.action.openTerminals'; + + constructor() { + super({ + id: OpenSessionTerminalsAction.ID, + title: localize2('agentSessions.terminals', 'Terminals'), + icon: Codicon.terminal, + f1: false, + // Active-terminals pill shown in the session header meta row + // (vs/sessions/browser/parts/sessionHeader.ts). Rendered with a + // custom action view item that shows the live terminal count. + menu: { + id: Menus.SessionHeaderMeta, + group: 'navigation', + order: 2, + when: SessionHasTerminalsContext + }, + }); + } + + override async run(accessor: ServicesAccessor, session?: IActiveSession): Promise<void> { + const sessionsService = accessor.get(ISessionsService); + const viewsService = accessor.get(IViewsService); + const pathService = accessor.get(IPathService); + + // The clicked session is forwarded as the argument by the session header, + // which has already promoted it to be the active session. Fall back to the + // active session when invoked without an explicit argument. + const targetSession = session ?? sessionsService.activeSession.get(); + + // Ensure the session's terminal is present and shown (the active-session + // change in the terminal contribution surfaces background terminals), then + // reveal the terminal view. + const contribution = getWorkbenchContribution<SessionsTerminalContribution>(SessionsTerminalContribution.ID); + const info = getSessionTerminalInfo(targetSession); + const cwd = info?.cwd ?? await pathService.userHome(); + await contribution.ensureTerminal(cwd, true, targetSession); + await viewsService.openView(TERMINAL_VIEW_ID); + } +} +registerAction2(OpenSessionTerminalsAction); + +// --- Open Terminals action view item (session header active-terminal count) + +/** + * Renders the {@link OpenSessionTerminalsAction} menu item contributed into + * {@link Menus.SessionHeaderMeta} (the session header meta row) as a + * `<terminal-icon> {n} terminals` pill. It extends the generic + * {@link SessionHeaderMetaActionViewItem} (so the icon and label render consistently + * with the other meta actions). The label shows the number of the session's terminals + * that have had a command sent in them; the hover additionally reports how many of + * those are currently running something (active). Activating the item reveals the + * terminal view. + * + * The counts are read for the per-surface session published via {@link ISessionContext} + * so the correct session's terminals are reflected even when several session views are + * visible at once. + */ +export class OpenSessionTerminalsActionViewItem extends SessionHeaderMetaActionViewItem { + + private readonly _countsObs: IObservable<ISessionTerminalCounts>; + + constructor( + action: MenuItemAction, + options: IActionViewItemOptions, + @ISessionContext sessionContext: ISessionContext, + @ISessionTerminalsService sessionTerminalsService: ISessionTerminalsService, + ) { + super(undefined, action, options); + + const changeSignal = observableSignalFromEvent(this, sessionTerminalsService.onDidChangeTerminals); + this._countsObs = derivedOpts({ owner: this, equalsFn: structuralEquals }, reader => { + changeSignal.read(reader); + const sessionId = sessionContext.session.read(reader)?.sessionId; + return sessionId ? sessionTerminalsService.getTerminalCounts(sessionId) : EMPTY_SESSION_TERMINAL_COUNTS; + }); + + this._register(autorun(reader => { + this._countsObs.read(reader); + this.updateLabel(); + this.updateTooltip(); + this.updateAriaLabel(); + })); + } + + protected override getLabelText(): string { + const { total } = this._countsObs.get(); + return total === 1 + ? localize('agentSessions.terminals.one', "{0} terminal", total) + : localize('agentSessions.terminals.many', "{0} terminals", total); + } + + protected override getTooltip(): string { + const { active } = this._countsObs.get(); + return active === 1 + ? localize('agentSessions.terminals.tooltip.one', "{0} active terminal", active) + : localize('agentSessions.terminals.tooltip.many', "{0} active terminals", active); + } + + protected override getAriaLabel(): string { + const { total, active } = this._countsObs.get(); + const totalLabel = total === 1 + ? localize('agentSessions.terminals.one', "{0} terminal", total) + : localize('agentSessions.terminals.many', "{0} terminals", total); + const activeLabel = active === 1 + ? localize('agentSessions.terminals.tooltip.one', "{0} active terminal", active) + : localize('agentSessions.terminals.tooltip.many', "{0} active terminals", active); + // e.g. "Open Terminals: 3 terminals, 1 active terminal" + return localize('agentSessions.terminals.ariaLabel', "Open Terminals: {0}, {1}", totalLabel, activeLabel); + } +} + +/** + * Registers the {@link OpenSessionTerminalsActionViewItem} for the active-terminals + * action in the session header meta toolbar. Registering it here (rather than in the + * core session header) keeps the rendering of the terminal-owned action co-located + * with the action itself. + */ +class OpenSessionTerminalsActionViewItemContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.openSessionTerminalsActionViewItem'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + ) { + super(); + + // The action view item service only notifies toolbars of a factory via + // the event passed to register(), not on registration itself. A session + // header restored with existing terminals may create its meta toolbar + // before this contribution runs, so announce the factory once right + // after registering to make those toolbars re-render and pick it up. + const onDidRegister = this._register(new Emitter<void>()); + this._register(actionViewItemService.register(Menus.SessionHeaderMeta, OpenSessionTerminalsAction.ID, (action, options, instantiationService) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + return instantiationService.createInstance(OpenSessionTerminalsActionViewItem, action, options); + }, onDidRegister.event)); + onDidRegister.fire(); + } +} + +registerWorkbenchContribution2(OpenSessionTerminalsActionViewItemContribution.ID, OpenSessionTerminalsActionViewItemContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts index 7b73b4ffaf093e..9c1b735a0597f6 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/agentHostSessionTaskRunner.test.ts @@ -66,7 +66,7 @@ function makeSession(opts: { providerId: string; cwd?: URI }): ISession { description: observableValue('description', undefined), chats: observableValue('chats', [chat]), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } 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 5c70b36d8f6047..4ea50b7547b384 100644 --- a/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts +++ b/src/vs/sessions/contrib/terminal/test/browser/sessionsTerminalContribution.test.ts @@ -11,6 +11,7 @@ import { constObservable, observableValue } from '../../../../../base/common/obs 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 { ISessionTerminalsService } from '../../../../services/sessions/browser/sessionTerminalsService.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'; @@ -19,7 +20,7 @@ import { ITerminalInstance, ITerminalService } from '../../../../../workbench/co import { ITerminalCapabilityStore, ICommandDetectionCapability, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { SessionsTerminalContribution } from '../../browser/sessionsTerminalContribution.js'; import { TestPathService } from '../../../../../workbench/test/browser/workbenchTestServices.js'; @@ -49,6 +50,8 @@ type TestTerminalInstance = ITerminalInstance & { _testCommandHistory: { timestamp: number }[]; _testSetDisposed(disposed: boolean): void; _testSetShellLaunchConfig(shellLaunchConfig: ITerminalInstance['shellLaunchConfig']): void; + _testSetHasChildProcesses(hasChildProcesses: boolean): void; + _testFireExecuteText(): void; }; type TestActiveSession = IActiveSession & { @@ -81,6 +84,7 @@ function makeAgentSession(opts: { mode: observableValue('test.mode', undefined), isArchived: observableValue('test.isArchived', opts.isArchived ?? false), isRead: observableValue('test.isRead', true), + interactivity: observableValue('test.interactivity', ChatInteractivity.Full), checkpoints: observableValue('test.checkpoints', undefined), lastTurnEnd: observableValue('test.lastTurnEnd', undefined), description: observableValue('test.description', undefined), @@ -117,11 +121,14 @@ function makeAgentSession(opts: { chats: observableValue('test.chats', [chat]), activeChat: observableValue('test.activeChat', chat), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), isCreated: observableValue('test.isCreated', true), sticky: observableValue('test.sticky', false), openChats: observableValue('test.openChats', [chat]), closedChats: constObservable([]), + lastClosedChat: undefined, + visibleChatTabs: constObservable([chat]), + shouldShowChatTabs: constObservable(false), } satisfies TestActiveSession; return session; } @@ -145,6 +152,7 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT mode: observableValue('test.mode', undefined), isArchived: observableValue('test.isArchived', false), isRead: observableValue('test.isRead', true), + interactivity: observableValue('test.interactivity', ChatInteractivity.Full), checkpoints: observableValue('test.checkpoints', undefined), lastTurnEnd: observableValue('test.lastTurnEnd', undefined), description: observableValue('test.description', undefined), @@ -178,27 +186,43 @@ function makeNonAgentSession(opts: { repository?: URI; worktree?: URI; providerT description: chat.description, chats: observableValue('test.chats', [chat]), mainChat: constObservable(chat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), } satisfies ISession; return session; } +/** + * Store that owns the emitters created by {@link makeTerminalInstance}. Assigned + * to the suite store in `setup` so the mock terminals' emitters are disposed on + * teardown without threading the store through every call site. + */ +let terminalMockStore: DisposableStore; + function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { const commandHistory: { timestamp: number }[] = []; let isDisposed = false; + let hasChildProcesses = false; let shellLaunchConfig: ITerminalInstance['shellLaunchConfig'] = {} as ITerminalInstance['shellLaunchConfig']; + const onDidChangeHasChildProcesses = terminalMockStore.add(new Emitter<boolean>()); + const onDidExecuteText = terminalMockStore.add(new Emitter<void>()); + const onCommandStarted = terminalMockStore.add(new Emitter<unknown>()); + const onDidAddCommandDetectionCapability = terminalMockStore.add(new Emitter<ICommandDetectionCapability>()); const capabilities = { get(cap: TerminalCapability) { if (cap === TerminalCapability.CommandDetection && commandHistory.length > 0) { - return { commands: commandHistory } as unknown as ICommandDetectionCapability; + return { commands: commandHistory, onCommandStarted: onCommandStarted.event } as unknown as ICommandDetectionCapability; } return undefined; - } - } as ITerminalCapabilityStore; + }, + onDidAddCommandDetectionCapability: onDidAddCommandDetectionCapability.event, + } as unknown as ITerminalCapabilityStore; return { instanceId: id, get isDisposed() { return isDisposed; }, + get hasChildProcesses() { return hasChildProcesses; }, + onDidChangeHasChildProcesses: onDidChangeHasChildProcesses.event, + onDidExecuteText: onDidExecuteText.event, get shellLaunchConfig() { return shellLaunchConfig; }, getInitialCwd: () => Promise.resolve(cwd), capabilities, @@ -209,6 +233,13 @@ function makeTerminalInstance(id: number, cwd: string): TestTerminalInstance { _testSetShellLaunchConfig(value: ITerminalInstance['shellLaunchConfig']) { shellLaunchConfig = value; }, + _testSetHasChildProcesses(value: boolean) { + hasChildProcesses = value; + onDidChangeHasChildProcesses.fire(value); + }, + _testFireExecuteText() { + onDidExecuteText.fire(); + }, } as unknown as TestTerminalInstance; } @@ -256,6 +287,7 @@ suite('SessionsTerminalContribution', () => { defaultCwdCalls = []; logService = new TestLogService(); allSessions = []; + terminalMockStore = store; instantiationService = store.add(new TestInstantiationService()); @@ -298,6 +330,10 @@ suite('SessionsTerminalContribution', () => { if (disposeOnCreatePaths.has(cwdStr)) { instance._testSetDisposed(true); terminalInstances.delete(id); + } else { + // The real terminal service fires onDidCreateInstance for new + // terminals; mirror that so the contribution observes them. + onDidCreateInstance.fire(instance); } return instance; } @@ -349,6 +385,10 @@ suite('SessionsTerminalContribution', () => { instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + instantiationService.stub(ISessionTerminalsService, new class extends mock<ISessionTerminalsService>() { + override registerProvider() { return Disposable.None; } + }); + instantiationService.stub(IViewsService, new class extends mock<IViewsService>() { override isViewVisible(): boolean { return false; } override onDidChangeViewVisibility = store.add(new Emitter<{ id: string; visible: boolean }>()).event; @@ -1239,6 +1279,101 @@ suite('SessionsTerminalContribution', () => { // rather than left in the background assert.ok(showBackgroundCalls.includes(restoredTerminal.instanceId), 'untracked restored terminal at matching cwd should be shown'); }); + + // --- Terminal counts (session header meta pill) --- + + test('getTerminalCounts excludes empty terminals and counts running ones as active', async () => { + const worktreeUri = URI.file('/worktree'); + const session = makeAgentSession({ sessionId: 'test:count', worktree: worktreeUri, providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + // A terminal was created and tracked, but no command has been sent in it. + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 0, active: 0 }); + + // Sending a command makes it count toward the total, but it is only active + // while something is running in it. + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testFireExecuteText(); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 0 }); + + // A long-running command (e.g. a watch task) keeps it active until it ends. + instance._testSetHasChildProcesses(true); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 1 }); + + instance._testSetHasChildProcesses(false); + assert.deepStrictEqual(contribution.getTerminalCounts('test:count'), { total: 1, active: 0 }); + }); + + test('a running child process alone counts the terminal as having a command', async () => { + const session = makeAgentSession({ sessionId: 'test:child', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testSetHasChildProcesses(true); + assert.deepStrictEqual(contribution.getTerminalCounts('test:child'), { total: 1, active: 1 }); + }); + + test('getTerminalCounts is scoped per session and aggregates multiple terminals', async () => { + const sessionA = makeAgentSession({ sessionId: 'test:a', worktree: URI.file('/a'), providerType: AgentSessionProviders.Background }); + + // Session A is active and gets its initial terminal with a command sent. + activeSessionObs.set(sessionA, undefined); + await tick(); + const a1 = [...terminalInstances.values()][0] as TestTerminalInstance; + a1._testFireExecuteText(); + + // The user creates a second terminal in the agents window while session A + // is active; it is associated with the active session. A command is sent + // in it and it keeps running. + const a2 = makeTerminalInstance(nextInstanceId++, '/a'); + terminalInstances.set(a2.instanceId, a2); + onDidCreateInstance.fire(a2); + a2._testFireExecuteText(); + a2._testSetHasChildProcesses(true); + + assert.deepStrictEqual(contribution.getTerminalCounts('test:a'), { total: 2, active: 1 }); + // A session with no tracked terminals reports zero. + assert.deepStrictEqual(contribution.getTerminalCounts('test:b'), { total: 0, active: 0 }); + }); + + test('creating a new terminal and running a command updates the counts', async () => { + const session = makeAgentSession({ sessionId: 'test:new', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + // First terminal: a finished command (counts toward total, not active). + const first = [...terminalInstances.values()][0] as TestTerminalInstance; + first._testFireExecuteText(); + assert.deepStrictEqual(contribution.getTerminalCounts('test:new'), { total: 1, active: 0 }); + + // A long-running command keeps the first terminal active. + first._testSetHasChildProcesses(true); + + // Creating a new terminal and running a command in it bumps both counts. + const second = makeTerminalInstance(nextInstanceId++, '/worktree'); + terminalInstances.set(second.instanceId, second); + onDidCreateInstance.fire(second); + second._testFireExecuteText(); + second._testSetHasChildProcesses(true); + + assert.deepStrictEqual(contribution.getTerminalCounts('test:new'), { total: 2, active: 2 }); + }); + + test('fires onDidChangeTerminals when a command is sent in a session terminal', async () => { + const session = makeAgentSession({ sessionId: 'test:fire', worktree: URI.file('/worktree'), providerType: AgentSessionProviders.Background }); + activeSessionObs.set(session, undefined); + await tick(); + + let fired = 0; + store.add(contribution.onDidChangeTerminals(() => fired++)); + + const instance = [...terminalInstances.values()][0] as TestTerminalInstance; + instance._testFireExecuteText(); + + assert.ok(fired > 0, 'expected onDidChangeTerminals to fire when a command is sent'); + }); }); function tick(): Promise<void> { diff --git a/src/vs/sessions/electron-browser/actions/vscodeActions.ts b/src/vs/sessions/electron-browser/actions/vscodeActions.ts index dfa87e4f57e5a7..a749aac9d4f94b 100644 --- a/src/vs/sessions/electron-browser/actions/vscodeActions.ts +++ b/src/vs/sessions/electron-browser/actions/vscodeActions.ts @@ -6,6 +6,8 @@ import { Codicon } from '../../../base/common/codicons.js'; import { getWindowId } from '../../../base/browser/dom.js'; import { mainWindow } from '../../../base/browser/window.js'; +import { Schemas } from '../../../base/common/network.js'; +import { URI } from '../../../base/common/uri.js'; import { ServicesAccessor } from '../../../editor/browser/editorExtensions.js'; import { localize2 } from '../../../nls.js'; import { Action2 } from '../../../platform/actions/common/actions.js'; @@ -14,8 +16,6 @@ import { IRemoteAgentHostService } from '../../../platform/agentHost/common/remo import { KeyCode, KeyMod } from '../../../base/common/keyCodes.js'; import { ContextKeyExpr } from '../../../platform/contextkey/common/contextkey.js'; import { KeybindingWeight } from '../../../platform/keybinding/common/keybindingsRegistry.js'; -import { IOpenerService } from '../../../platform/opener/common/opener.js'; -import { IProductService } from '../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry.js'; import { IsAuxiliaryWindowContext } from '../../../workbench/common/contextkeys.js'; import { IsPhoneLayoutContext, SessionsWelcomeVisibleContext } from '../../common/contextkeys.js'; @@ -28,7 +28,7 @@ import { OpenInVSCodeTitleBarWidget } from '../../browser/widget/openInVSCodeWid import { IActionViewItemService } from '../../../platform/actions/browser/actionViewItemService.js'; import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { Disposable } from '../../../base/common/lifecycle.js'; -import { getOpenInVSCodeUri, getVSCodeProtocolScheme, resolveRemoteAuthority } from '../../browser/openInVSCodeUtils.js'; +import { resolveRemoteAuthority } from '../../browser/openInVSCodeUtils.js'; import { INativeHostService } from '../../../platform/native/common/native.js'; export class OpenSessionInVSCodeAction extends Action2 { @@ -53,36 +53,44 @@ export class OpenSessionInVSCodeAction extends Action2 { const telemetryService = accessor.get(ITelemetryService); logSessionsInteraction(telemetryService, 'openInVSCode'); - const openerService = accessor.get(IOpenerService); - const productService = accessor.get(IProductService); - const nativeHostService = accessor.get(INativeHostService); const sessionsService = accessor.get(ISessionsService); const sessionsProvidersService = accessor.get(ISessionsProvidersService); const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const scheme = getVSCodeProtocolScheme(productService); + const nativeHostService = accessor.get(INativeHostService); + + const folderUri = this.getFolderUriToOpen(sessionsService, sessionsProvidersService, remoteAgentHostService); + if (!folderUri) { + return nativeHostService.openWindow(); + } + // Hand off the active session so the opened window restores it too, not just the folder. + const chatSessionToOpen = sessionsService.activeSession.get()?.resource; + return nativeHostService.openWindow([{ folderUri }], { forceNewWindow: true, chatSessionToOpen }); + } + + private getFolderUriToOpen(sessionsService: ISessionsService, sessionsProvidersService: ISessionsProvidersService, remoteAgentHostService: IRemoteAgentHostService): URI | undefined { const activeSession = sessionsService.activeSession.get(); if (!activeSession) { - await openerService.open(getOpenInVSCodeUri(scheme, undefined, undefined, undefined), { openExternal: true }); - return; + return undefined; } const workspace = activeSession.workspace.get(); - const folder = workspace?.folders[0]; - const rawFolderUri = folder?.workingDirectory; + const rawFolderUri = workspace?.folders[0]?.workingDirectory; if (!rawFolderUri) { - await openerService.open(getOpenInVSCodeUri(scheme, undefined, undefined, undefined), { openExternal: true }); - return; + return undefined; } - if (workspace?.isVirtualWorkspace) { - await nativeHostService.openWindow([{ folderUri: rawFolderUri }], { forceNewWindow: true }); - return; + if (rawFolderUri.scheme !== AGENT_HOST_SCHEME) { + return rawFolderUri; } - const folderUri = rawFolderUri.scheme === AGENT_HOST_SCHEME ? fromAgentHostUri(rawFolderUri) : rawFolderUri; const remoteAuthority = resolveRemoteAuthority(activeSession.providerId, sessionsProvidersService, remoteAgentHostService); - await openerService.open(getOpenInVSCodeUri(scheme, folderUri, remoteAuthority, activeSession.resource), { openExternal: true }); + if (!remoteAuthority) { + return rawFolderUri; + } + + const agentHostUri = fromAgentHostUri(rawFolderUri); + return agentHostUri.with({ authority: remoteAuthority, scheme: Schemas.vscodeRemote }); } } diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 8d92aace06ad5e..5d7f0e8eef2170 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -66,9 +66,11 @@ import { DefaultAccountService } from '../../workbench/services/accounts/browser import { AccountPolicyService, IAccountPolicyGateService } from '../../workbench/services/policies/common/accountPolicyService.js'; import { MultiplexPolicyService } from '../../platform/policy/common/multiplexPolicyService.js'; import { Workbench as AgenticWorkbench } from '../browser/workbench.js'; +import { createSessionsWorkbench } from '../browser/workbenchFactory.js'; import { NativeMenubarControl } from '../../workbench/electron-browser/parts/titlebar/menubarControl.js'; import { IWorkspaceEditingService } from '../../workbench/services/workspaces/common/workspaceEditing.js'; import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; +import { ConfigurationCache } from '../../workbench/services/configuration/common/configurationCache.js'; import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; import { getWorkspaceIdentifier } from '../../platform/workspaces/common/workspaceIdentifier.js'; @@ -126,7 +128,7 @@ export class SessionsMain extends Disposable { this.applyWindowZoomLevel(services.configurationService); // Create Agentic Workbench - const workbench = new AgenticWorkbench(mainWindow.document.body, { + const workbench = createSessionsWorkbench(mainWindow.document.body, { extraClasses: this.getExtraClasses(), }, services.serviceCollection, services.logService); @@ -308,7 +310,7 @@ export class SessionsMain extends Disposable { serviceCollection.set(IWorkspaceEditingService, workspaceContextService); const [configurationService, storageService] = await Promise.all([ - this.createConfigurationService(workspaceContextService, userDataProfileService, uriIdentityService, fileService, logService, policyService).then(configurationService => { + this.createConfigurationService(workspaceContextService, userDataProfileService, uriIdentityService, fileService, logService, policyService, environmentService).then(configurationService => { // Configuration serviceCollection.set(IWorkbenchConfigurationService, configurationService); @@ -360,9 +362,11 @@ export class SessionsMain extends Disposable { uriIdentityService: IUriIdentityService, fileService: FileService, logService: ILogService, - policyService: IPolicyService + policyService: IPolicyService, + environmentService: INativeWorkbenchEnvironmentService, ): Promise<ConfigurationService> { - const configurationService = new ConfigurationService(userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, logService); + const configurationCache = new ConfigurationCache([Schemas.file, Schemas.vscodeUserData], environmentService, fileService); + const configurationService = new ConfigurationService(userDataProfileService, workspaceContextService, uriIdentityService, fileService, policyService, logService, configurationCache, environmentService); try { await configurationService.initialize(); } catch (error) { diff --git a/src/vs/sessions/electron-browser/sessions.ts b/src/vs/sessions/electron-browser/sessions.ts index 1fc583b712b5a7..ff1ebe4a7fc73e 100644 --- a/src/vs/sessions/electron-browser/sessions.ts +++ b/src/vs/sessions/electron-browser/sessions.ts @@ -112,6 +112,9 @@ const baseUrl = new URL(`${fileUriFromPath(configuration.appRoot, { isWindows: safeProcess.platform === 'win32', scheme: 'vscode-file', fallbackAuthority: 'vscode-app' })}/out/`); globalThis._VSCODE_FILE_ROOT = baseUrl.toString(); + // Set product configuration as global (used e.g. to select the ASAR path in `amdX`) + globalThis._VSCODE_PRODUCT_JSON = { ...configuration.product }; + // Dev only: CSS import map tricks setupCSSImportMaps<T>(configuration, baseUrl); diff --git a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts index 3b129d0b75e393..5aace548851de3 100644 --- a/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts +++ b/src/vs/sessions/services/agentHost/browser/agentHostCustomizationService.ts @@ -4,38 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../base/common/uri.js'; -import { Emitter, Event } from '../../../../base/common/event.js'; -import { combinedDisposable, Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; +import { combinedDisposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { basename, isEqual } from '../../../../base/common/resources.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; -import { AgentHostMcpServers, AgentHostMcpServersConfigKey } from '../../../../platform/agentHost/common/agentHostSchema.js'; -import { IAgentHostCustomizationService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; -import { IAgentHostMcpServer, IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; +import { IAgentHostCustomizationService, AbstractAgentHostCustomizationService, type IAgentHostCustomizationTarget } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { IAgentHostSessionsProvider, isAgentHostProvider } from '../../../common/agentHostSessionsProvider.js'; import { ISessionsProvidersService } from '../../sessions/browser/sessionsProvidersService.js'; import { ISessionsManagementService } from '../../sessions/common/sessionsManagement.js'; import { ISessionsService } from '../../sessions/browser/sessionsService.js'; import { ISessionsProvider } from '../../sessions/common/sessionsProvider.js'; -import { AgentCustomization, Customization, CustomizationType } from '../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentCustomization, CustomizationType } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ISession } from '../../sessions/common/session.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; -export class AgentHostCustomizationService extends Disposable implements IAgentHostCustomizationService { - declare readonly _serviceBrand: undefined; - private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>()); - readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event; - private readonly _onDidChangeCustomizations = this._register(new Emitter<void>()); - readonly onDidChangeCustomizations: Event<void> = this._onDidChangeCustomizations.event; +export class AgentHostCustomizationService extends AbstractAgentHostCustomizationService { private readonly _providerListeners = this._register(new DisposableMap<ISessionsProvider>()); constructor( @ISessionsManagementService private readonly _sessionsManagementService: ISessionsManagementService, @ISessionsService private readonly _sessionsService: ISessionsService, @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService logService: ILogService, ) { - super(); + super(instantiationService, logService); this._register(this._sessionsManagementService.onDidChangeSessions(() => { - this._onDidChangeCustomAgents.fire(); - this._onDidChangeCustomizations.fire(); + this._fireCustomAgentsChanged(); + this._fireCustomizationsChanged(); })); } @@ -56,75 +52,50 @@ export class AgentHostCustomizationService extends Disposable implements IAgentH return undefined; } - getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { + protected _resolveTarget(sessionResource: URI): IAgentHostCustomizationTarget | undefined { const session = this._getSession(sessionResource); - if (session) { - const provider = this._getAHSProvider(session); - if (provider) { - const agents = provider.getCustomAgents(session.sessionId); - const activeMode = session.mode.get()?.id; - return agents.length === 0 && activeMode ? [this._agentFromMode(activeMode)] : agents; - } - } - return []; - } - - getCustomizations(sessionResource: URI): readonly Customization[] { - const session = this._getSession(sessionResource); - if (session) { - const provider = this._getAHSProvider(session); - if (provider) { - return provider.getCustomizations(session.sessionId); - } + if (!session) { + return undefined; } - return []; - } - - getWorkingDirectory(sessionResource: URI): string | undefined { - const session = this._getSession(sessionResource); - if (session) { - const provider = this._getAHSProvider(session); - if (provider) { - return provider.getWorkingDirectory(session.sessionId); - } + const provider = this._getAHSProvider(session); + if (!provider) { + return undefined; } - return undefined; + const servers = provider.getMcpServers(session.sessionId); + return { + customizations: provider.getCustomizations(session.sessionId), + workingDirectory: provider.getWorkingDirectory(session.sessionId), + logOutputChannelId: servers[0]?.logOutputChannelId, + rootConfig: provider.getRootConfig(), + authenticate: request => provider.authenticate(request), + setCustomizationEnabled: (rawId, enabled) => { + servers.find(server => this._serverIdMatchesRawId(server.id, rawId))?.setEnabled(enabled); + }, + startMcpServer: rawId => { + return servers.find(server => this._serverIdMatchesRawId(server.id, rawId))?.start() ?? Promise.resolve(); + }, + stopMcpServer: rawId => { + return servers.find(server => this._serverIdMatchesRawId(server.id, rawId))?.stop() ?? Promise.resolve(); + }, + setRootConfigValue: (property, value) => { + void provider.setRootConfigValue(property, value); + }, + }; } - getMcpServers(sessionResource: URI): readonly IAgentHostMcpServer[] { + override getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { const session = this._getSession(sessionResource); if (session) { const provider = this._getAHSProvider(session); if (provider) { - return provider.getMcpServers(session.sessionId); + const agents = provider.getCustomAgents(session.sessionId); + const activeMode = session.mode.get()?.id; + return agents.length === 0 && activeMode ? [this._agentFromMode(activeMode)] : agents; } } return []; } - addMcpServer(sessionResource: URI, name: string, config: IMcpServerConfiguration): void { - const session = this._getSession(sessionResource); - if (!session) { - return; - } - - const provider = this._getAHSProvider(session); - if (!provider) { - return; - } - - const existingServers = provider.getRootConfig()?.values?.[AgentHostMcpServersConfigKey]; - const servers: AgentHostMcpServers = existingServers && typeof existingServers === 'object' && !Array.isArray(existingServers) - ? existingServers as AgentHostMcpServers - : {}; - - void provider.setRootConfigValue(AgentHostMcpServersConfigKey, { - ...servers, - [name]: config, - }); - } - - private _ensureProviderListener(provider: IAgentHostSessionsProvider): void { if (this._providerListeners.has(provider)) { return; @@ -133,14 +104,19 @@ export class AgentHostCustomizationService extends Disposable implements IAgentH // Keep both subscriptions alive under one map key so replacing the provider entry disposes both together. this._providerListeners.set(provider, combinedDisposable( provider.onDidChangeCustomAgents(() => { - this._onDidChangeCustomAgents.fire(); + this._fireCustomAgentsChanged(); }), provider.onDidChangeCustomizations(() => { - this._onDidChangeCustomizations.fire(); + this._fireCustomizationsChanged(); }) )); } + private _serverIdMatchesRawId(serverId: string, rawId: string): boolean { + const separator = serverId.indexOf('/'); + return serverId === rawId || (separator >= 0 && serverId.slice(separator + 1) === rawId); + } + private _agentFromMode(uri: string): AgentCustomization { return { id: uri, diff --git a/src/vs/sessions/services/configuration/browser/configurationService.ts b/src/vs/sessions/services/configuration/browser/configurationService.ts index 30042fa98b6fd5..f29a6b6e53fa36 100644 --- a/src/vs/sessions/services/configuration/browser/configurationService.ts +++ b/src/vs/sessions/services/configuration/browser/configurationService.ts @@ -19,7 +19,7 @@ import { OS, OperatingSystem } from '../../../../base/common/platform.js'; import { IConfigurationChange, IConfigurationChangeEvent, IConfigurationData, IConfigurationOverrides, IConfigurationUpdateOptions, IConfigurationUpdateOverrides, IConfigurationValue, ConfigurationTarget, isConfigurationOverrides, isConfigurationUpdateOverrides } from '../../../../platform/configuration/common/configuration.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; import { ConfigurationChangeEvent, ConfigurationModel } from '../../../../platform/configuration/common/configurationModels.js'; -import { DefaultConfiguration, IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; +import { IPolicyConfiguration, NullPolicyConfiguration, PolicyConfiguration } from '../../../../platform/configuration/common/configurations.js'; import { Extensions, IConfigurationRegistry, IRegisteredConfigurationPropertySchema, keyFromOverrideIdentifiers } from '../../../../platform/configuration/common/configurationRegistry.js'; import { IFileService, FileOperationError, FileOperationResult } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -27,10 +27,11 @@ import { IPolicyService, NullPolicyService } from '../../../../platform/policy/c import { Registry } from '../../../../platform/registry/common/platform.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFolder, WorkbenchState, Workspace } from '../../../../platform/workspace/common/workspace.js'; -import { FolderConfiguration, UserConfiguration, WorkspaceConfiguration } from '../../../../workbench/services/configuration/browser/configuration.js'; -import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, FOLDER_CONFIG_FOLDER_NAME, FOLDER_SETTINGS_PATH, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; +import { DefaultConfiguration, FolderConfiguration, UserConfiguration, WorkspaceConfiguration } from '../../../../workbench/services/configuration/browser/configuration.js'; +import { APPLICATION_SCOPES, APPLY_ALL_PROFILES_SETTING, FOLDER_CONFIG_FOLDER_NAME, FOLDER_SETTINGS_PATH, IConfigurationCache, IWorkbenchConfigurationService, RestrictedSettings } from '../../../../workbench/services/configuration/common/configuration.js'; import { Configuration } from '../../../../workbench/services/configuration/common/configurationModels.js'; import { IUserDataProfileService } from '../../../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { IBrowserWorkbenchEnvironmentService } from '../../../../workbench/services/environment/browser/environmentService.js'; // Import to register configuration contributions import '../../../../workbench/services/configuration/browser/configurationService.js'; @@ -76,11 +77,13 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig private readonly fileService: IFileService, policyService: IPolicyService, private readonly logService: ILogService, + configurationCache: IConfigurationCache, + environmentService: IBrowserWorkbenchEnvironmentService, ) { super(); this.settingsResource = userDataProfileService.currentProfile.settingsResource; - this.defaultConfiguration = this._register(new SessionsDefaultConfiguration(logService)); + this.defaultConfiguration = this._register(new SessionsDefaultConfiguration(userDataProfileService.currentProfile.id, configurationCache, environmentService, logService)); this.policyConfiguration = policyService instanceof NullPolicyService ? new NullPolicyConfiguration() : this._register(new PolicyConfiguration(this.defaultConfiguration, policyService, logService)); this.initAgentsWindowReadOnlyKeys(); this.userConfiguration = this._register(new UserConfiguration(userDataProfileService.currentProfile.settingsResource, userDataProfileService.currentProfile.tasksResource, userDataProfileService.currentProfile.mcpResource, { exclude: [...this.agentsWindowReadOnlyKeys] }, fileService, uriIdentityService, logService)); @@ -271,6 +274,11 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig } async reloadConfiguration(_target?: ConfigurationTarget | IWorkspaceFolder): Promise<void> { + this.reloadDefaultConfiguration(); + if (_target === ConfigurationTarget.DEFAULT) { + return; + } + const userModel = await this.userConfiguration.initialize(); const previousData = this._configuration.toData(); const change = this._configuration.compareAndUpdateLocalUserConfiguration(userModel); @@ -294,8 +302,12 @@ export class ConfigurationService extends Disposable implements IWorkbenchConfig this.triggerConfigurationChange(change, previousData, ConfigurationTarget.USER); } + private reloadDefaultConfiguration(): void { + this.onDefaultConfigurationChanged(this.defaultConfiguration.reload()); + } + hasCachedConfigurationDefaultsOverrides(): boolean { - return false; + return this.defaultConfiguration.hasCachedConfigurationDefaultsOverrides(); } async whenRemoteConfigurationLoaded(): Promise<void> { } diff --git a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts index 78ea9e367de5cc..d8cf9ac7a9ba3d 100644 --- a/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts +++ b/src/vs/sessions/services/configuration/test/browser/configurationService.test.ts @@ -27,6 +27,7 @@ import { SessionsWorkspaceContextService } from '../../../workspace/browser/work import { getWorkspaceIdentifier } from '../../../../../platform/workspaces/common/workspaceIdentifier.js'; import { Event } from '../../../../../base/common/event.js'; import { IUserDataProfileService } from '../../../../../workbench/services/userDataProfile/common/userDataProfile.js'; +import { IConfigurationCache } from '../../../../../workbench/services/configuration/common/configuration.js'; const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); @@ -105,7 +106,8 @@ suite('Sessions ConfigurationService', () => { await fileService.writeFile(configResource, VSBuffer.fromString(JSON.stringify({ folders: [] }))); workspaceService = disposables.add(new SessionsWorkspaceContextService(getWorkspaceIdentifier(configResource), uriIdentityService)); - testObject = disposables.add(new ConfigurationService(userDataProfileService, workspaceService, uriIdentityService, fileService, new NullPolicyService(), logService)); + const nullConfigurationCache: IConfigurationCache = { needsCaching: () => false, read: async () => '', write: async () => { }, remove: async () => { } }; + testObject = disposables.add(new ConfigurationService(userDataProfileService, workspaceService, uriIdentityService, fileService, new NullPolicyService(), logService, nullConfigurationCache, TestEnvironmentService)); await testObject.initialize(); }); diff --git a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts index dd09e468fc70fc..af676ae7800e5c 100644 --- a/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionGroupsService.ts @@ -74,6 +74,12 @@ export interface ISessionGroupsService { /** Add a session to a group (removing it from any previous group). */ addToGroup(sessionId: string, groupId: string): void; + /** + * Add multiple sessions to a group at once (removing them from any previous + * group), firing a single change event. + */ + addToGroup(sessionIds: Iterable<string>, groupId: string): void; + /** Remove a session from its group, if any. */ removeFromGroup(sessionId: string): void; @@ -263,12 +269,18 @@ export class SessionGroupsService extends Disposable implements ISessionGroupsSe this._onDidChange.fire({ groupsChanged: true, membershipChanged }); } - addToGroup(sessionId: string, groupId: string): void { - if (!this._groups.has(groupId) || this._membership.get(sessionId) === groupId) { + addToGroup(sessionIdOrIds: string | Iterable<string>, groupId: string): void { + if (!this._groups.has(groupId)) { return; } + const sessionIds = typeof sessionIdOrIds === 'string' ? [sessionIdOrIds] : sessionIdOrIds; const membershipChanged = new Set<string>(); - this.setMembership(sessionId, groupId, membershipChanged); + for (const sessionId of sessionIds) { + this.setMembership(sessionId, groupId, membershipChanged); + } + if (membershipChanged.size === 0) { + return; + } const evicted = this.evictExcessEmptyGroups(); this.save(); this._onDidChange.fire({ groupsChanged: evicted, membershipChanged }); diff --git a/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts b/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts new file mode 100644 index 00000000000000..1dd634e701a704 --- /dev/null +++ b/src/vs/sessions/services/sessions/browser/sessionTerminalsService.ts @@ -0,0 +1,114 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const ISessionTerminalsService = createDecorator<ISessionTerminalsService>('sessionTerminalsService'); + +/** + * The terminal counts surfaced for a session in the header meta pill. + */ +export interface ISessionTerminalCounts { + /** + * The number of terminals associated with the session that have had at least + * one command sent in them. Empty terminals that never ran a command are + * excluded. Drives the pill label and visibility. + */ + readonly total: number; + + /** + * The number of those terminals that are currently running something (have a + * live child process), e.g. an in-progress `npm install` or a watch task. + * Drives the pill hover. Always `<= total`. + */ + readonly active: number; +} + +export const EMPTY_SESSION_TERMINAL_COUNTS: ISessionTerminalCounts = { total: 0, active: 0 }; + +/** + * Backing data source for {@link ISessionTerminalsService}. Implemented by the + * sessions terminal contribution, which owns the per-session terminal tracking, + * and registered via {@link ISessionTerminalsService.registerProvider}. + * + * The contribution lives in the `contrib` layer, so it cannot be depended upon + * directly by lower-layer consumers (e.g. the `SessionView` that sets the + * `SessionHasTerminalsContext` key). This provider indirection inverts the + * dependency: the contribution registers itself into the service instead. + */ +export interface ISessionTerminalsProvider { + + /** Fires when the terminal counts for one or more sessions may have changed. */ + readonly onDidChangeTerminals: Event<void>; + + /** See {@link ISessionTerminalsService.getTerminalCounts}. */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts; +} + +/** + * Exposes the terminal counts (terminals with a command, and of those, the ones + * currently running) associated with a session, so surfaces such as the session + * header meta row can show a "{n} terminals" pill without depending on the + * terminal contribution directly. + */ +export interface ISessionTerminalsService { + + readonly _serviceBrand: undefined; + + /** Fires when the terminal counts for one or more sessions may have changed. */ + readonly onDidChangeTerminals: Event<void>; + + /** + * The terminal counts for the given session. Returns + * {@link EMPTY_SESSION_TERMINAL_COUNTS} when no provider is registered. + */ + getTerminalCounts(sessionId: string): ISessionTerminalCounts; + + /** + * Registers the backing provider. Only one provider is supported at a time; + * registering a second provider while one is active throws. Dispose the + * returned disposable to unregister. + */ + registerProvider(provider: ISessionTerminalsProvider): IDisposable; +} + +export class SessionTerminalsService extends Disposable implements ISessionTerminalsService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeTerminals = this._register(new Emitter<void>()); + readonly onDidChangeTerminals: Event<void> = this._onDidChangeTerminals.event; + + private _provider: ISessionTerminalsProvider | undefined; + + getTerminalCounts(sessionId: string): ISessionTerminalCounts { + return this._provider?.getTerminalCounts(sessionId) ?? EMPTY_SESSION_TERMINAL_COUNTS; + } + + registerProvider(provider: ISessionTerminalsProvider): IDisposable { + if (this._provider) { + throw new Error('A session terminals provider is already registered'); + } + this._provider = provider; + const listener = provider.onDidChangeTerminals(() => this._onDidChangeTerminals.fire()); + + // A provider registering may change the answer for any session (from the + // no-provider default), so announce the change once on registration. + this._onDidChangeTerminals.fire(); + + return toDisposable(() => { + listener.dispose(); + if (this._provider === provider) { + this._provider = undefined; + this._onDidChangeTerminals.fire(); + } + }); + } +} + +registerSingleton(ISessionTerminalsService, SessionTerminalsService, InstantiationType.Delayed); diff --git a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts index 71fa8ca384880a..bf3b3b33289d69 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsListModelService.ts @@ -47,6 +47,7 @@ export interface ISessionsListModelService { pinSession(session: ISession): void; unpinSession(session: ISession): void; + unpinSessions(sessions: ISession[]): void; isSessionPinned(session: ISession): boolean; // -- Read/Unread -- @@ -182,6 +183,19 @@ export class SessionsListModelService extends Disposable implements ISessionsLis this._onDidChange.fire({ changes: [{ sessionId: session.sessionId, kind: SessionListModelChangeKind.Pinned }] }); } + unpinSessions(sessions: ISession[]): void { + const changed: { sessionId: string; kind: SessionListModelChangeKind }[] = []; + for (const session of sessions) { + if (this._pinnedSessionIds.delete(session.sessionId)) { + changed.push({ sessionId: session.sessionId, kind: SessionListModelChangeKind.Pinned }); + } + } + if (changed.length > 0) { + this.saveSet(SessionsListModelService.PINNED_SESSIONS_KEY, this._pinnedSessionIds); + this._onDidChange.fire({ changes: changed }); + } + } + isSessionPinned(session: ISession): boolean { return this._pinnedSessionIds.has(session.sessionId); } diff --git a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts index e9ecba35d25171..bc07e59d7d80df 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsManagementService.ts @@ -23,6 +23,10 @@ import { ISessionsProvidersChangeEvent, ISessionsProvidersService } from './sess import { IDeleteChatOptions, ISessionChangeEvent, ISessionsProvider } from '../common/sessionsProvider.js'; import { IChat, ISession, ISessionWorkspace, SessionStatus, ISessionType } from '../common/session.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +/** Storage key for the last session type used to create a quick chat. */ +const LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY = 'sessions.quickChat.lastUsedSessionType'; export class SessionsManagementService extends Disposable implements ISessionsManagementService { @@ -85,6 +89,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa @IChatWidgetHistoryService private readonly chatWidgetHistoryService: IChatWidgetHistoryService, @IPathService private readonly pathService: IPathService, @IRemoteAgentHostService private readonly remoteAgentHostService: IRemoteAgentHostService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -218,6 +223,19 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return result; } + getQuickChatSessionTypes(): IProviderSessionType[] { + const result: IProviderSessionType[] = []; + for (const provider of this.sessionsProvidersService.getProviders()) { + if (!provider.supportsQuickChats) { + continue; + } + for (const sessionType of provider.sessionTypes) { + result.push({ providerId: provider.id, sessionType }); + } + } + return result; + } + resolveWorkspace(folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { for (const provider of this.sessionsProvidersService.getProviders()) { const workspace = provider.resolveWorkspace(folderUri); @@ -330,6 +348,79 @@ export class SessionsManagementService extends Disposable implements ISessionsMa return session; } + /** + * Resolve the provider and session type to use for a quick chat, keyed on + * {@link ISessionsProvider.supportsQuickChats} instead of `resolveWorkspace`. + * Honors an explicit `options.sessionTypeId` (validated against the chosen + * provider) and otherwise defaults to the last-used type, then the first + * advertised one. Throws when no capable provider/type can be resolved. + */ + private _resolveProviderForQuickChat(options?: ICreateNewSessionOptions): { provider: ISessionsProvider; sessionTypeId: string } { + const providers = this.sessionsProvidersService.getProviders(); + let provider: ISessionsProvider | undefined; + + if (options?.providerId) { + provider = providers.find(p => p.id === options.providerId); + if (!provider) { + throw new Error(`Sessions provider '${options.providerId}' not found`); + } + if (!provider.supportsQuickChats) { + throw new Error(`Sessions provider '${options.providerId}' does not support quick chats`); + } + if (options.sessionTypeId && !provider.sessionTypes.some(t => t.id === options.sessionTypeId)) { + throw new Error(`Sessions provider '${options.providerId}' does not advertise session type '${options.sessionTypeId}'`); + } + } else { + // Iterate providers (in `order`) and pick the first that supports + // quick chats. When a specific session type was requested, also + // require the provider to advertise it. + for (const candidate of providers) { + if (!candidate.supportsQuickChats) { + continue; + } + if (options?.sessionTypeId && !candidate.sessionTypes.some(t => t.id === options.sessionTypeId)) { + continue; + } + provider = candidate; + break; + } + if (!provider) { + throw new Error('No sessions provider supports quick chats'); + } + } + const sessionTypeId = options?.sessionTypeId ?? this._defaultQuickChatSessionType(provider); + if (!sessionTypeId) { + throw new Error(`No session types available for provider '${provider.id}'`); + } + return { provider, sessionTypeId }; + } + + /** Default quick-chat session type: the last-used one if still advertised, else the first. */ + private _defaultQuickChatSessionType(provider: ISessionsProvider): string | undefined { + const lastUsed = this.storageService.get(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, StorageScope.PROFILE); + if (lastUsed && provider.sessionTypes.some(t => t.id === lastUsed)) { + return lastUsed; + } + return provider.sessionTypes[0]?.id; + } + + createQuickChat(options?: ICreateNewSessionOptions): ISession { + const { provider, sessionTypeId } = this._resolveProviderForQuickChat(options); + + const previousNewSession = this._newSession.get(); + const session = provider.createQuickChat(sessionTypeId); + this._newSession.set(session, undefined); + this.storageService.store(LAST_USED_QUICK_CHAT_SESSION_TYPE_STORAGE_KEY, sessionTypeId, StorageScope.PROFILE, StorageTarget.USER); + + // Mirror `createNewSession`: dispose the previous new session this + // composer just replaced, using its own provider, after a successful + // create so a throw above leaves the previous one intact. + if (previousNewSession && previousNewSession.sessionId !== session.sessionId) { + this._getProvider(previousNewSession)?.deleteNewSession(previousNewSession.sessionId); + } + return session; + } + async createNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise<IChat | undefined> { const provider = this._getProvider(session); if (!provider) { @@ -353,7 +444,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa if (!provider) { throw new Error(`Provider '${session.providerId}' not found for session '${session.sessionId}'`); } - if (!session.capabilities.supportsMultipleChats) { + if (!session.capabilities.get().supportsMultipleChats) { throw new Error(`Session '${session.sessionId}' does not support forking into a chat`); } return provider.forkChat(session.sessionId, sourceChat, turnId); @@ -470,22 +561,39 @@ export class SessionsManagementService extends Disposable implements ISessionsMa * Create a new session for the given folder and send a chat request to it, * without navigating into the started session. The started session appears * in the sessions list once the provider commits it, while the user's - * current view is left untouched. + * current view is left untouched. Returns the committed session, + * or `undefined` if the service was disposed during the send. * * Unlike {@link sendNewChatRequest} with `background`, this does not go * through the new-session composer: it creates a fresh session purely for * this request and never sets it as pending/active. Intended for callers * outside the composer that want to kick off a session programmatically. * - * If the send fails, the stranded draft is disposed through its provider and - * the error is rethrown so the caller can react. + * If the send or any configuration setter fails, the stranded draft is + * disposed through its provider and the error is rethrown. */ - async createAndSendNewChatRequest(folderUri: URI, options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise<void> { + async createAndSendNewChatRequest(folderUri: URI, options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise<ISession | undefined> { const { provider, sessionTypeId } = this._resolveProviderForNewSession(folderUri, createOptions); const session = provider.createNewSession(folderUri, sessionTypeId); try { - await this._sendNewChatRequestInBackground(provider, session, options); + if (createOptions?.modelId) { + provider.setModel(session.sessionId, createOptions.modelId); + } + if (createOptions?.modeId) { + provider.setMode?.(session.sessionId, createOptions.modeId); + } + if (createOptions?.permissionLevel) { + provider.setPermissionLevel?.(session.sessionId, createOptions.permissionLevel); + } + if (createOptions?.isolationMode) { + provider.setIsolationMode?.(session.sessionId, createOptions.isolationMode); + } + if (createOptions?.branch) { + provider.setBranch?.(session.sessionId, createOptions.branch); + } + + return await this._sendNewChatRequestInBackground(provider, session, options); } catch (e) { // The send never committed, so the draft is stranded. Dispose it // through its provider to release the eager backend session before @@ -511,7 +619,7 @@ export class SessionsManagementService extends Disposable implements ISessionsMa * Providers are multi-new-session aware, so the graduating session and a * concurrently reseeded composer draft coexist without conflict. */ - private async _sendNewChatRequestInBackground(provider: ISessionsProvider, session: ISession, options: ISendRequestOptions): Promise<void> { + private async _sendNewChatRequestInBackground(provider: ISessionsProvider, session: ISession, options: ISendRequestOptions): Promise<ISession | undefined> { // Notify listeners (e.g., telemetry) that a send is starting so they can // prewarm caches whose result is consumed when `onDidSendRequest` fires. this._onWillSendRequest.fire(session); @@ -530,10 +638,11 @@ export class SessionsManagementService extends Disposable implements ISessionsMa this._pendingSendChatResources.delete(chatResourceKey); } if (this._store.isDisposed) { - return; + return undefined; } this._onDidStartSession.fire(updatedSession); this._onDidSendRequest.fire({ session: updatedSession, chat, isNewSession: true, isNewChat: true, options }); + return updatedSession; } async sendRequest(session: ISession, chat: IChat, options: ISendRequestOptions): Promise<void> { diff --git a/src/vs/sessions/services/sessions/browser/sessionsService.ts b/src/vs/sessions/services/sessions/browser/sessionsService.ts index 97b82ac56c7017..f8167bafac8e96 100644 --- a/src/vs/sessions/services/sessions/browser/sessionsService.ts +++ b/src/vs/sessions/services/sessions/browser/sessionsService.ts @@ -15,7 +15,7 @@ import { InstantiationType, registerSingleton } from '../../../../platform/insta import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; -import { IChat, ISession, SessionStatus } from '../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../common/session.js'; import { IActiveSession, ICreateNewChatInSessionOptions, ICreateNewSessionOptions, IRecentlyOpenedSessions, ISessionsChangeEvent, ISessionsManagementService, IToggleSessionStickinessEvent } from '../common/sessionsManagement.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; import { SessionsNavigation } from './sessionNavigation.js'; @@ -160,6 +160,14 @@ export interface ISessionsService { */ openNewSession(options?: IOpenNewSessionOptions): ISession | undefined; + /** + * Open a new **quick chat**: create a concrete workspace-less draft session + * (via {@link ISessionsManagementService.createQuickChat}) and show it as the + * active session. Returns the activated session, or `undefined` when no + * provider supports quick chats. + */ + openQuickChat(options?: ICreateNewSessionOptions): IActiveSession | undefined; + /** * Switch to the new-chat-in-session view. * Adds a new chat to the session via the provider, makes it the active chat, @@ -401,14 +409,18 @@ export class SessionsService extends Disposable implements ISessionsService { private _activeSessionViewListeners(activeSession: IActiveSession): IDisposable { const disposables = new DisposableStore(); - // When the active session becomes archived, return to the new-session view - // pre-selecting the same folder so the user stays in context. + // When the active session becomes archived, return to the new-session + // view (or the quick-chat composer for a quick chat), keeping context. let wasArchived = activeSession.isArchived.get(); disposables.add(autorun(reader => { const isArchived = activeSession.isArchived.read(reader); if (isArchived && !wasArchived) { - const folderUri = activeSession.workspace.read(undefined)?.folders[0]?.root; - this.openNewSession(folderUri ? { folderUri, providerId: activeSession.providerId, sessionTypeId: activeSession.sessionType } : undefined); + if (activeSession.isQuickChat?.read(undefined)) { + this.openQuickChat(); + } else { + const folderUri = activeSession.workspace.read(undefined)?.folders[0]?.root; + this.openNewSession(folderUri ? { folderUri, providerId: activeSession.providerId, sessionTypeId: activeSession.sessionType } : undefined); + } } wasArchived = isArchived; })); @@ -419,7 +431,9 @@ export class SessionsService extends Disposable implements ISessionsService { const chats = activeSession.chats.read(reader); const activeChat = activeSession.activeChat.read(reader); if (activeChat && !chats.some(c => this.uriIdentityService.extUri.isEqual(c.resource, activeChat.resource))) { - const fallback = chats[chats.length - 1] ?? activeSession.mainChat.read(reader); + // Fall back to the last visible (non-hidden) chat, or the main chat. + const visible = chats.filter(c => c.interactivity.read(reader) !== ChatInteractivity.Hidden); + const fallback = visible[visible.length - 1] ?? activeSession.mainChat.read(reader); if (fallback) { this.openChat(activeSession, fallback.resource); } @@ -458,6 +472,9 @@ export class SessionsService extends Disposable implements ISessionsService { // no slot remains); drive the open flow below so the fallback is fully // opened. if (e.removed.length) { + for (const session of e.removed) { + this._sessionStates.delete(session.resource); + } this._visibility.removeMany(e.removed.map(r => r.sessionId)); } @@ -558,8 +575,8 @@ export class SessionsService extends Disposable implements ISessionsService { * with the active session by the visibility model, and the model's * canonical active session is updated reactively by the mirror autorun. */ - private _activate(session: ISession | undefined, preserveFocus?: boolean): void { - this._visibility.setActive(session, preserveFocus); + private _activate(session: ISession | undefined, preserveFocus?: boolean): IActiveSession | undefined { + return this._visibility.setActive(session, preserveFocus); } async openChat(session: ISession, chatUri: URI): Promise<void> { @@ -612,6 +629,12 @@ export class SessionsService extends Disposable implements ISessionsService { if (this.uriIdentityService.extUri.isEqual(chat.resource, session.mainChat.get().resource)) { return; } + // Subagent (tool-origin) chats are hidden by default and toggled via an + // in-memory shown set, not the persisted closed set, so they never + // participate in closed-chat persistence. + if (chat.origin?.kind === ChatOriginKind.Tool) { + return; + } const existing = this._sessionStates.get(session.resource); const closedSet = new Set(existing?.closedChatResources ?? []); const chatResource = chat.resource.toString(); @@ -685,10 +708,33 @@ export class SessionsService extends Disposable implements ISessionsService { // their state from the still-alive session object. Otherwise clear the // active session (first time / after send). const newSession = this.sessionsManagementService.newSession.get(); + + // A quick-chat draft must not be restored into the workspace new-session + // composer (symmetric to the New Quick Chat gesture): discard it and show + // a fresh workspace composer instead. + if (newSession?.isQuickChat?.get()) { + this.sessionsManagementService.discardNewSession(newSession); + this._activate(undefined); + return undefined; + } + this._activate(newSession ?? undefined); return newSession ?? undefined; } + openQuickChat(options?: ICreateNewSessionOptions): IActiveSession | undefined { + this._startOpenSession(); + try { + const session = this.sessionsManagementService.createQuickChat(options); + return this._activate(session); + } catch (e) { + // No provider supports quick chats: leave whatever was visible as-is + // rather than activating an unrelated workspace-bound draft. + this.logService.trace(`[SessionsView] openQuickChat: createQuickChat failed: ${e}`); + return undefined; + } + } + async openNewChatInSession(session: ISession, options?: ICreateNewChatInSessionOptions): Promise<void> { this._cancelRestore(); this._startOpenSession(); @@ -843,6 +889,26 @@ export class SessionsService extends Disposable implements ISessionsService { private _saveSessionStates(): void { const entries = this._snapshotVisibleSessionStates(); + + // Also persist the per-session state (closed chats, last active chat) of + // sessions that are not currently visible, so a session switched out of + // the grid keeps its closed-chat set across a reload. Grid-placement + // fields are stripped so they are not restored into the grid. + const visible = new ResourceMap<true>(); + for (const entry of entries) { + visible.set(URI.parse(entry.sessionResource), true); + } + for (const [resource, state] of this._sessionStates) { + if (visible.has(resource)) { + continue; + } + entries.push({ + sessionResource: state.sessionResource, + activeChatResource: state.activeChatResource, + closedChatResources: state.closedChatResources, + }); + } + this.storageService.store(ACTIVE_SESSION_STATES_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } diff --git a/src/vs/sessions/services/sessions/browser/visibleSessions.ts b/src/vs/sessions/services/sessions/browser/visibleSessions.ts index 9999cdc721ee4a..fd05f1177f6744 100644 --- a/src/vs/sessions/services/sessions/browser/visibleSessions.ts +++ b/src/vs/sessions/services/sessions/browser/visibleSessions.ts @@ -8,7 +8,7 @@ import { IObservable, ISettableObservable, ITransaction, autorun, derived, obser import { URI } from '../../../../base/common/uri.js'; import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { IActiveSession } from '../common/sessionsManagement.js'; -import { IChat, ISession, SessionStatus } from '../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../common/session.js'; /** * Wraps an {@link ISession} with an active chat observable to form an @@ -32,10 +32,29 @@ export class VisibleSession extends Disposable implements IActiveSession { private readonly _activeChat: ISettableObservable<IChat>; readonly activeChat: IObservable<IChat>; + /** + * Model and mode are scoped to the active chat so the Agents window pickers + * read and write the selection of the currently focused chat, not the + * session/default chat. Sessions with multiple peer chats keep an + * independent model/agent per chat. + */ + private readonly _activeChatModelId: IObservable<string | undefined>; + private readonly _activeChatMode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; + /** Resource strings of chats that have been closed (hidden from the tab strip). */ private readonly _closedChatUris: ISettableObservable<ReadonlySet<string>>; + /** + * Resource strings of subagent (tool-origin) chats the user explicitly opened, + * so they surface as tabs. Subagents are hidden from the tab strip by default; + * this set is not persisted, so they revert to hidden on reload. + */ + private readonly _shownSubagentUris: ISettableObservable<ReadonlySet<string>>; + /** Append-only list tracking close order; last element is the most recently closed. */ + private readonly _closedChatOrder: IChat[] = []; readonly openChats: IObservable<readonly IChat[]>; readonly closedChats: IObservable<readonly IChat[]>; + readonly visibleChatTabs: IObservable<readonly IChat[]>; + readonly shouldShowChatTabs: IObservable<boolean>; constructor( private readonly _session: ISession, @@ -46,6 +65,9 @@ export class VisibleSession extends Disposable implements IActiveSession { this._activeChat = observableValue<IChat>(`activeChat-${_session.sessionId}`, initialChat); this.activeChat = this._activeChat; + this._activeChatModelId = derived(this, reader => this._activeChat.read(reader).modelId.read(reader)); + this._activeChatMode = derived(this, reader => this._activeChat.read(reader).mode.read(reader)); + // Seed the closed set from persisted state, but never hide the chat that // is being restored as active, nor the main chat (which can never be // closed and must always remain in the tab strip). @@ -57,13 +79,25 @@ export class VisibleSession extends Disposable implements IActiveSession { } this._closedChatUris = observableValue<ReadonlySet<string>>('closedChatUris', seed); + // Subagents are hidden by default; if the restored active chat is one, + // surface its tab so the session opens where the user left off. + const shownSubagents = new Set<string>(); + if (initialChat?.origin?.kind === ChatOriginKind.Tool) { + shownSubagents.add(initialChat.resource.toString()); + } + this._shownSubagentUris = observableValue<ReadonlySet<string>>('shownSubagentUris', shownSubagents); + this._isCreated = _session.status.map(status => status !== SessionStatus.Untitled); this.isCreated = this._isCreated; this.openChats = derived(this, reader => { const closed = this._closedChatUris.read(reader); const chats = this._session.chats.read(reader); - return closed.size === 0 ? chats : chats.filter(c => !closed.has(c.resource.toString())); + // Hidden chats are internal workers that must never be surfaced in the + // tab strip; closed chats are user-dismissed. Both are excluded here. + return chats.filter(c => + c.interactivity.read(reader) !== ChatInteractivity.Hidden && + !closed.has(c.resource.toString())); }); this.closedChats = derived(this, reader => { const closed = this._closedChatUris.read(reader); @@ -72,6 +106,41 @@ export class VisibleSession extends Disposable implements IActiveSession { } return this._session.chats.read(reader).filter(c => closed.has(c.resource.toString())); }); + // Tab strip contents: the open chats in the provider's order, with subagent + // (tool-origin) chats hidden by default. A subagent surfaces as a tab only + // once explicitly opened (e.g. from the Conversations menu), tracked in + // `_shownSubagentUris`. Hidden and closed chats are excluded by `openChats`. + this.visibleChatTabs = derived(this, reader => { + const shownSubagents = this._shownSubagentUris.read(reader); + return this.openChats.read(reader).filter(c => + c.origin?.kind !== ChatOriginKind.Tool || + shownSubagents.has(c.resource.toString())); + }); + // Shown for more than one real (non-tool) chat — counting closed ones — + // or a single chat whose title diverged from the session title. An opened + // subagent tab also warrants showing the strip, so any time there is more + // than one visible tab the strip is shown. The strip is also shown as soon + // as the session has any subagent (tool-origin) chat, so the Conversations + // menu (which lists subagents) surfaces in the tab bar. + this.shouldShowChatTabs = derived(this, reader => { + const chats = this._session.chats.read(reader); + if (chats.some(c => c.origin?.kind === ChatOriginKind.Tool)) { + return true; + } + const tabChats = chats.filter(c => + c.origin?.kind !== ChatOriginKind.Tool && + c.interactivity.read(reader) !== ChatInteractivity.Hidden); + if (tabChats.length > 1) { + return true; + } + if (this.visibleChatTabs.read(reader).length > 1) { + return true; + } + if (tabChats.length === 1) { + return tabChats[0].title.read(reader) !== this._session.title.read(reader); + } + return false; + }); } setActiveChat(chat: IChat): void { @@ -84,23 +153,52 @@ export class VisibleSession extends Disposable implements IActiveSession { if (chatUri === this._session.mainChat.get().resource.toString()) { return; } + // Closing a subagent (tool-origin) tab just hides it again; it stays + // reachable from the Conversations menu and is not added to the + // reopenable closed set. + if (chat.origin?.kind === ChatOriginKind.Tool) { + const shown = this._shownSubagentUris.get(); + if (!shown.has(chatUri)) { + return; + } + const nextShown = new Set(shown); + nextShown.delete(chatUri); + transaction(tx => { + this._shownSubagentUris.set(nextShown, tx); + if (this._activeChat.get().resource.toString() === chatUri) { + this._activeChat.set(this._defaultActiveChat(this._closedChatUris.get(), nextShown), tx); + } + }); + return; + } const closed = this._closedChatUris.get(); if (closed.has(chatUri)) { return; } const next = new Set(closed); next.add(chatUri); + this._closedChatOrder.push(chat); transaction(tx => { this._closedChatUris.set(next, tx); - // If the closed chat was active, fall back to another open chat. + // If the closed chat was active, fall back to another visible tab. if (this._activeChat.get().resource.toString() === chatUri) { - const open = this._session.chats.get().filter(c => !next.has(c.resource.toString())); - this._activeChat.set(open[open.length - 1] ?? this._session.mainChat.get(), tx); + this._activeChat.set(this._defaultActiveChat(next, this._shownSubagentUris.get()), tx); } }); } openChat(chat: IChat): void { + // Opening a subagent (tool-origin) chat surfaces it as a tab. + if (chat.origin?.kind === ChatOriginKind.Tool) { + const shown = this._shownSubagentUris.get(); + if (shown.has(chat.resource.toString())) { + return; + } + const next = new Set(shown); + next.add(chat.resource.toString()); + this._shownSubagentUris.set(next, undefined); + return; + } const closed = this._closedChatUris.get(); if (!closed.has(chat.resource.toString())) { return; @@ -108,6 +206,37 @@ export class VisibleSession extends Disposable implements IActiveSession { const next = new Set(closed); next.delete(chat.resource.toString()); this._closedChatUris.set(next, undefined); + const idx = this._closedChatOrder.findLastIndex(c => c.resource.toString() === chat.resource.toString()); + if (idx !== -1) { + this._closedChatOrder.splice(idx, 1); + } + } + + /** + * Pick the active chat to fall back to when the current one is closed: the + * last chat that would appear as a visible tab given the closed and shown- + * subagent sets, or the main chat. + */ + private _defaultActiveChat(closed: ReadonlySet<string>, shownSubagents: ReadonlySet<string>): IChat { + const candidates = this._session.chats.get().filter(c => + c.interactivity.get() !== ChatInteractivity.Hidden && + !closed.has(c.resource.toString()) && + (c.origin?.kind !== ChatOriginKind.Tool || shownSubagents.has(c.resource.toString()))); + return candidates[candidates.length - 1] ?? this._session.mainChat.get(); + } + + get lastClosedChat(): IChat | undefined { + // Filter out stale entries whose chat has since been deleted from the session. + const currentChats = this._session.chats.get(); + const closed = this._closedChatUris.get(); + for (let i = this._closedChatOrder.length - 1; i >= 0; i--) { + const chat = this._closedChatOrder[i]; + const uri = chat.resource.toString(); + if (closed.has(uri) && currentChats.some(c => c.resource.toString() === uri)) { + return chat; + } + } + return undefined; } setSticky(value: boolean): void { @@ -126,13 +255,16 @@ export class VisibleSession extends Disposable implements IActiveSession { get icon() { return this._session.icon; } get createdAt() { return this._session.createdAt; } get workspace() { return this._session.workspace; } + get isQuickChat() { return this._session.isQuickChat; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } get status() { return this._session.status; } - get changes() { return this._session.changes; } + get changesSummary() { return this._session.changesSummary; } get changesets() { return this._session.changesets; } - get modelId() { return this._session.modelId; } - get mode() { return this._session.mode; } + get changes() { return this._session.changes; } + get externalChanges() { return this._session.externalChanges; } + get modelId() { return this._activeChatModelId; } + get mode() { return this._activeChatMode; } get loading() { return this._session.loading; } get isArchived() { return this._session.isArchived; } get isRead() { return this._session.isRead; } @@ -164,11 +296,14 @@ class ResourceOverrideSession implements ISession { get icon() { return this._session.icon; } get createdAt() { return this._session.createdAt; } get workspace() { return this._session.workspace; } + get isQuickChat() { return this._session.isQuickChat; } get title() { return this._session.title; } get updatedAt() { return this._session.updatedAt; } get status() { return this._session.status; } + get changesSummary() { return this._session.changesSummary; } get changes() { return this._session.changes; } get changesets() { return this._session.changesets; } + get externalChanges() { return this._session.externalChanges; } get modelId() { return this._session.modelId; } get mode() { return this._session.mode; } get loading() { return this._session.loading; } diff --git a/src/vs/sessions/services/sessions/common/session.ts b/src/vs/sessions/services/sessions/common/session.ts index 67381be7306330..0600ac15d43894 100644 --- a/src/vs/sessions/services/sessions/common/session.ts +++ b/src/vs/sessions/services/sessions/common/session.ts @@ -5,7 +5,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { IObservable } from '../../../../base/common/observable.js'; +import { IObservable, IReader } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; @@ -48,6 +48,24 @@ export const enum SessionStatus { Error = 4, } +/** + * Provider-agnostic interactivity of a chat within a session. Mirrors the agent + * host protocol's notion of chat interactivity but is decoupled from it so that + * non-agent-host providers can report it too. + * + * Supports the agent-team pattern where a lead chat is fully interactive while + * worker chats are read-only (visible for observability) or hidden (internal + * implementation detail). + */ +export const enum ChatInteractivity { + /** The user can send messages to the chat (default when unspecified). */ + Full = 'full', + /** The chat is visible but read-only — the user can watch but not send messages. */ + ReadOnly = 'read-only', + /** The chat is an internal worker that should not be shown in the UI at all. */ + Hidden = 'hidden', +} + export interface ISessionGitRepository { /** The source repository URI. */ readonly uri: URI; @@ -151,6 +169,40 @@ export interface ISessionChangesSummary { export type ISessionFileChange = IChatSessionFileChange | IChatSessionFileChange2; +/** + * The kind of change applied to a {@link ISessionFile}. + * + * A file that is first created and then edited during the session is reported + * as {@link Created}. A file that is deleted is reported as {@link Deleted} + * regardless of any earlier creation or edit. + */ +export const enum SessionFileOperation { + /** The file was created during the session (and possibly edited afterwards). */ + Created = 'created', + /** The file existed before the session and was modified during it. */ + Modified = 'modified', + /** The file was deleted during the session. */ + Deleted = 'deleted', +} + +/** + * A file that was created, edited or deleted **outside** the session workspace + * folders during the session. These are surfaced separately from + * {@link ISession.changes} because they are not part of the workspace and will + * not be committed. + */ +export interface ISessionFile { + /** The file URI (after-state for create/modify, the deleted path for delete). */ + readonly uri: URI; + /** The kind of change applied to the file during the session. */ + readonly operation: SessionFileOperation; + /** + * URI from which the file's pre-session content can be read, when known. + * Used to render a diff for {@link SessionFileOperation.Modified} files. + */ + readonly originalUri?: URI; +} + /** * Well-known id of the changeset that holds the diff between a session's branch * and its base (e.g. `main...feature`). Shared so that consumers which always @@ -159,6 +211,16 @@ export type ISessionFileChange = IChatSessionFileChange | IChatSessionFileChange */ export const BRANCH_CHANGES_CHANGESET_ID = 'branchChanges'; +/** + * Well-known id of the changeset that holds the diff made during the session's + * **last turn** only (as opposed to the cumulative session diff). Consumers that + * want to reflect just the most recent turn — e.g. the chat input status pills — + * can locate it in {@link ISession.changesets} by id. + * + * Must match the agent host provider's `ChangesetKind.Turn` value. + */ +export const TURN_CHANGES_CHANGESET_ID = 'turn'; + export interface ISessionChangeset { /** Unique identifier for the changeset. */ readonly id: string; @@ -277,8 +339,30 @@ export const enum ChatOriginKind { export interface IChatOrigin { readonly kind: ChatOriginKind; + /** + * For a chat spawned by another chat (e.g. a subagent worker chat, kind + * {@link ChatOriginKind.Tool}, or a {@link ChatOriginKind.Fork}), the + * resource of the chat that spawned it. Undefined for user-originated chats. + */ + readonly parentChat?: URI; } +/** + * Per-chat capabilities. Consumers gate chat-management UI (rename, delete) on + * these flags rather than on the chat's origin/provider, so the affordances are + * offered exactly where the backing chat supports them. A worker (subagent) + * chat, for example, is neither renameable nor deletable. + */ +export interface IChatCapabilities { + /** Whether this chat's title can be renamed. */ + readonly canRename: boolean; + /** Whether this chat can be permanently deleted. */ + readonly canDelete: boolean; +} + +/** Capabilities assumed for a chat that does not advertise its own. */ +export const DEFAULT_CHAT_CAPABILITIES: IChatCapabilities = { canRename: true, canDelete: true }; + /** * A single chat within a session, produced by the sessions management layer. */ @@ -298,6 +382,14 @@ export interface IChat { readonly status: IObservable<SessionStatus>; /** File changes produced by the chat. */ readonly changes: IObservable<readonly ISessionFileChange[]>; + /** + * File changes produced by the chat's **last turn** only (as opposed to the + * cumulative chat {@link changes}). Derived from the chat's live output + * stream so consumers — e.g. the chat input status pills — can reflect just + * what the most recent request produced. Providers that cannot determine + * this omit the observable. + */ + readonly lastTurnChanges?: IObservable<readonly ISessionFileChange[]>; /** Checkpoints associated with the chat. */ readonly checkpoints: IObservable<IChatCheckpoints | undefined>; /** Currently selected model identifier. */ @@ -308,12 +400,45 @@ export interface IChat { readonly isArchived: IObservable<boolean>; /** Whether the chat has been read. */ readonly isRead: IObservable<boolean>; + /** + * Whether and how the user can interact with this chat. Providers that do + * not distinguish read-only chats report {@link ChatInteractivity.Full}. + * + * - {@link ChatInteractivity.Full}: the user can send messages (default). + * - {@link ChatInteractivity.ReadOnly}: the chat is shown but the composer is + * hidden (e.g. an agent-team worker chat the user can watch but not steer). + * - {@link ChatInteractivity.Hidden}: the chat is an internal worker that + * should not be surfaced in the UI at all; the visible session model filters + * these out of the tab strip and never makes them the active chat. + */ + readonly interactivity: IObservable<ChatInteractivity>; /** Status description shown while the chat is active (e.g., current agent action). */ readonly description: IObservable<IMarkdownString | undefined>; /** Timestamp of when the last agent turn ended, if any. */ readonly lastTurnEnd: IObservable<Date | undefined>; /** How the chat came into existence, if provided by the backend. */ readonly origin?: IChatOrigin; + /** + * Capabilities of this chat (rename/delete). Absent means the chat inherits + * {@link DEFAULT_CHAT_CAPABILITIES} (fully capable); read via + * {@link getChatCapabilities}. + */ + readonly capabilities?: IObservable<IChatCapabilities>; +} + +/** + * Resolve a chat's effective capabilities. Combines the chat's own advertised + * {@link IChat.capabilities} (falling back to {@link DEFAULT_CHAT_CAPABILITIES}) + * with the session-level invariant that a session's main chat can never be + * deleted — it lives and dies with the session. Pass the owning session so the + * main-chat rule applies; omit it to read only the chat's own capabilities. + */ +export function getChatCapabilities(chat: IChat, session: ISession | undefined, reader: IReader | undefined): IChatCapabilities { + const own = chat.capabilities?.read(reader) ?? DEFAULT_CHAT_CAPABILITIES; + if (session && isEqual(chat.resource, session.mainChat.read(reader).resource)) { + return own.canDelete ? { ...own, canDelete: false } : own; + } + return own; } /** @@ -335,6 +460,8 @@ export interface ISession { readonly createdAt: Date; /** Workspace this session operates on. */ readonly workspace: IObservable<ISessionWorkspace | undefined>; + /** Whether this is a workspace-less "quick chat". Only quick-chat-capable providers set this; absent means `false`. */ + readonly isQuickChat?: IObservable<boolean>; // Reactive properties @@ -349,7 +476,14 @@ export interface ISession { /** File changes produced by the session. */ readonly changes: IObservable<readonly ISessionFileChange[]>; /** Changesets produced by the session. */ - readonly changesets: IObservable<readonly ISessionChangeset[]>; + readonly changesets: IObservable<readonly ISessionChangeset[] | undefined>; + /** + * Files created, edited or deleted **outside** the session workspace folders + * during the session (e.g. config files in the user's home directory). These + * are not part of {@link changes} and will not be committed. Providers that + * cannot determine this report an empty array (or omit the observable). + */ + readonly externalChanges?: IObservable<readonly ISessionFile[]>; /** Currently selected model identifier. */ readonly modelId: IObservable<string | undefined>; readonly mode: IObservable<{ readonly id: string; readonly kind: string } | undefined>; @@ -367,8 +501,13 @@ export interface ISession { readonly chats: IObservable<readonly IChat[]>; /** The main (first) chat of this session. Providers may replace it for a new session via {@link ISessionsProvider.createNewChat}. */ readonly mainChat: IObservable<IChat>; - /** Capabilities of this session. */ - readonly capabilities: ISessionCapabilities; + /** + * Capabilities of this session. Observable so consumers (context keys, chat + * catalog) react when a provider's advertised capabilities hydrate or change + * after the session is first surfaced (e.g. an agent host whose root state + * arrives after the session's first state update). + */ + readonly capabilities: IObservable<ISessionCapabilities>; } /** @@ -393,6 +532,13 @@ export function toSessionId(providerId: string, resource: URI): string { export interface ISessionCapabilities { /** Whether this session supports multiple chats. */ readonly supportsMultipleChats: boolean; + /** + * Whether this session supports forking a chat from a turn into a new peer + * chat. The agents-window fork gesture gates on this flag rather than on the + * provider id, so fork is offered exactly where the backing agent supports + * it. Defaults to falsy (no fork) when omitted. + */ + readonly supportsFork?: boolean; /** * Whether this session's title can be renamed. The agents-window UI * (session header inline edit, sessions-list `Rename...` action) gates @@ -430,6 +576,17 @@ export interface ISessionCapabilities { export const SESSION_WORKSPACE_GROUP_LOCAL = localize('sessionWorkspaceGroup.local', "Local"); export const SESSION_WORKSPACE_GROUP_REMOTE = localize('sessionWorkspaceGroup.remote', "Remote"); +/** + * The fallback title for an untitled session: "New Chat" for a quick chat, + * otherwise "New Session". Callers pass the boolean so they control how they + * read `isQuickChat` (reader-tracked vs `.get()`). + */ +export function getUntitledSessionTitle(isQuickChat: boolean): string { + return isQuickChat + ? localize('agentSessions.newChat', "New Chat") + : localize('agentSessions.newSession', "New Session"); +} + export interface ISessionWorkspaceBrowseAction { /** Display label for the browse action. */ readonly label: string; @@ -507,6 +664,10 @@ export function sessionFileChangesEqual(a: readonly ISessionFileChange[], b: rea if (!isEqual(x.originalUri, y.originalUri)) { return false; } + + if (x.reviewed !== y.reviewed) { + return false; + } } return true; diff --git a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts index 23736a0a95e648..4738c7dbf12bfb 100644 --- a/src/vs/sessions/services/sessions/common/sessionContextKeys.ts +++ b/src/vs/sessions/services/sessions/common/sessionContextKeys.ts @@ -4,11 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { IReader } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { SessionHasChangesContext, SessionHasPullRequestContext, SessionHasWorkspaceContext, + IsQuickChatSessionContext, SessionIsArchivedContext, SessionIsCreatedContext, SessionIsReadContext, @@ -16,14 +18,19 @@ import { SessionProviderIdContext, SessionSupportsDeleteContext, SessionSupportsMultipleChatsContext, + SessionSupportsForkContext, SessionSupportsRenameContext, SessionTypeContext, SessionWorkspaceIsVirtualContext, SessionIdContext, SessionHasMultipleCommittedChatsContext, + SessionShouldShowChatTabsContext, SessionHasMultipleOpenChatsContext, + SessionActiveChatIsClosableContext, + SessionActiveChatIsDeletableContext, + SessionActiveChatHasSubagentsContext, } from '../../../common/contextkeys.js'; -import { ChatOriginKind, ISession, SessionStatus } from './session.js'; +import { ChatOriginKind, getChatCapabilities, ISession, SessionStatus } from './session.js'; import { IActiveSession } from './sessionsManagement.js'; /** @@ -36,16 +43,22 @@ interface ISessionContextKeys { readonly isArchived: IContextKey<boolean>; readonly isRead: IContextKey<boolean>; readonly supportsMultipleChats: IContextKey<boolean>; + readonly supportsFork: IContextKey<boolean>; readonly supportsRename: IContextKey<boolean>; readonly supportsDelete: IContextKey<boolean>; readonly workspaceIsVirtual: IContextKey<boolean>; readonly hasChanges: IContextKey<boolean>; readonly hasPullRequest: IContextKey<boolean>; readonly hasWorkspace: IContextKey<boolean>; + readonly isQuickChat: IContextKey<boolean>; readonly isCreated: IContextKey<boolean>; readonly sticky: IContextKey<boolean>; readonly hasMultipleCommittedChats: IContextKey<boolean>; + readonly shouldShowChatTabs: IContextKey<boolean>; readonly hasMultipleOpenChats: IContextKey<boolean>; + readonly activeChatIsClosable: IContextKey<boolean>; + readonly activeChatIsDeletable: IContextKey<boolean>; + readonly activeChatHasSubagents: IContextKey<boolean>; } /** @@ -68,16 +81,22 @@ function getBoundKeys(contextKeyService: IContextKeyService): ISessionContextKey isArchived: SessionIsArchivedContext.bindTo(contextKeyService), isRead: SessionIsReadContext.bindTo(contextKeyService), supportsMultipleChats: SessionSupportsMultipleChatsContext.bindTo(contextKeyService), + supportsFork: SessionSupportsForkContext.bindTo(contextKeyService), supportsRename: SessionSupportsRenameContext.bindTo(contextKeyService), supportsDelete: SessionSupportsDeleteContext.bindTo(contextKeyService), workspaceIsVirtual: SessionWorkspaceIsVirtualContext.bindTo(contextKeyService), hasChanges: SessionHasChangesContext.bindTo(contextKeyService), hasPullRequest: SessionHasPullRequestContext.bindTo(contextKeyService), hasWorkspace: SessionHasWorkspaceContext.bindTo(contextKeyService), + isQuickChat: IsQuickChatSessionContext.bindTo(contextKeyService), isCreated: SessionIsCreatedContext.bindTo(contextKeyService), sticky: SessionIsStickyContext.bindTo(contextKeyService), hasMultipleCommittedChats: SessionHasMultipleCommittedChatsContext.bindTo(contextKeyService), + shouldShowChatTabs: SessionShouldShowChatTabsContext.bindTo(contextKeyService), hasMultipleOpenChats: SessionHasMultipleOpenChatsContext.bindTo(contextKeyService), + activeChatIsClosable: SessionActiveChatIsClosableContext.bindTo(contextKeyService), + activeChatIsDeletable: SessionActiveChatIsDeletableContext.bindTo(contextKeyService), + activeChatHasSubagents: SessionActiveChatHasSubagentsContext.bindTo(contextKeyService), }; boundKeysByService.set(contextKeyService, keys); } @@ -105,9 +124,11 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS keys.type.set(session?.sessionType ?? ''); keys.isArchived.set(session?.isArchived.read(reader) ?? false); keys.isRead.set(session?.isRead.read(reader) ?? true); - keys.supportsMultipleChats.set(session?.capabilities.supportsMultipleChats ?? false); - keys.supportsRename.set(session?.capabilities.supportsRename ?? false); - keys.supportsDelete.set(session?.capabilities.supportsDelete ?? false); + const capabilities = session?.capabilities.read(reader); + keys.supportsMultipleChats.set(capabilities?.supportsMultipleChats ?? false); + keys.supportsFork.set(capabilities?.supportsFork ?? false); + keys.supportsRename.set(capabilities?.supportsRename ?? false); + keys.supportsDelete.set(capabilities?.supportsDelete ?? false); keys.workspaceIsVirtual.set(session?.workspace.read(reader)?.isVirtualWorkspace ?? true); // Mirror the changes pill: the default changeset, falling back to the session's changes. @@ -124,6 +145,11 @@ export function setSessionContextKeys(session: ISession | undefined, contextKeyS keys.hasPullRequest.set(!!pullRequest); keys.hasWorkspace.set(!!session?.workspace.read(reader)?.label); + + // Sourced from the session's `isQuickChat` tag — never inferred from + // `workspace === undefined` (which is also transiently true for a + // still-resolving workspace session). + keys.isQuickChat.set(!!session && (session.isQuickChat?.read(reader) ?? false)); } /** @@ -148,7 +174,34 @@ export function setActiveSessionContextKeys(session: IActiveSession | undefined, .reduce((count, chat) => chat.status.read(reader) === SessionStatus.Untitled || chat.origin?.kind === ChatOriginKind.Tool ? count : count + 1, 0) ?? 0; keys.hasMultipleCommittedChats.set(committedChatCount > 1); - // More than one open chat (incl. drafts) means the tab strip is shown; the - // header then hides its own New Chat button. - keys.hasMultipleOpenChats.set((session?.openChats.read(reader).filter(chat => chat.origin?.kind !== ChatOriginKind.Tool).length ?? 0) > 1); + // The tab strip is shown when the session has more than one chat (counting + // closed chats) or its single remaining chat's title diverged from the + // session title; the header then hides its own New Chat button. + keys.shouldShowChatTabs.set(session?.shouldShowChatTabs.read(reader) ?? false); + + // More than one open chat tab (incl. drafts): scopes chat-to-chat navigation + // so it stays a no-op when only a single open chat remains (e.g. a single + // chat with a diverged title, or one open + one closed chat). + keys.hasMultipleOpenChats.set((session?.visibleChatTabs.read(reader).length ?? 0) > 1); + + // The active chat can be closed (hidden) from the tab strip when it is a + // non-main chat — including read-only subagent chats, which surface as + // closeable tabs. The main chat lives and dies with its session. + const activeChat = session?.activeChat.read(reader); + const mainResource = session?.mainChat.read(reader).resource; + const isNonMainChat = !!activeChat && !!mainResource && !isEqual(activeChat.resource, mainResource); + keys.activeChatIsClosable.set(isNonMainChat); + // It can be permanently deleted only when its effective capabilities allow + // it: the main chat and worker (subagent) chats report `canDelete: false`, + // so they are closeable but not deletable. + keys.activeChatIsDeletable.set(!!activeChat && getChatCapabilities(activeChat, session, reader).canDelete); + + // The active chat has subagents when any tool-origin chat names it as its + // parent. These are listed as a separate group in the Conversations menu, so + // the menu must surface even when the active chat is the only committed chat. + const allChats = session?.chats.read(reader) ?? []; + keys.activeChatHasSubagents.set(!!activeChat && allChats.some(chat => + chat.origin?.kind === ChatOriginKind.Tool && + !!chat.origin.parentChat && + isEqual(chat.origin.parentChat, activeChat.resource))); } diff --git a/src/vs/sessions/services/sessions/common/sessionsManagement.ts b/src/vs/sessions/services/sessions/common/sessionsManagement.ts index f501bc4a64d69e..beb2fd585a4376 100644 --- a/src/vs/sessions/services/sessions/common/sessionsManagement.ts +++ b/src/vs/sessions/services/sessions/common/sessionsManagement.ts @@ -54,6 +54,36 @@ export interface ICreateNewSessionOptions { * chosen provider advertises for the folder URI. */ readonly sessionTypeId?: string; + /** + * Optional model identifier to apply to the new session via + * {@link ISessionsProvider.setModel}. If the provider throws, the + * stranded draft is disposed and the error propagates. + */ + readonly modelId?: string; + /** + * Optional chat mode identifier (typically a value from `ChatModeKind`) + * to apply via {@link ISessionsProvider.setMode}. Skipped if the + * provider does not implement the setter. + */ + readonly modeId?: string; + /** + * Optional permission level (typically a value from + * `ChatPermissionLevel`) to apply via + * {@link ISessionsProvider.setPermissionLevel}. Skipped if the provider + * does not implement the setter. + */ + readonly permissionLevel?: string; + /** + * Optional worktree isolation mode (`worktree` or `workspace`) to apply + * via {@link ISessionsProvider.setIsolationMode}. Skipped if the + * provider does not implement the setter. + */ + readonly isolationMode?: string; + /** + * Optional git branch to apply via {@link ISessionsProvider.setBranch}. + * Skipped if the provider does not implement the setter. + */ + readonly branch?: string; } /** @@ -118,6 +148,23 @@ export interface IActiveSession extends ISession { /** The closed (hidden from the tab strip) but still reopenable chats. Deleted chats drop out. */ readonly closedChats: IObservable<readonly IChat[]>; + + /** The most recently closed chat, or `undefined` if none. */ + readonly lastClosedChat: IChat | undefined; + + /** + * The chats shown as tabs in the tab strip: {@link openChats} with subagent + * (tool-origin) chats hidden by default, in the provider's order. A subagent + * surfaces as a tab only once explicitly opened. + */ + readonly visibleChatTabs: IObservable<readonly IChat[]>; + + /** + * Whether the chat tab strip should be shown: the session has more than one + * chat (counting closed, non-tool chats), or its single remaining chat has a + * title that diverged from the session title. + */ + readonly shouldShowChatTabs: IObservable<boolean>; } /** @@ -169,6 +216,14 @@ export interface ISessionsManagementService { */ getSessionTypesForFolder(folderUri: URI): IProviderSessionType[]; + /** + * Get all session types offered for quick chats, across every provider that + * sets {@link ISessionsProvider.supportsQuickChats}. Returns one entry per + * (provider × advertised type) so the UI can let the user pick a type when + * creating a quick chat. + */ + getQuickChatSessionTypes(): IProviderSessionType[]; + /** * Resolve a workspace URI to a workspace using the first provider whose * {@link ISessionsProvider.resolveWorkspace} succeeds. Returns `undefined` @@ -251,6 +306,20 @@ export interface ISessionsManagementService { */ createNewSession(folderUri: URI, options?: ICreateNewSessionOptions): ISession; + /** + * Create a new **quick chat**: a workspace-less session not scoped to any + * folder (`ISession.workspace` resolves to `undefined`). + * + * When `options.providerId` is omitted, picks the first registered provider + * (by `order`) that sets {@link ISessionsProvider.supportsQuickChats}. When + * `options.sessionTypeId` is omitted, defaults to the chosen provider's + * first advertised quick-chat session type. + * + * Tracks the created session as the new session and returns it. Does not + * make it active/visible — the `ISessionsService` shows it. + */ + createQuickChat(options?: ICreateNewSessionOptions): ISession; + /** * Create (or reuse an existing untitled) chat in the given session via its * provider so it can be shown as the new-chat-in-session view. Pass @@ -300,10 +369,11 @@ export interface ISessionsManagementService { * The started session appears in the sessions list once the provider * commits it, while the user's current view is left untouched. Intended for * callers outside the new-session composer that want to kick off a session - * programmatically. Rejects (after disposing the stranded draft) if the send - * fails. + * programmatically. Returns the committed session, or `undefined` if the + * service was disposed during the send. Rejects (after disposing the + * stranded draft) if the send fails. */ - createAndSendNewChatRequest(folderUri: URI, options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise<void>; + createAndSendNewChatRequest(folderUri: URI, options: ISendRequestOptions, createOptions?: ICreateNewSessionOptions): Promise<ISession | undefined>; /** * Send a request for an existing chat within a session. diff --git a/src/vs/sessions/services/sessions/common/sessionsProvider.ts b/src/vs/sessions/services/sessions/common/sessionsProvider.ts index 63cc86ed0c9808..a7b73a1a2dfc3b 100644 --- a/src/vs/sessions/services/sessions/common/sessionsProvider.ts +++ b/src/vs/sessions/services/sessions/common/sessionsProvider.ts @@ -27,6 +27,8 @@ export interface ISendRequestOptions { readonly query: string; /** Optional attached context entries. */ readonly attachedContext?: IChatRequestVariableEntry[]; + /** Optional display title for the new session. */ + readonly title?: string; } /** @@ -137,6 +139,21 @@ export interface ISessionsProvider { */ readonly supportsLocalWorkspaces?: boolean; + /** + * Whether this provider can create **quick chats**: workspace-less sessions + * that are not scoped to any folder (`ISession.workspace` is `undefined`). + * When `true`, the provider must implement {@link createQuickChat}. + * Defaults to falsy (quick chats not supported). Providers that do change + * this at runtime should signal it via {@link onDidChangeCapabilities}. + */ + readonly supportsQuickChats?: boolean; + + /** + * Optional. Fires when a capability flag that consumers gate UI on (e.g. + * {@link supportsQuickChats}) changes at runtime, so they can re-evaluate. + */ + readonly onDidChangeCapabilities?: Event<void>; + /** * Resolve a workspace for the given repository URI. * Returns `undefined` when the provider cannot handle the given URI @@ -156,6 +173,19 @@ export interface ISessionsProvider { */ createNewSession(workspaceUri: URI, sessionTypeId: string): ISession; + /** + * Create a new **quick chat**: a workspace-less session not scoped to any + * folder (`ISession.workspace` resolves to `undefined`). Like + * {@link createNewSession}, the returned session is an untitled draft that + * the provider must not add to its session list until the first request is + * sent, and that is disposed via {@link deleteNewSession} if abandoned. + * + * Callers must gate on {@link supportsQuickChats}; providers that do not + * support quick chats must throw. + * @param sessionTypeId The ID of the session type to create. + */ + createQuickChat(sessionTypeId: string): ISession; + /** * Delete a new (untitled, not-yet-sent) session previously created via * {@link createNewSession}, removing it from the provider's tracking and @@ -225,6 +255,34 @@ export interface ISessionsProvider { */ setModel(sessionId: string, modelId: string): void; + /** + * Set the chat mode for a session. + * @param sessionId The ID of the session. + * @param modeId The mode identifier to set. + */ + setMode?(sessionId: string, modeId: string): void; + + /** + * Set the permission level for a session. + * @param sessionId The ID of the session. + * @param level The permission level to set. + */ + setPermissionLevel?(sessionId: string, level: string): void; + + /** + * Set the isolation mode for a session. + * @param sessionId The ID of the session. + * @param mode The isolation mode to set. + */ + setIsolationMode?(sessionId: string, mode: string): void; + + /** + * Set the git branch for a session. + * @param sessionId The ID of the session. + * @param branch The branch name to set. + */ + setBranch?(sessionId: string, branch: string): void; + /** * Archive a session. * @param sessionId The ID of the session to archive. diff --git a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts index eb107d397654ad..81d661020ee7ad 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionGroupsService.test.ts @@ -39,7 +39,7 @@ function createSession(id: string): ISession { lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue<readonly IChat[]>(`chats-${id}`, []), mainChat: constObservable<IChat>(undefined!), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } @@ -104,6 +104,29 @@ suite('SessionGroupsService', () => { assert.deepStrictEqual(service.getSessionIdsInGroup(b.id), ['s1']); }); + test('addToGroup adds multiple sessions in a single change event', () => { + const a = service.createGroup('A'); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.addToGroup(['s1', 's2', 's3'], a.id); + + assert.deepStrictEqual( + [service.getSessionIdsInGroup(a.id), changeCount], + [['s1', 's2', 's3'], 1] + ); + }); + + test('addToGroup with multiple sessions does not fire when none change', () => { + const a = service.createGroup('A', ['s1', 's2']); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.addToGroup(['s1', 's2'], a.id); + + assert.strictEqual(changeCount, 0); + }); + test('remove from group clears membership', () => { const a = service.createGroup('A', ['s1', 's2']); service.removeFromGroup('s1'); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts index 36994f659561d2..3697dc4602591b 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionNavigation.test.ts @@ -13,7 +13,7 @@ import { NullLogService } from '../../../../../platform/log/common/log.js'; import { InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { IActiveSession, ICreateNewSessionOptions, IProviderSessionType, IRecentlyOpenedSessions, ISessionsManagementService } from '../../common/sessionsManagement.js'; -import { IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; import { SessionsNavigation } from '../../browser/sessionNavigation.js'; import { SessionsRecencyHistory } from '../../browser/sessionsRecencyHistory.js'; import { Event } from '../../../../../base/common/event.js'; @@ -31,6 +31,7 @@ const stubChat = { mode: constObservable(undefined), isArchived: constObservable(false), isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), description: constObservable(undefined), lastTurnEnd: constObservable(undefined), }; @@ -48,6 +49,7 @@ function stubChatWithId(id: string, status: SessionStatus = SessionStatus.Comple mode: constObservable(undefined), isArchived: constObservable(false), isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), description: constObservable(undefined), lastTurnEnd: constObservable(undefined), }; @@ -77,7 +79,7 @@ function stubSession(id: string, status: SessionStatus = SessionStatus.Completed lastTurnEnd: constObservable(undefined), chats: constObservable(sessionChats), mainChat: constObservable(sessionChats[0]), - capabilities: { supportsMultipleChats: chats !== undefined && chats.length > 1 }, + capabilities: constObservable({ supportsMultipleChats: chats !== undefined && chats.length > 1 }), }; } @@ -123,6 +125,9 @@ class MockSessionStore implements ISessionsManagementService { activeChat: observableValue<IChat>(`test.activeChat-${session.sessionId}`, activeChat), openChats: session.chats, closedChats: constObservable([]), + lastClosedChat: undefined, + visibleChatTabs: session.chats, + shouldShowChatTabs: constObservable(false), }; this.activeSession.set(active, undefined); } else { @@ -165,6 +170,7 @@ class MockSessionStore implements ISessionsManagementService { getAllSessionTypes(): ISessionType[] { return []; } getSessionTypesForFolder(_folderUri: URI): IProviderSessionType[] { return []; } + getQuickChatSessionTypes(): IProviderSessionType[] { return []; } resolveWorkspace(_folderUri: URI): { providerId: string; workspace: ISessionWorkspace } | undefined { return undefined; } async openSession(sessionResource: URI): Promise<void> { @@ -196,12 +202,13 @@ class MockSessionStore implements ISessionsManagementService { } restoreVisibleSessions(): Promise<void> { throw new Error('not implemented'); } createNewSession(_folderUri: URI, _options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } + createQuickChat(_options?: ICreateNewSessionOptions): ISession { throw new Error('not implemented'); } createNewChatInSession(_session: ISession): Promise<IChat | undefined> { throw new Error('not implemented'); } forkChatInSession(_session: ISession, _sourceChat: URI, _turnId: string): Promise<IChat> { throw new Error('not implemented'); } discardNewSession(): void { throw new Error('not implemented'); } unsetNewSession(): void { throw new Error('not implemented'); } sendNewChatRequest(_session: ISession, _options: ISendRequestOptions): Promise<void> { throw new Error('not implemented'); } - createAndSendNewChatRequest(_folderUri: URI, _options: ISendRequestOptions, _createOptions?: ICreateNewSessionOptions): Promise<void> { throw new Error('not implemented'); } + createAndSendNewChatRequest(_folderUri: URI, _options: ISendRequestOptions, _createOptions?: ICreateNewSessionOptions): Promise<ISession | undefined> { throw new Error('not implemented'); } sendRequest(_session: ISession, _chat: IChat, _options: ISendRequestOptions): Promise<void> { throw new Error('not implemented'); } openNewChatInSession(_session: ISession): Promise<void> { throw new Error('not implemented'); } openPreviousSession(): Promise<void> { throw new Error('not implemented'); } diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts index b2af6a14839fe6..2fa31a32eb9ce1 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsListModelService.test.ts @@ -45,7 +45,7 @@ function createSession(id: string, status: SessionStatus = SessionStatus.Complet lastTurnEnd: observableValue(`lastTurnEnd-${id}`, undefined), chats: observableValue<readonly IChat[]>(`chats-${id}`, []), mainChat: constObservable<IChat>(undefined!), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } @@ -120,6 +120,34 @@ suite('SessionsListModelService', () => { assert.strictEqual(service.isSessionPinned(s2), false); }); + test('unpinSessions unpins multiple sessions and fires once', () => { + const s1 = createSession('s1'); + const s2 = createSession('s2'); + const s3 = createSession('s3'); + service.pinSession(s1); + service.pinSession(s2); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.unpinSessions([s1, s2, s3]); + + assert.deepStrictEqual( + [service.isSessionPinned(s1), service.isSessionPinned(s2), changeCount], + [false, false, 1] + ); + }); + + test('unpinSessions does not fire when none are pinned', () => { + const s1 = createSession('s1'); + const s2 = createSession('s2'); + let changeCount = 0; + disposables.add(service.onDidChange(() => changeCount++)); + + service.unpinSessions([s1, s2]); + + assert.strictEqual(changeCount, 0); + }); + // -- Read/Unread -- test('markRead marks session as read', () => { @@ -362,7 +390,8 @@ suite('SessionsListModelService', () => { service.markRead(session); // Make session the active one - activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]) }, undefined); + // Make session the active one + activeSession.set({ ...session, activeChat: constObservable(session.mainChat.get()), isCreated: constObservable(true), sticky: constObservable(false), openChats: session.chats, closedChats: constObservable([]), lastClosedChat: undefined, visibleChatTabs: session.chats, shouldShowChatTabs: constObservable(false) }, undefined); // Seed the last-known status as InProgress sessionsChangedEmitter.fire({ added: [], removed: [], changed: [session] }); diff --git a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts index 45ed4a498abd0d..af94e90a61efc9 100644 --- a/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/sessionsManagementService.test.ts @@ -23,11 +23,11 @@ import { IChatService } from '../../../../../workbench/contrib/chat/common/chatS import { IChatEditorOptions } from '../../../../../workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.js'; import { IChatWidgetHistoryService } from '../../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; import { PreferredGroup } from '../../../../../workbench/services/editor/common/editorService.js'; -import { IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; +import { ChatInteractivity, IChat, ISession, ISessionType, ISessionWorkspace, SessionStatus } from '../../common/session.js'; import { ILanguageModelChatMetadataAndIdentifier } from '../../../../../workbench/contrib/chat/common/languageModels.js'; import { ISessionChangeEvent, ISendRequestOptions, ISessionModelPickerOptions, ISessionsProvider } from '../../common/sessionsProvider.js'; import { SessionsManagementService } from '../../browser/sessionsManagementService.js'; -import { ISessionsManagementService } from '../../common/sessionsManagement.js'; +import { ISessionsManagementService, ICreateNewSessionOptions } from '../../common/sessionsManagement.js'; import { SessionsService } from '../../browser/sessionsService.js'; import { ISessionsPartService } from '../../browser/sessionsPartService.js'; import { ISessionsProvidersService } from '../../browser/sessionsProvidersService.js'; @@ -45,6 +45,7 @@ const stubChat = { mode: constObservable(undefined), isArchived: constObservable(false), isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), description: constObservable(undefined), lastTurnEnd: constObservable(undefined), } satisfies IChat; @@ -70,7 +71,7 @@ function stubSession(overrides: Partial<ISession> & Pick<ISession, 'sessionId' | lastTurnEnd: constObservable(undefined), chats: constObservable([]), mainChat: constObservable(stubChat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), ...overrides, }; } @@ -149,7 +150,7 @@ class TestSessionsProvider extends mock<ISessionsProvider>() { override getModels(): readonly ILanguageModelChatMetadataAndIdentifier[] { return []; } override getModelPickerOptions(): ISessionModelPickerOptions { return { useGroupedModelPicker: true, showFeatured: true, showUnavailableFeatured: false, showManageModelsAction: false }; } override readonly onDidChangeModels = Event.None; - override setModel(): void { } + override setModel(_sessionId: string, _modelId: string): void { } override async archiveSession(): Promise<void> { } override async unarchiveSession(): Promise<void> { } override async deleteSession(): Promise<void> { } @@ -828,6 +829,93 @@ suite('SessionsManagementService', () => { assert.strictEqual(view.activeSession.get(), undefined); }); + test('createAndSendNewChatRequest invokes configuration setters from createOptions', async () => { + const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + chats: constObservable([chat]), + mainChat: constObservable(chat), + }); + const calls: string[] = []; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override setModel(_sessionId: string, _modelId: string): void { calls.push(`setModel:${_modelId}`); } + override setMode(_sessionId: string, _modeId: string): void { calls.push(`setMode:${_modeId}`); } + override setPermissionLevel(_sessionId: string, _level: string): void { calls.push(`setPermissionLevel:${_level}`); } + override setIsolationMode(_sessionId: string, _mode: string): void { calls.push(`setIsolationMode:${_mode}`); } + override setBranch(_sessionId: string, _branch: string): void { calls.push(`setBranch:${_branch}`); } + override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> { return session; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + const createOptions: ICreateNewSessionOptions = { + modelId: 'gpt-4o', + modeId: 'agent', + permissionLevel: 'allowedTools', + isolationMode: 'worktree', + branch: 'main', + }; + const result = await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, createOptions); + + assert.strictEqual(result?.sessionId, 's1'); + assert.deepStrictEqual(calls, [ + 'setModel:gpt-4o', + 'setMode:agent', + 'setPermissionLevel:allowedTools', + 'setIsolationMode:worktree', + 'setBranch:main', + ]); + }); + + test('createAndSendNewChatRequest disposes stranded draft when a setter throws', async () => { + const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + chats: constObservable([chat]), + mainChat: constObservable(chat), + }); + let deleted = false; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override setModel(): void { throw new Error('model not found'); } + override deleteNewSession(): void { deleted = true; } + override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> { return session; } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + + await assert.rejects( + () => service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }, { modelId: 'bad' }), + /model not found/, + ); + assert.strictEqual(deleted, true); + }); + + test('createAndSendNewChatRequest returns undefined when service is disposed mid-send', async () => { + const chat: IChat = { ...stubChat, resource: URI.parse('test:///chat') }; + const session = stubSession({ + sessionId: 's1', + providerId: 'test', + chats: constObservable([chat]), + mainChat: constObservable(chat), + }); + const serviceRef: { current?: ISessionsManagementService } = {}; + const provider = new class extends TestSessionsProvider { + override resolveWorkspace(): ISessionWorkspace { return { folderUri: URI.parse('test:///folder') } as unknown as ISessionWorkspace; } + override async sendRequest(_sessionId: string, _chatResource: URI, _options: ISendRequestOptions): Promise<ISession> { + // Dispose the service while the send is in-flight. + (serviceRef.current as unknown as { dispose(): void }).dispose(); + return session; + } + }(session); + const { service } = createSessionsManagementService(session, disposables, provider); + serviceRef.current = service; + + const result = await service.createAndSendNewChatRequest(URI.parse('test:///folder'), { query: 'hi' }); + assert.strictEqual(result, undefined); + }); + test('discardNewSession fires onDidDiscardNewSession with the discarded draft', () => { const session = stubSession({ sessionId: 's1', providerId: 'test' }); const provider = new class extends TestSessionsProvider { @@ -1134,7 +1222,7 @@ suite('SessionsManagementService', () => { test('asks the provider to fork the chat when the session supports multiple chats', async () => { const sourceChat = URI.parse('test:///source'); const forkedChat: IChat = { ...stubChat, resource: URI.parse('test:///forked') }; - const session = stubSession({ sessionId: 'fork', providerId: 'test', capabilities: { supportsMultipleChats: true } }); + const session = stubSession({ sessionId: 'fork', providerId: 'test', capabilities: constObservable({ supportsMultipleChats: true }) }); let forkChatArgs: readonly [string, URI, string] | undefined; const provider = new class extends TestSessionsProvider { constructor() { super(session); } @@ -1157,7 +1245,7 @@ suite('SessionsManagementService', () => { }); test('throws when the provider is not found', async () => { - const session = stubSession({ sessionId: 'orphan', providerId: 'missing-provider', capabilities: { supportsMultipleChats: true } }); + const session = stubSession({ sessionId: 'orphan', providerId: 'missing-provider', capabilities: constObservable({ supportsMultipleChats: true }) }); const provider = new TestSessionsProvider(stubSession({ sessionId: 'other', providerId: 'test' })); const { service } = createSessionsManagementService(session, disposables, provider); @@ -1165,7 +1253,7 @@ suite('SessionsManagementService', () => { }); test('throws when the session does not support multiple chats', async () => { - const session = stubSession({ sessionId: 'single-chat', providerId: 'test', capabilities: { supportsMultipleChats: false } }); + const session = stubSession({ sessionId: 'single-chat', providerId: 'test', capabilities: constObservable({ supportsMultipleChats: false }) }); const { service } = createSessionsManagementService(session, disposables); await assert.rejects(() => service.forkChatInSession(session, URI.parse('test:///source'), 'turn-1'), /does not support forking into a chat/); @@ -1184,7 +1272,7 @@ suite('SessionsManagementService', () => { providerId: 'test', chats: constObservable(chats), mainChat: constObservable(chats[0]), - capabilities: { supportsMultipleChats: true }, + capabilities: constObservable({ supportsMultipleChats: true }), }); } @@ -1273,6 +1361,267 @@ suite('SessionsManagementService', () => { assert.deepStrictEqual(closedTitles(view), []); }); + + test('a closed chat stays closed across a restart', async () => { + const mainA = chat('mainA'); + const chatB = chat('b'); + const sessionA = stubSession({ + sessionId: 'A', providerId: 'test', + status: constObservable(SessionStatus.Completed), + chats: constObservable([mainA, chatB]), + mainChat: constObservable(mainA), + capabilities: constObservable({ supportsMultipleChats: true }), + }); + const storage = disposables.add(new InMemoryStorageService()); + const provider = new class extends TestSessionsProvider { + constructor() { super(sessionA); } + override getSessions(): ISession[] { return [sessionA]; } + }; + const makeView = () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock<IChatService>() { + override readonly onDidSubmitRequest = Event.None; + }); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + return createView(instantiationService, service, disposables); + }; + + // First window: close chat B, then simulate shutdown (flush storage). + const first = makeView(); + await first.openSession(sessionA.resource); + await first.closeChat(first.activeSession.get()!, chatB); + await storage.flush(); + + // Second window: restore and confirm B is still closed. + const second = makeView(); + await second.restoreVisibleSessions(); + assert.deepStrictEqual((second.activeSession.get()?.closedChats.get() ?? []).map(c => c.title.get()), ['b']); + }); + + test('a chat closed in a non-active session stays closed across a restart', async () => { + const mainA = chat('mainA'); + const chatA2 = chat('a2'); + const sessionA = stubSession({ + sessionId: 'A', providerId: 'test', + status: constObservable(SessionStatus.Completed), + chats: constObservable([mainA, chatA2]), + mainChat: constObservable(mainA), + capabilities: constObservable({ supportsMultipleChats: true }), + }); + const mainB = chat('mainB'); + const chatB2 = chat('b2'); + const sessionB = stubSession({ + sessionId: 'B', providerId: 'test', + status: constObservable(SessionStatus.Completed), + chats: constObservable([mainB, chatB2]), + mainChat: constObservable(mainB), + capabilities: constObservable({ supportsMultipleChats: true }), + }); + const storage = disposables.add(new InMemoryStorageService()); + const provider = new class extends TestSessionsProvider { + constructor() { super(sessionA); } + override getSessions(): ISession[] { return [sessionA, sessionB]; } + }; + const makeView = () => { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, storage); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService([provider])); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock<IChatService>() { + override readonly onDidSubmitRequest = Event.None; + }); + const service = disposables.add(instantiationService.createInstance(SessionsManagementService)); + return createView(instantiationService, service, disposables); + }; + + // First window: close a chat in each session, end on session A so B is + // no longer visible, then simulate shutdown (flush storage). + const first = makeView(); + await first.openSession(sessionB.resource); + await first.closeChat(first.activeSession.get()!, chatB2); + await first.openSession(sessionA.resource); + await first.closeChat(first.activeSession.get()!, chatA2); + await storage.flush(); + + // Second window: restore, then switch to B and confirm its chat is still closed. + const second = makeView(); + await second.restoreVisibleSessions(); + await second.openSession(sessionB.resource); + assert.deepStrictEqual((second.activeSession.get()?.closedChats.get() ?? []).map(c => c.title.get()), ['b2']); + }); + }); + + suite('createQuickChat', () => { + + /** + * Provider that supports quick chats and mints a fresh draft session on + * each `createQuickChat`, recording the requested type and call count. + */ + class QuickChatProvider extends TestSessionsProvider { + lastQuickChatType: string | undefined; + createQuickChatCalls = 0; + override readonly supportsQuickChats = true; + + constructor( + seed: ISession, + override readonly id: string = 'quick-provider', + override readonly order: number = 0, + override readonly sessionTypes: readonly ISessionType[] = [{ id: 'quick', label: 'Quick', icon: Codicon.vm }], + ) { + super(seed); + } + + override createQuickChat(sessionTypeId: string): ISession { + this.createQuickChatCalls++; + this.lastQuickChatType = sessionTypeId; + return stubSession({ sessionId: `q${this.createQuickChatCalls}`, providerId: this.id }); + } + } + + function setupQuickChat(providers: readonly ISessionsProvider[]): ISessionsManagementService { + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IContextKeyService, disposables.add(new MockContextKeyService())); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService(providers)); + instantiationService.stub(IUriIdentityService, { extUri: extUriBiasedIgnorePathCase }); + instantiationService.stub(IChatWidgetService, new TestChatWidgetService()); + instantiationService.stub(IProgressService, new TestProgressService()); + instantiationService.stub(IChatService, new class extends mock<IChatService>() { + override readonly onDidSubmitRequest = Event.None; + }); + return disposables.add(instantiationService.createInstance(SessionsManagementService)); + } + + test('creates a session via the first capable provider (by order) and defaults the type', () => { + const plain = new class extends TestSessionsProvider { + override readonly id = 'plain'; + override readonly order = 0; + }(stubSession({ sessionId: 'p1', providerId: 'plain' })); + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' }), 'quick-provider', 1); + + const service = setupQuickChat([plain, quick]); + const session = service.createQuickChat(); + + assert.deepStrictEqual({ + createdSessionId: session.sessionId, + requestedType: quick.lastQuickChatType, + draft: service.newSession.get()?.sessionId, + }, { + createdSessionId: 'q1', + requestedType: 'quick', + draft: 'q1', + }); + }); + + test('mints a new quick-chat session on each call', () => { + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' })); + + const service = setupQuickChat([quick]); + const first = service.createQuickChat(); + const second = service.createQuickChat(); + + assert.deepStrictEqual({ + first: first.sessionId, + second: second.sessionId, + createQuickChatCalls: quick.createQuickChatCalls, + draft: service.newSession.get()?.sessionId, + }, { + first: 'q1', + second: 'q2', + createQuickChatCalls: 2, + draft: 'q2', + }); + }); + + test('throws when no provider supports quick chats', () => { + const plain = new TestSessionsProvider(stubSession({ sessionId: 'p1', providerId: 'test' })); + const service = setupQuickChat([plain]); + assert.throws(() => service.createQuickChat(), /No sessions provider supports quick chats/); + }); + + test('honours options.providerId and the requested session type', () => { + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' }), 'quick-provider', 0, [ + { id: 'quick', label: 'Quick', icon: Codicon.vm }, + { id: 'other', label: 'Other', icon: Codicon.vm }, + ]); + + const service = setupQuickChat([quick]); + service.createQuickChat({ providerId: 'quick-provider', sessionTypeId: 'other' }); + + assert.strictEqual(quick.lastQuickChatType, 'other'); + }); + + test('honours an explicit sessionTypeId without a providerId', () => { + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' }), 'quick-provider', 0, [ + { id: 'quick', label: 'Quick', icon: Codicon.vm }, + { id: 'other', label: 'Other', icon: Codicon.vm }, + ]); + + const service = setupQuickChat([quick]); + service.createQuickChat({ sessionTypeId: 'other' }); + + assert.strictEqual(quick.lastQuickChatType, 'other'); + }); + + test('defaults to the last-used session type on the next call', () => { + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' }), 'quick-provider', 0, [ + { id: 'quick', label: 'Quick', icon: Codicon.vm }, + { id: 'other', label: 'Other', icon: Codicon.vm }, + ]); + + const service = setupQuickChat([quick]); + service.createQuickChat({ sessionTypeId: 'other' }); + service.createQuickChat(); + + assert.strictEqual(quick.lastQuickChatType, 'other'); + }); + + test('throws when the requested provider does not advertise the session type', () => { + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' })); + const service = setupQuickChat([quick]); + assert.throws(() => service.createQuickChat({ providerId: 'quick-provider', sessionTypeId: 'missing' }), /does not advertise session type/); + }); + + test('throws when the requested provider does not support quick chats', () => { + const plain = new class extends TestSessionsProvider { + override readonly id = 'plain'; + }(stubSession({ sessionId: 'p1', providerId: 'plain' })); + const service = setupQuickChat([plain]); + assert.throws(() => service.createQuickChat({ providerId: 'plain' }), /does not support quick chats/); + }); + + test('getQuickChatSessionTypes returns every advertised type from quick-chat-capable providers only', () => { + const plain = new class extends TestSessionsProvider { + override readonly id = 'plain'; + override readonly order = 0; + }(stubSession({ sessionId: 'p1', providerId: 'plain' })); + const quick = new QuickChatProvider(stubSession({ sessionId: 'seed', providerId: 'quick-provider' }), 'quick-provider', 1, [ + { id: 'quick', label: 'Quick', icon: Codicon.vm }, + { id: 'other', label: 'Other', icon: Codicon.vm }, + ]); + + const service = setupQuickChat([plain, quick]); + + assert.deepStrictEqual( + service.getQuickChatSessionTypes().map(t => ({ providerId: t.providerId, sessionTypeId: t.sessionType.id })), + [ + { providerId: 'quick-provider', sessionTypeId: 'quick' }, + { providerId: 'quick-provider', sessionTypeId: 'other' }, + ], + ); + }); }); }); diff --git a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts index cb67a82dd90475..7e055a99c0abde 100644 --- a/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts +++ b/src/vs/sessions/services/sessions/test/browser/visibleSessions.test.ts @@ -12,7 +12,7 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { VisibleSession, VisibleSessions } from '../../browser/visibleSessions.js'; -import { IChat, ISession } from '../../common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISession, SessionStatus } from '../../common/session.js'; const stubChat: IChat = { resource: URI.parse('test:///chat'), @@ -26,6 +26,7 @@ const stubChat: IChat = { mode: constObservable(undefined), isArchived: constObservable(false), isRead: constObservable(true), + interactivity: constObservable(ChatInteractivity.Full), description: constObservable(undefined), lastTurnEnd: constObservable(undefined), }; @@ -53,7 +54,7 @@ function stubSession(sessionId: string): ISession { lastTurnEnd: constObservable(undefined), chats: constObservable([stubChat]), mainChat: constObservable(stubChat), - capabilities: { supportsMultipleChats: false }, + capabilities: constObservable({ supportsMultipleChats: false }), }; } @@ -914,6 +915,10 @@ suite('VisibleSession - open/close chats', () => { return { ...stubChat, resource: URI.parse(`test:///chat/${id}`), title: constObservable(id) }; } + function makeChatWith(id: string, interactivity: ChatInteractivity): IChat { + return { ...makeChat(id), interactivity: constObservable(interactivity) }; + } + function createSession(chats: IChat[], initialClosedChatUris?: Iterable<string>, initialActiveChat?: IChat) { const chatsObs = observableValue<readonly IChat[]>('chats', chats); const base = stubSession('S'); @@ -1022,4 +1027,235 @@ suite('VisibleSession - open/close chats', () => { active: 'main', }); }); + + test('lastClosedChat returns the most recently closed chat regardless of creation order', () => { + // B was created before C, but if C is closed first and then B, + // lastClosedChat should return B (not C, which closedChats.at(-1) would wrongly give). + const [main, b, c] = [makeChat('main'), makeChat('b'), makeChat('c')]; + const { visible } = createSession([main, b, c]); + + visible.closeChat(c); // close C first + visible.closeChat(b); // close B last + + assert.strictEqual(visible.lastClosedChat?.title.get(), 'b'); + }); + + test('lastClosedChat updates after reopening the last-closed chat', () => { + const [main, b, c] = [makeChat('main'), makeChat('b'), makeChat('c')]; + const { visible } = createSession([main, b, c]); + + visible.closeChat(b); + visible.closeChat(c); + assert.strictEqual(visible.lastClosedChat?.title.get(), 'c'); + + visible.openChat(c); // reopen C — B is now the last closed + assert.strictEqual(visible.lastClosedChat?.title.get(), 'b'); + }); + + test('lastClosedChat returns undefined when no chats are closed', () => { + const [main, b] = [makeChat('main'), makeChat('b')]; + const { visible } = createSession([main, b]); + + assert.strictEqual(visible.lastClosedChat, undefined); + }); + + test('lastClosedChat skips deleted chats and returns the next valid one', () => { + const [main, b, c] = [makeChat('main'), makeChat('b'), makeChat('c')]; + const { visible, chatsObs } = createSession([main, b, c]); + + visible.closeChat(b); + visible.closeChat(c); + // Delete C from the session (simulates permanent deletion) + chatsObs.set([main, b], undefined); + + // C is gone; lastClosedChat should fall back to B + assert.strictEqual(visible.lastClosedChat?.title.get(), 'b'); + }); + + test('hidden chats are excluded from the tab strip but read-only chats are not', () => { + const main = makeChatWith('main', ChatInteractivity.Full); + const readOnly = makeChatWith('ro', ChatInteractivity.ReadOnly); + const hidden = makeChatWith('hidden', ChatInteractivity.Hidden); + const { visible, ids } = createSession([main, readOnly, hidden]); + + assert.deepStrictEqual(snapshot(visible, ids), { + open: ['main', 'ro'], + closed: [], + active: 'main', + }); + }); +}); + +suite('VisibleSession - visibleChatTabs', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, status = SessionStatus.Completed, origin?: ChatOriginKind): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + status: constObservable(status), + origin: origin ? { kind: origin } : undefined, + }; + } + + function createSession(chats: IChat[]) { + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable(chats), mainChat: constObservable(chats[0]) }; + return disposables.add(new VisibleSession(session, chats[0])); + } + + test('keeps provider order and hides tool-origin (subagent) chats by default', () => { + const visible = createSession([ + makeChat('main'), + makeChat('draft', SessionStatus.Untitled), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + makeChat('second'), + ]); + + // Subagent (tool-origin) chats are hidden from the tab strip by default. + assert.deepStrictEqual(visible.visibleChatTabs.get().map(c => c.title.get()), ['main', 'draft', 'second']); + }); + + test('surfaces a subagent tab once it is explicitly opened, and hides it again on close', () => { + const chats = [ + makeChat('main'), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + ]; + const visible = createSession(chats); + const tool = chats[1]; + + visible.openChat(tool); + const afterOpen = visible.visibleChatTabs.get().map(c => c.title.get()); + visible.closeChat(tool); + const afterClose = visible.visibleChatTabs.get().map(c => c.title.get()); + + assert.deepStrictEqual({ afterOpen, afterClose }, { + afterOpen: ['main', 'tool'], + afterClose: ['main'], + }); + }); + + test('a closed subagent tab is not added to the reopenable closed chats', () => { + const chats = [ + makeChat('main'), + makeChat('tool', SessionStatus.Completed, ChatOriginKind.Tool), + ]; + const visible = createSession(chats); + const tool = chats[1]; + + visible.openChat(tool); + visible.closeChat(tool); + + assert.deepStrictEqual(visible.closedChats.get().map(c => c.title.get()), []); + }); +}); + +suite('VisibleSession - shouldShowChatTabs', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, title: string, origin?: ChatOriginKind): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(title), + status: constObservable(SessionStatus.Completed), + origin: origin ? { kind: origin } : undefined, + }; + } + + function createSession(sessionTitle: string, chats: IChat[]) { + const base = stubSession('S'); + const session: ISession = { ...base, title: constObservable(sessionTitle), chats: constObservable(chats), mainChat: constObservable(chats[0]) }; + return disposables.add(new VisibleSession(session, chats[0])); + } + + test('hidden for a single chat matching the session title', () => { + const visible = createSession('Title', [makeChat('main', 'Title')]); + assert.strictEqual(visible.shouldShowChatTabs.get(), false); + }); + + test('shown for a single chat whose title diverged from the session title', () => { + const visible = createSession('Session Title', [makeChat('main', 'Chat Title')]); + assert.strictEqual(visible.shouldShowChatTabs.get(), true); + }); + + test('shown for more than one chat even if a chat title matches the session title', () => { + const visible = createSession('main', [makeChat('main', 'main'), makeChat('second', 'second')]); + assert.strictEqual(visible.shouldShowChatTabs.get(), true); + }); + + test('shown for a single non-tool chat matching the session title when it has a subagent', () => { + const visible = createSession('Title', [ + makeChat('main', 'Title'), + makeChat('tool', 'tool', ChatOriginKind.Tool), + ]); + // The strip is shown as soon as the session has any subagent, so the + // Conversations menu (which lists subagents) surfaces in the tab bar. + assert.strictEqual(visible.shouldShowChatTabs.get(), true); + }); + + test('hidden when there are no tab chats', () => { + const main = makeChat('main', 'Title'); + const base = stubSession('S'); + const session: ISession = { ...base, title: constObservable('Title'), chats: constObservable<readonly IChat[]>([]), mainChat: constObservable(main) }; + const visible = disposables.add(new VisibleSession(session, main)); + assert.strictEqual(visible.shouldShowChatTabs.get(), false); + }); + + test('stays shown after a non-main chat is closed back down to a single open chat', () => { + const main = makeChat('main', 'Title'); + const second = makeChat('second', 'second'); + const visible = createSession('Title', [main, second]); + + assert.strictEqual(visible.shouldShowChatTabs.get(), true); + assert.strictEqual(visible.visibleChatTabs.get().length, 2); + + visible.closeChat(second); + + assert.deepStrictEqual({ + shouldShowChatTabs: visible.shouldShowChatTabs.get(), + visibleChatTabs: visible.visibleChatTabs.get().map(c => c.title.get()), + }, { + shouldShowChatTabs: true, + visibleChatTabs: ['Title'], + }); + }); +}); + +suite('VisibleSession - per-chat model/mode', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function makeChat(id: string, modelId: string | undefined, modeId: string | undefined): IChat { + return { + ...stubChat, + resource: URI.parse(`test:///chat/${id}`), + title: constObservable(id), + modelId: constObservable(modelId), + mode: constObservable(modeId ? { id: modeId, kind: 'agent' } : undefined), + }; + } + + test('modelId and mode follow the active chat, not the session/default chat', () => { + const first = makeChat('first', 'model-1', 'agent-1'); + const second = makeChat('second', 'model-2', 'agent-2'); + const base = stubSession('S'); + const session: ISession = { ...base, chats: constObservable([first, second]), mainChat: constObservable(first) }; + const visible = disposables.add(new VisibleSession(session, first)); + + assert.deepStrictEqual( + { modelId: visible.modelId.get(), mode: visible.mode.get() }, + { modelId: 'model-1', mode: { id: 'agent-1', kind: 'agent' } }, + ); + + visible.setActiveChat(second); + + assert.deepStrictEqual( + { modelId: visible.modelId.get(), mode: visible.mode.get() }, + { modelId: 'model-2', mode: { id: 'agent-2', kind: 'agent' } }, + ); + }); }); diff --git a/src/vs/sessions/services/sessions/test/common/session.test.ts b/src/vs/sessions/services/sessions/test/common/session.test.ts index 57bbfbe04560c1..648e46c89b1b2e 100644 --- a/src/vs/sessions/services/sessions/test/common/session.test.ts +++ b/src/vs/sessions/services/sessions/test/common/session.test.ts @@ -9,7 +9,7 @@ import { constObservable, IObservable } from '../../../../../base/common/observa import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { IChatSessionFileChange, IChatSessionFileChange2 } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; -import { IGitHubInfo, ISessionWorkspace, sessionFileChangesEqual, sessionWorkspaceEqual } from '../../common/session.js'; +import { getUntitledSessionTitle, IGitHubInfo, ISessionWorkspace, sessionFileChangesEqual, sessionWorkspaceEqual } from '../../common/session.js'; suite('sessionFileChangesEqual', () => { @@ -139,3 +139,16 @@ suite('sessionWorkspaceEqual', () => { assert.strictEqual(sessionWorkspaceEqual(workspace('main'), workspace('feature')), false); }); }); + +suite('getUntitledSessionTitle', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('returns "New Chat" for a quick chat', () => { + assert.strictEqual(getUntitledSessionTitle(true), 'New Chat'); + }); + + test('returns "New Session" for a non-quick-chat session', () => { + assert.strictEqual(getUntitledSessionTitle(false), 'New Session'); + }); +}); diff --git a/src/vs/sessions/sessions.common.main.ts b/src/vs/sessions/sessions.common.main.ts index c52f82dda0f814..71bd70b887c83d 100644 --- a/src/vs/sessions/sessions.common.main.ts +++ b/src/vs/sessions/sessions.common.main.ts @@ -143,6 +143,7 @@ import '../workbench/services/chat/common/chatEntitlementService.js'; import '../workbench/services/log/common/defaultLogLevels.js'; import '../workbench/services/agentHost/common/agentHostResourceService.js'; import '../platform/agentHost/browser/agentHostConnectionsService.js'; +import '../platform/agentHost/browser/agentHostEnablementService.js'; import './services/agentHost/browser/agentHostCustomizationService.js'; import { InstantiationType, registerSingleton } from '../platform/instantiation/common/extensions.js'; @@ -456,6 +457,7 @@ import './browser/layoutActions.js'; import './contrib/accountMenu/browser/account.contribution.js'; import './contrib/aiCustomizationTreeView/browser/aiCustomizationTreeView.contribution.js'; import './contrib/chat/browser/chat.contribution.js'; +import './contrib/promptTimeline/browser/promptTimeline.contribution.js'; import './contrib/providers/agentHost/browser/exportDebugLogsAction.js'; import './contrib/providers/agentHost/browser/agentHostSessionConfigPicker.js'; import './contrib/chat/browser/customizationsDebugLog.contribution.js'; @@ -478,10 +480,12 @@ import './contrib/browserView/browser/sessionBrowserView.contribution.js'; import './contrib/editor/browser/editor.contribution.js'; import './contrib/terminal/browser/sessionsTerminalContribution.js'; +import './contrib/terminal/browser/terminalMetaActions.js'; import './contrib/chatDebug/browser/chatDebug.contribution.js'; import './contrib/workspace/browser/workspace.contribution.js'; import './contrib/aquarium/browser/aquarium.contribution.js'; import './contrib/policyBlocked/browser/policyBlocked.contribution.js'; +import './contrib/automations/browser/automations.contribution.js'; // Onboarding: the engine + spotlight presentation (from the workbench layer) and // the Agents window scenario data. diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 88b1e76640cf16..57d2c28477e87e 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -230,6 +230,7 @@ import './contrib/providers/agentHost/browser/agentSessionSettings.contribution. import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js'; import './contrib/providers/agentHost/browser/agentHostSessionBranchActions.js'; import './contrib/providers/agentHost/browser/agentHostSkillButtons.js'; +import './contrib/providers/agentHost/browser/openSubagentChat.js'; import './contrib/providers/agentHost/electron-browser/agentHost.contribution.js'; // Tunnel Host (allow remote connections to local agent host) diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index a158a48b292f52..efbd95f9ab9c2a 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -19,7 +19,9 @@ import './sessions.common.main.js'; //#region --- workbench parts -import '../workbench/browser/parts/dialogs/dialog.web.contribution.js'; +// Agents window uses a phone-aware dialog handler (bottom sheets on phone) +// in place of the standard web dialog handler. +import './browser/parts/dialogs/mobileDialog.web.contribution.js'; //#endregion @@ -168,6 +170,7 @@ import './contrib/providers/agentHost/browser/agentSessionSettings.contribution. import './contrib/providers/agentHost/browser/agentHostSettings.contribution.js'; import './contrib/providers/agentHost/browser/agentHostSessionBranchActions.js'; import './contrib/providers/agentHost/browser/agentHostSkillButtons.js'; +import './contrib/providers/agentHost/browser/openSubagentChat.js'; // Host filter dropdown in the titlebar (scopes the sessions list to a host) import './contrib/providers/remoteAgentHost/browser/hostFilter.contribution.js'; diff --git a/src/vs/sessions/test/browser/layoutActions.test.ts b/src/vs/sessions/test/browser/layoutActions.test.ts index 09cc84f1b10afe..984822ffdb2934 100644 --- a/src/vs/sessions/test/browser/layoutActions.test.ts +++ b/src/vs/sessions/test/browser/layoutActions.test.ts @@ -10,7 +10,9 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/comm import { isIMenuItem, MenuId, MenuRegistry } from '../../../platform/actions/common/actions.js'; import { CommandsRegistry } from '../../../platform/commands/common/commands.js'; import { ToggleAuxiliaryBarAction } from '../../../workbench/browser/parts/auxiliarybar/auxiliaryBarActions.js'; +import { MainEditorAreaVisibleContext } from '../../../workbench/common/contextkeys.js'; import { Menus } from '../../browser/menus.js'; +import { SinglePaneDetailChangesOrFilesActiveContext } from '../../common/contextkeys.js'; // Import layout actions to trigger menu registration import '../../browser/layoutActions.js'; @@ -29,24 +31,46 @@ suite('Sessions - Layout Actions', () => { assert.strictEqual(toggleAlwaysOnTop.group, 'navigation'); }); - test('auxiliary bar toggle reuses the core command with state-dependent icons on the editor title', () => { - // The editor-title menu items reference the core toggle command rather than registering - // their own; assert it is actually registered so the contribution cannot silently break. + test('original-layout auxiliary bar toggle reuses the core command with state-dependent icons on the editor title layout menu', () => { + // The original (non-single-pane) editor-title menu items reference the core toggle command + // rather than registering their own; assert it is actually registered so the contribution + // cannot silently break. (The single-pane "Toggle Details" item is a dedicated command + // registered by SinglePaneLayoutController and is asserted in its own suite.) assert.ok(CommandsRegistry.getCommand(ToggleAuxiliaryBarAction.ID), 'core toggle auxiliary bar command should be registered'); - const auxiliaryBarToggles = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) + // Original layout: two mutually-exclusive right-panel icons on the layout group. + const layoutToggleIcons = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) .filter(isIMenuItem) .filter(item => item.command.id === ToggleAuxiliaryBarAction.ID) - .map(item => ({ - group: item.group, - order: item.order, - icon: ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined, - })) - .sort((a, b) => (a.icon ?? '').localeCompare(b.icon ?? '')); - - assert.deepStrictEqual(auxiliaryBarToggles, [ - { group: 'navigation', order: 99.5, icon: Codicon.rightPanelHide.id }, - { group: 'navigation', order: 99.5, icon: Codicon.rightPanelShow.id }, - ]); + .map(item => ThemeIcon.isThemeIcon(item.command.icon) ? item.command.icon.id : undefined) + .sort((a, b) => (a ?? '').localeCompare(b ?? '')); + assert.deepStrictEqual(layoutToggleIcons, [Codicon.rightPanelHide.id, Codicon.rightPanelShow.id]); + }); + + test('single-pane editor layout actions render in the layout cluster ordered hide, then maximize/restore', async () => { + await import('../../contrib/editor/browser/editor.contribution.js'); + + // Single-pane layout entries live on the shared editor-title layout menu (so + // they render after the editor-title actions, like the classic layout) and are + // distinguished from the classic entries by the MainEditorAreaVisibleContext gate. + const layoutItems = MenuRegistry.getMenuItems(MenuId.EditorTitleLayout) + .filter(isIMenuItem) + .filter(item => (item.when?.serialize() ?? '').includes(MainEditorAreaVisibleContext.key)); + const groupOrder = (id: string) => layoutItems + .filter(item => item.command.id === id) + .map(item => ({ group: item.group, order: item.order })); + + assert.deepStrictEqual(groupOrder('workbench.action.agentSessions.hideMainEditorPart'), [{ group: 'navigation', order: 10 }]); + assert.deepStrictEqual(groupOrder('workbench.action.agentSessions.maximizeMainEditorPart'), [{ group: 'navigation', order: 20 }]); + assert.deepStrictEqual(groupOrder('workbench.action.agentSessions.restoreMainEditorPart'), [{ group: 'navigation', order: 20 }]); + + // Hide is additionally gated on the changes/files detail being active. + const hideWhen = layoutItems.find(item => item.command.id === 'workbench.action.agentSessions.hideMainEditorPart')?.when?.serialize() ?? ''; + assert.ok(hideWhen.includes(SinglePaneDetailChangesOrFilesActiveContext.key)); + + // Add File as Context stays an editor-title action, not a layout action. + const editorTitleIds = MenuRegistry.getMenuItems(Menus.SessionsEditorTitle).filter(isIMenuItem).map(item => item.command.id); + assert.ok(editorTitleIds.includes('workbench.action.agentSessions.addFileAsContext')); + assert.ok(!layoutItems.some(item => item.command.id === 'workbench.action.agentSessions.addFileAsContext')); }); }); diff --git a/src/vs/sessions/test/browser/resolveRemoteAuthority.test.ts b/src/vs/sessions/test/browser/resolveRemoteAuthority.test.ts index e1b9f6b944f16b..1d9701f92d4dd1 100644 --- a/src/vs/sessions/test/browser/resolveRemoteAuthority.test.ts +++ b/src/vs/sessions/test/browser/resolveRemoteAuthority.test.ts @@ -5,10 +5,9 @@ import assert from 'assert'; import { decodeHex } from '../../../base/common/buffer.js'; -import { URI } from '../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; import { IRemoteAgentHostEntry, IRemoteAgentHostService, getEntryAddress, RemoteAgentHostEntryType } from '../../../platform/agentHost/common/remoteAgentHostService.js'; -import { getOpenInVSCodeUri, getVSCodeProtocolScheme, resolveRemoteAuthority, sshAuthorityString } from '../../browser/openInVSCodeUtils.js'; +import { resolveRemoteAuthority, sshAuthorityString } from '../../browser/openInVSCodeUtils.js'; import { ISessionsProvidersService } from '../../services/sessions/browser/sessionsProvidersService.js'; suite('resolveRemoteAuthority', () => { @@ -232,56 +231,3 @@ suite('sshAuthorityString', () => { assert.strictEqual(result, 'actualhost'); }); }); - -suite('Open in Editor URI', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('preserves the session resource when opening a local workspace', () => { - const uri = getOpenInVSCodeUri( - getVSCodeProtocolScheme({ quality: 'insider', urlProtocol: 'vscode-insiders' }), - URI.file('/workspace'), - undefined, - URI.parse('agent-host-copilotcli:/session-id'), - ); - - assert.deepStrictEqual({ - scheme: uri.scheme, - authority: uri.authority, - path: uri.path, - params: Object.fromEntries(new URLSearchParams(uri.query)), - }, { - scheme: 'vscode-insiders', - authority: 'file', - path: '/workspace', - params: { - windowId: '_blank', - session: 'agent-host-copilotcli:/session-id', - }, - }); - }); - - test('uses the remote workspace protocol URI', () => { - const uri = getOpenInVSCodeUri( - getVSCodeProtocolScheme({ quality: 'stable', urlProtocol: 'vscode' }), - URI.file('/workspace'), - 'ssh-remote+host', - URI.parse('agent-host-copilotcli:/session-id'), - ); - - assert.deepStrictEqual({ - scheme: uri.scheme, - authority: uri.authority, - path: uri.path, - params: Object.fromEntries(new URLSearchParams(uri.query)), - }, { - scheme: 'vscode', - authority: 'vscode-remote', - path: '/ssh-remote+host/workspace', - params: { - windowId: '_blank', - session: 'agent-host-copilotcli:/session-id', - }, - }); - }); -}); diff --git a/src/vs/sessions/test/browser/workbench.test.ts b/src/vs/sessions/test/browser/workbench.test.ts index c0ca04011b5f34..310c22a87cddfa 100644 --- a/src/vs/sessions/test/browser/workbench.test.ts +++ b/src/vs/sessions/test/browser/workbench.test.ts @@ -4,102 +4,162 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { SashState } from '../../../base/browser/ui/sash/sash.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../base/test/common/utils.js'; +import { Part } from '../../../workbench/browser/part.js'; +import { IPartVisibilityChangeEvent, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; +import { DockedAuxiliaryBarController, IDockedAuxiliaryBarHost } from '../../browser/dockedAuxiliaryBarController.js'; import { Workbench } from '../../browser/workbench.js'; +import { DockedEditorSizeMemento, SinglePaneWorkbench } from '../../browser/singlePaneWorkbench.js'; -interface IWorkbenchTestHarness { - partVisibility: { - sidebar: boolean; - auxiliaryBar: boolean; - editor: boolean; - panel: boolean; - sessions: boolean; - }; - layoutPolicy: { - viewportClass: { - get(): 'phone' | 'tablet' | 'desktop'; - }; - }; - storageService: { - store(...args: unknown[]): void; - }; - _editorMaximized: boolean; - _restoreAttachedEditorMaximizedOnShow: boolean; - setEditorMaximized(maximized: boolean): void; - setAuxiliaryBarHidden(hidden: boolean): void; - _savePartVisibility(): void; -} +interface IViewSize { width: number; height: number } suite('Sessions - Workbench', () => { ensureNoDisposablesAreLeakedInTestSuite(); + // Real Workbench methods invoked against a prototype-chained fake harness so + // the protected layout hooks dispatch to the base (grid) or SinglePaneWorkbench + // (docked) override, exactly as at runtime. + const setEditorHidden = Reflect.get(Workbench.prototype, 'setEditorHidden') as (this: ITestWorkbench, hidden: boolean, explicit?: boolean) => void; + const setAuxiliaryBarHidden = Reflect.get(Workbench.prototype, 'setAuxiliaryBarHidden') as (this: ITestWorkbench, hidden: boolean) => void; + const setSideBarHidden = Reflect.get(Workbench.prototype, 'setSideBarHidden') as (this: ITestWorkbench, hidden: boolean) => void; + const handleDidCloseEditor = Reflect.get(Workbench.prototype, 'handleDidCloseEditor') as (this: ITestWorkbench) => void; + const setEditorMaximized = Reflect.get(Workbench.prototype, 'setEditorMaximized') as (this: IMaximizeTestHarness, maximized: boolean) => void; + const onEditorNodeResized = Reflect.get(SinglePaneWorkbench.prototype, '_onEditorNodeResized') as (this: ITestWorkbench, nodeWidth: number) => void; + const onGridDidChange = Reflect.get(SinglePaneWorkbench.prototype, '_onGridDidChange') as (this: ITestWorkbench) => void; + const persistedAuxiliaryBarWidth = Reflect.get(SinglePaneWorkbench.prototype, '_persistedAuxiliaryBarWidth') as (this: ITestWorkbench, gridWidth: number | undefined) => number | undefined; const rememberAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'rememberAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; const restoreAttachedEditorMaximizedState = Reflect.get(Workbench.prototype, 'restoreAttachedEditorMaximizedState') as (this: IWorkbenchTestHarness) => void; - const setAuxiliaryBarHidden = Reflect.get(Workbench.prototype, 'setAuxiliaryBarHidden') as (this: IWorkbenchTestHarness, hidden: boolean) => void; const loadPartVisibility = Reflect.get(Workbench.prototype, '_loadPartVisibility') as (this: IWorkbenchTestHarness, storageService: { get(): string | undefined; remove(): void }) => { editor?: boolean; auxiliaryBar?: boolean; sidebar?: boolean }; const savePartVisibility = Reflect.get(Workbench.prototype, '_savePartVisibility') as (this: IWorkbenchTestHarness) => void; - const setEditorHidden = Reflect.get(Workbench.prototype, 'setEditorHidden') as (this: IEditorSplitTestHarness, hidden: boolean) => void; - const applyEditorSplitSize = Reflect.get(Workbench.prototype, '_applyEditorSplitSize') as (this: IEditorSplitTestHarness, mainAreaWidth: number) => void; + const handleWillOpenEditor = Reflect.get(Workbench.prototype, '_handleWillOpenEditor') as (this: IWillOpenTestHarness, e: { groupId: number; editor: { typeId: string } }) => void; + const createDesktopGridDescriptor = Reflect.get(Workbench.prototype, 'createDesktopGridDescriptor') as (this: IGridDescriptorTestHarness, width: number, height: number) => { root: { data: readonly unknown[] } }; - interface IEditorSplitTestHarness { - readonly editorPartView: object; - readonly sessionsPartView: object; - readonly mainContainer: { classList: { toggle(name: string, force: boolean): void } }; - readonly workbenchGrid: { - getViewSize(view: object): { width: number; height: number }; - setViewVisible(view: object, visible: boolean): void; - resizeView(view: object, size: { width: number; height: number }): void; - }; - readonly resizes: { width: number; height: number }[]; - readonly visibilityChanges: boolean[]; - partVisibility: { editor: boolean }; + // --- Harness ------------------------------------------------------------ + + interface ITestWorkbench { + partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; _editorMaximized: boolean; + _editorRevealedExplicitly: boolean; + _editorPartAutoVisibilitySuppressionCount: number; + _restoreAttachedEditorMaximizedOnShow: boolean; _hasAppliedInitialEditorSplit: boolean; - setEditorMaximized(maximized: boolean): void; - _applyEditorSplitSize(mainAreaWidth: number): void; - _savePartVisibility(): void; + _dockedAuxiliaryBarWidth: number; + _memento: DockedEditorSizeMemento; + readonly resizes: IViewSize[]; + readonly visibilityChanges: boolean[]; + readonly events: IPartVisibilityChangeEvent[]; + readonly classToggles: { name: string; force: boolean }[]; + readonly counts: { save: number; layout: number }; + setEditorHidden(hidden: boolean, explicit?: boolean): void; + setAuxiliaryBarHidden(hidden: boolean): void; + } + + interface IGridDescriptorTestHarness extends ITestWorkbench { + _savedPartSizes: { sidebar?: number; auxiliaryBar?: number; editor?: number; sessions?: number; panel?: number }; + layoutPolicy: { + getPartSizes(width: number, height: number): { sideBarSize: number; auxiliaryBarSize: number; panelSize: number }; + viewportClass: { get(): string }; + }; + titleBarPartView: { minimumHeight: number }; } - function createEditorSplitHarness(sessionsWidth: number, overrides?: Partial<Pick<IEditorSplitTestHarness, 'partVisibility' | '_hasAppliedInitialEditorSplit'>>): IEditorSplitTestHarness { + interface IHostOptions { + single?: boolean; + partVisibility?: Partial<ITestWorkbench['partVisibility']>; + sessionsWidth?: number; + editorWidth?: number; + sideBarWidth?: number; + dockedWidth?: number; + hasAppliedInitialEditorSplit?: boolean; + suppressionCount?: number; + editorGroupService?: { mainPart: { groups: readonly { isEmpty: boolean }[] } }; + viewDescriptorService?: { + getDefaultViewContainer(...args: unknown[]): { id: string } | undefined; + getViewContainerById?(id: string): { hideIfEmpty: boolean } | null; + getViewContainerModel?(container: object): { activeViewDescriptors: readonly object[] }; + }; + } + + function createHost(options: IHostOptions = {}): ITestWorkbench { const editorPartView = {}; const sessionsPartView = {}; - const resizes: { width: number; height: number }[] = []; + const sideBarPartView = {}; + const auxiliaryBarPartView = {}; + const resizes: IViewSize[] = []; const visibilityChanges: boolean[] = []; - const editorSize = { width: 0, height: 800 }; - return { + const events: IPartVisibilityChangeEvent[] = []; + const classToggles: { name: string; force: boolean }[] = []; + const counts = { save: 0, layout: 0 }; + const viewSizes = new Map<object, IViewSize>([ + [editorPartView, { width: options.editorWidth ?? 0, height: 800 }], + [sessionsPartView, { width: options.sessionsWidth ?? 1000, height: 800 }], + [sideBarPartView, { width: options.sideBarWidth ?? 280, height: 800 }], + [auxiliaryBarPartView, { width: 300, height: 800 }], + ]); + + const host = { editorPartView, sessionsPartView, - mainContainer: { classList: { toggle: () => { } } }, + sideBarPartView, + auxiliaryBarPartView, + _editorPartContainer: undefined, + mainContainer: { classList: { toggle: (name: string, force: boolean) => { classToggles.push({ name, force }); } } }, + partVisibility: { sidebar: true, auxiliaryBar: true, editor: false, panel: false, sessions: true, ...options.partVisibility }, workbenchGrid: { - getViewSize: view => view === sessionsPartView ? { width: sessionsWidth, height: 800 } : editorSize, - setViewVisible: (_view, visible) => visibilityChanges.push(visible), - resizeView: (_view, size) => { - resizes.push(size); - editorSize.width = size.width; - }, + getViewSize: (view: object) => viewSizes.get(view) ?? { width: 0, height: 0 }, + setViewVisible: (_view: object, visible: boolean) => { visibilityChanges.push(visible); }, + resizeView: (view: object, size: IViewSize) => { resizes.push(size); viewSizes.set(view, size); }, }, - resizes, - visibilityChanges, - partVisibility: { editor: false }, + _hasAppliedInitialEditorSplit: options.hasAppliedInitialEditorSplit ?? false, + _savedPartSizes: {}, + _editorRevealedExplicitly: false, _editorMaximized: false, - _hasAppliedInitialEditorSplit: false, + _editorPartAutoVisibilitySuppressionCount: options.suppressionCount ?? 0, + _restoreAttachedEditorMaximizedOnShow: false, + editorGroupService: options.editorGroupService, + paneCompositeService: { + getActivePaneComposite: () => undefined, + hideActivePaneComposite: () => { }, + getLastActivePaneCompositeId: () => undefined, + openPaneComposite: () => { }, + }, + viewDescriptorService: options.viewDescriptorService ?? { getDefaultViewContainer: () => undefined }, + // docked bookkeeping + _dockedAuxiliaryBarWidth: options.dockedWidth ?? DockedAuxiliaryBarController.DEFAULT_WIDTH, + _syncingEditorVisibility: false, + _memento: new DockedEditorSizeMemento(), + // stubs for the heavy base helpers the hooks call + _savePartVisibility: () => { counts.save++; }, + _fireDidChangePartVisibility: (partId: Parts, visible: boolean) => { events.push({ partId, visible }); }, + _notifyContainerDidLayout: () => { }, + _layoutDockedAuxBar: () => { counts.layout++; }, + layoutMobileSidebar: () => { }, setEditorMaximized: () => { }, - _applyEditorSplitSize: applyEditorSplitSize, - _savePartVisibility: () => { }, - ...overrides, + // captures + resizes, + visibilityChanges, + events, + classToggles, + counts, }; + + Object.setPrototypeOf(host, options.single ? SinglePaneWorkbench.prototype : Workbench.prototype); + return host as unknown as ITestWorkbench; } + // --- Editor split / reveal --------------------------------------------- + test('applies an even editor split the first time the editor is revealed', () => { - const workbench = createEditorSplitHarness(1000); + const host = createHost({ sessionsWidth: 1000 }); - setEditorHidden.call(workbench, false); + setEditorHidden.call(host, false); assert.deepStrictEqual({ - editorVisible: workbench.partVisibility.editor, - appliedSplit: workbench._hasAppliedInitialEditorSplit, - visibilityChanges: workbench.visibilityChanges, - resizes: workbench.resizes, + editorVisible: host.partVisibility.editor, + appliedSplit: host._hasAppliedInitialEditorSplit, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, }, { editorVisible: true, appliedSplit: true, @@ -108,15 +168,141 @@ suite('Sessions - Workbench', () => { }); }); + test('docked sidebar hide grows the editor by the freed sidebar width and show restores it', () => { + const host = createHost({ single: true, sideBarWidth: 280, editorWidth: 620, partVisibility: { sidebar: true, editor: true, auxiliaryBar: true } }); + + setSideBarHidden.call(host, true); + setSideBarHidden.call(host, false); + + assert.deepStrictEqual({ + sidebarVisible: host.partVisibility.sidebar, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + layoutCount: host.counts.layout, + snapshot: host._memento.editorSizeGrownForSidebarHide, + }, { + sidebarVisible: true, + visibilityChanges: [false, true], + resizes: [ + { width: 900, height: 800 }, + { width: 620, height: 800 }, + ], + layoutCount: 2, + snapshot: undefined, + }); + }); + + test('standard layout sidebar hide does not grow the editor', () => { + const host = createHost({ sideBarWidth: 280, editorWidth: 620, partVisibility: { sidebar: true, editor: true, auxiliaryBar: true } }); + + setSideBarHidden.call(host, true); + + assert.deepStrictEqual({ + sidebarVisible: host.partVisibility.sidebar, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + }, { + sidebarVisible: false, + visibilityChanges: [false], + resizes: [], + }); + }); + + test('docked sidebar hide grows the detail panel (not the editor node) when the editor is hidden and show restores it', () => { + const host = createHost({ single: true, sideBarWidth: 280, editorWidth: 620, dockedWidth: 300, partVisibility: { sidebar: true, editor: false, auxiliaryBar: true } }); + + setSideBarHidden.call(host, true); + const afterHide = { + editorVisible: host.partVisibility.editor, + detailWidth: host._dockedAuxiliaryBarWidth, + resizes: [...host.resizes], + detailSnapshot: host._memento.detailWidthGrownForSidebarHide, + editorSnapshot: host._memento.editorSizeGrownForSidebarHide, + }; + + setSideBarHidden.call(host, false); + + assert.deepStrictEqual({ + afterHide, + editorVisible: host.partVisibility.editor, + detailWidth: host._dockedAuxiliaryBarWidth, + resizes: host.resizes, + detailSnapshot: host._memento.detailWidthGrownForSidebarHide, + layoutCount: host.counts.layout, + }, { + afterHide: { + editorVisible: false, + detailWidth: 580, + resizes: [{ width: 580, height: 800 }], + detailSnapshot: 300, + editorSnapshot: undefined, + }, + editorVisible: false, + detailWidth: 300, + resizes: [ + { width: 580, height: 800 }, + { width: 300, height: 800 }, + ], + detailSnapshot: undefined, + layoutCount: 2, + }); + }); + + test('single-pane descriptor uses the docked detail width for a detail-only first open', () => { + const host = createHost({ single: true, dockedWidth: 300, partVisibility: { editor: false, auxiliaryBar: true } }) as IGridDescriptorTestHarness; + host.layoutPolicy = { + getPartSizes: () => ({ sideBarSize: 280, auxiliaryBarSize: 340, panelSize: 300 }), + viewportClass: { get: () => 'desktop' }, + }; + host.titleBarPartView = { minimumHeight: 30 }; + + const descriptor = createDesktopGridDescriptor.call(host, 1200, 800); + const contentSection = descriptor.root.data[1] as { data: readonly unknown[] }; + const rightSection = contentSection.data[1] as { data: readonly unknown[] }; + const topRightSection = rightSection.data[0] as { data: readonly unknown[] }; + const editorNode = topRightSection.data[1] as { size: number; visible: boolean }; + + assert.deepStrictEqual({ size: editorNode.size, visible: editorNode.visible }, { size: 300, visible: true }); + }); + + test('showing docked detail with hidden editor restores the preferred detail width instead of cached node width', () => { + const host = createHost({ single: true, editorWidth: 640, dockedWidth: 300, partVisibility: { editor: false, auxiliaryBar: false } }); + + setAuxiliaryBarHidden.call(host, false); + + assert.deepStrictEqual({ + auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + editorVisible: host.partVisibility.editor, + resizes: host.resizes, + visibilityChanges: host.visibilityChanges, + events: host.events, + layoutCount: host.counts.layout, + }, { + auxiliaryBarVisible: true, + editorVisible: false, + resizes: [{ width: 300, height: 800 }], + visibilityChanges: [true], + events: [{ partId: Parts.AUXILIARYBAR_PART, visible: true }], + layoutCount: 1, + }); + }); + + test('persists the user detail width instead of a temporary sidebar-collapse grow width', () => { + const host = createHost({ single: true, dockedWidth: 580 }); + host._memento.detailWidthGrownForSidebarHide = 300; + + assert.strictEqual(persistedAuxiliaryBarWidth.call(host, undefined), 300); + }); + test('does not re-apply the even split on later editor reveals', () => { - const workbench = createEditorSplitHarness(1000, { _hasAppliedInitialEditorSplit: true }); + const host = createHost({ sessionsWidth: 1000, hasAppliedInitialEditorSplit: true }); - setEditorHidden.call(workbench, false); + setEditorHidden.call(host, false); assert.deepStrictEqual({ - editorVisible: workbench.partVisibility.editor, - visibilityChanges: workbench.visibilityChanges, - resizes: workbench.resizes, + editorVisible: host.partVisibility.editor, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, }, { editorVisible: true, visibilityChanges: [true], @@ -125,34 +311,741 @@ suite('Sessions - Workbench', () => { }); test('clamps the even editor split to a minimum width', () => { - const workbench = createEditorSplitHarness(400); + const host = createHost({ sessionsWidth: 400 }); - setEditorHidden.call(workbench, false); + setEditorHidden.call(host, false); - assert.deepStrictEqual(workbench.resizes, [{ width: 300, height: 800 }]); + assert.deepStrictEqual(host.resizes, [{ width: 300, height: 800 }]); }); - function createWorkbenchHarness(): IWorkbenchTestHarness { - return { - partVisibility: { + test('relayouts the docked detail panel when the editor visibility changes', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true }); + + setEditorHidden.call(host, false); + setEditorHidden.call(host, true); + + assert.deepStrictEqual({ + layoutCount: host.counts.layout, + visibilityChanges: host.visibilityChanges, + }, { + layoutCount: 2, + visibilityChanges: [true, true], + }); + }); + + test('fires editor visibility changes when docked editor content is hidden or shown', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, partVisibility: { editor: true, auxiliaryBar: true } }); + + setEditorHidden.call(host, true); + setEditorHidden.call(host, false); + + assert.deepStrictEqual(host.events, [ + { partId: Parts.EDITOR_PART, visible: false }, + { partId: Parts.EDITOR_PART, visible: true }, + ]); + }); + + test('shrinks the docked editor node to the detail width when hiding the editor', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 320, editorWidth: 900, partVisibility: { editor: true, auxiliaryBar: true } }); + + setEditorHidden.call(host, true); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + }, { + editorVisible: false, + visibilityChanges: [true], + resizes: [{ width: 320, height: 800 }], + }); + }); + + test('clears stale sidebar-grow snapshots when hiding the editor with the detail visible', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 320, editorWidth: 900, partVisibility: { editor: true, auxiliaryBar: true } }); + // Captured while the editor was visible and the sessions list was hidden. + host._memento.editorSizeGrownForSidebarHide = { width: 900, height: 800 }; + host._memento.detailWidthGrownForSidebarHide = 500; + + setEditorHidden.call(host, true); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + resizes: host.resizes, + editorSizeGrownForSidebarHide: host._memento.editorSizeGrownForSidebarHide, + detailWidthGrownForSidebarHide: host._memento.detailWidthGrownForSidebarHide, + }, { + editorVisible: false, + resizes: [{ width: 320, height: 800 }], + editorSizeGrownForSidebarHide: undefined, + detailWidthGrownForSidebarHide: undefined, + }); + }); + + // --- [Scenario 5] editor auto-reveal on open --------------------------- + + interface IWillOpenTestHarness { + _editorPartAutoVisibilitySuppressionCount: number; + _editorRevealOnOpenExclusion?: (editor: { typeId: string }) => boolean; + partVisibility: { editor: boolean }; + editorGroupService: { mainPart: { groups: { id: number }[] } }; + setEditorHidden(hidden: boolean, explicit?: boolean): void; + restoreAttachedEditorMaximizedState(): void; + } + + function createWillOpenHarness(overrides?: Partial<IWillOpenTestHarness>): { harness: IWillOpenTestHarness; setEditorHiddenCalls: { hidden: boolean; explicit?: boolean }[] } { + const setEditorHiddenCalls: { hidden: boolean; explicit?: boolean }[] = []; + const harness: IWillOpenTestHarness = { + _editorPartAutoVisibilitySuppressionCount: 0, + // Mirrors the predicate the single-pane layout controller registers for the + // managed Changes and Files tabs (their content lives in the detail panel). + _editorRevealOnOpenExclusion: editor => + editor.typeId === 'workbench.editors.agentSessions.emptyFile' || + editor.typeId === 'workbench.input.agentSessions.sessionChanges', + partVisibility: { editor: false }, + editorGroupService: { mainPart: { groups: [{ id: 1 }] } }, + setEditorHidden: (hidden, explicit) => setEditorHiddenCalls.push({ hidden, explicit }), + restoreAttachedEditorMaximizedState: () => { }, + ...overrides, + }; + return { harness, setEditorHiddenCalls }; + } + + test('[Scenario 5] does not reveal a hidden editor when the managed empty Files tab is activated', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + // Closing the Changes tab activates the managed empty Files placeholder. + handleWillOpenEditor.call(harness, { groupId: 1, editor: { typeId: 'workbench.editors.agentSessions.emptyFile' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, []); + }); + + test('[Scenario 5] does not reveal a hidden editor when the managed Changes tab is activated', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + // Clicking the Changes tab activates the managed Changes multi-diff editor. + handleWillOpenEditor.call(harness, { groupId: 1, editor: { typeId: 'workbench.input.agentSessions.sessionChanges' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, []); + }); + + test('[Scenario 5] reveals a hidden editor when a real editor is opened', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + handleWillOpenEditor.call(harness, { groupId: 1, editor: { typeId: 'workbench.editors.files.fileEditorInput' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, [{ hidden: false, explicit: true }]); + }); + + test('[Scenario 5] does not reveal when the open targets a non-main-part group', () => { + const { harness, setEditorHiddenCalls } = createWillOpenHarness({ partVisibility: { editor: false } }); + + handleWillOpenEditor.call(harness, { groupId: 99, editor: { typeId: 'workbench.editors.files.fileEditorInput' } }); + + assert.deepStrictEqual(setEditorHiddenCalls, []); + }); + + test('restores the docked editor node size when showing after hide', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 320, editorWidth: 900, partVisibility: { editor: true, auxiliaryBar: true } }); + + setEditorHidden.call(host, true); + setEditorHidden.call(host, false); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + visibilityChanges: [true, true], + resizes: [ + { width: 320, height: 800 }, + { width: 900, height: 800 }, + ], + snapshot: undefined, + }); + }); + + test('suppresses docked editor reveal sync while hiding the editor', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 320, editorWidth: 900, partVisibility: { editor: true, auxiliaryBar: true } }); + // Any grid mutation re-enters reveal-sync; it must be a no-op while suspended. + const grid = (host as unknown as { workbenchGrid: { setViewVisible(view: object, visible: boolean): void } }).workbenchGrid; + const setViewVisible = grid.setViewVisible; + grid.setViewVisible = (view, visible) => { + setViewVisible(view, visible); + onEditorNodeResized.call(host, 900); + }; + + setEditorHidden.call(host, true); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + resizes: host.resizes, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + editorVisible: false, + events: [{ partId: Parts.EDITOR_PART, visible: false }], + resizes: [{ width: 320, height: 800 }], + snapshot: { width: 900, height: 800 }, + }); + }); + + test('applies an even split when revealing the docked editor with no captured width even after the initial split', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 300, partVisibility: { editor: false, auxiliaryBar: true } }); + + setEditorHidden.call(host, false); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + }, { + editorVisible: true, + visibilityChanges: [true], + resizes: [{ width: 500, height: 800 }], + }); + }); + + test('restores a captured docked editor width instead of applying an even split', () => { + const host = createHost({ single: true, sessionsWidth: 1000, hasAppliedInitialEditorSplit: true, dockedWidth: 300, partVisibility: { editor: false, auxiliaryBar: true } }); + host._memento.dockedEditorSizeBeforeHide = { width: 720, height: 800 }; + + setEditorHidden.call(host, false); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + visibilityChanges: host.visibilityChanges, + resizes: host.resizes, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + editorVisible: true, + visibilityChanges: [true], + resizes: [{ width: 720, height: 800 }], + snapshot: undefined, + }); + }); + + test('reopening the whole side pane while the sidebar is collapsed even-splits instead of restoring a cramped width', () => { + // Simulates toggle-close order (auxiliary bar already hidden, editor about + // to hide) while the sidebar is collapsed: the editor grid node collapses to + // a tiny width and a stale sidebar-grow snapshot is present. Closing must not + // capture the collapsed width, and must clear the stale snapshots so the + // reopen applies a comfortable even split of the wide main area. + const host = createHost({ single: true, sessionsWidth: 1360, hasAppliedInitialEditorSplit: true, dockedWidth: 300, editorWidth: 40, partVisibility: { editor: true, auxiliaryBar: false } }); + host._memento.editorSizeGrownForSidebarHide = { width: 620, height: 800 }; + host._memento.detailWidthGrownForSidebarHide = 300; + + setEditorHidden.call(host, true); + const afterClose = { + snapshot: host._memento.dockedEditorSizeBeforeHide, + grownEditor: host._memento.editorSizeGrownForSidebarHide, + grownDetail: host._memento.detailWidthGrownForSidebarHide, + resizes: [...host.resizes], + }; + + setEditorHidden.call(host, false); + + assert.deepStrictEqual({ + afterClose, + editorVisible: host.partVisibility.editor, + resizes: host.resizes, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + afterClose: { + snapshot: undefined, + grownEditor: undefined, + grownDetail: undefined, + resizes: [], + }, + editorVisible: true, + resizes: [{ width: 680, height: 800 }], + snapshot: undefined, + }); + }); + + // --- Docked editor hide/reveal-sync (grid sash / editor part layout) ---- + // Width-based visibility is symmetric: squeezing the node down to the detail + // width hides the editor, and widening it far enough to fit the editor at its + // minimum content width beside the detail reveals it again. A small widen (below + // that threshold) resizes the detail panel and must not reveal. + + test('does not reveal the docked editor when the grid sash widens the node while only the detail is shown', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 305 }); + host._memento.dockedEditorSizeBeforeHide = { width: 900, height: 800 }; + + onGridDidChange.call(host); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + classToggles: host.classToggles, + resizes: host.resizes, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + classToggles: [], + resizes: [], + snapshot: { width: 900, height: 800 }, + }); + }); + + test('does not reveal the docked editor from editor part layout width while only the detail is shown', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 300 }); + host._memento.dockedEditorSizeBeforeHide = { width: 900, height: 800 }; + + onEditorNodeResized.call(host, 305); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + snapshot: host._memento.dockedEditorSizeBeforeHide, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + snapshot: { width: 900, height: 800 }, + }); + }); + + test('reveals the docked editor when the sash widens the node enough to fit the editor beside the detail', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 500, partVisibility: { editor: false, auxiliaryBar: true } }); + + // Detail width 300 + reveal margin 200 = 500 reveal threshold; the node is at it. + onEditorNodeResized.call(host, 500); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + classToggles: host.classToggles, + }, { + editorVisible: true, + events: [{ partId: Parts.EDITOR_PART, visible: true }], + layoutCount: 1, + saveCount: 1, + classToggles: [{ name: 'nomaineditorarea', force: false }], + }); + }); + + test('does not reveal the docked editor while widening below the editor-fits threshold', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 499, partVisibility: { editor: false, auxiliaryBar: true } }); + + // One px short of detail (300) + reveal margin (200). + onEditorNodeResized.call(host, 499); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('does not reveal the docked editor from a widen while the detail is also hidden', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 650, partVisibility: { editor: false, auxiliaryBar: false } }); + + onEditorNodeResized.call(host, 650); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('keeps docked editor hidden when editor part layout width leaves only detail width', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 300 }); + + onEditorNodeResized.call(host, 304); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('keeps docked editor hidden when grid sash leaves only detail width', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 300 }); + + onGridDidChange.call(host); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + }, { + editorVisible: false, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('hides docked editor when sash squeezes node down to detail width', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 600, partVisibility: { editor: true, auxiliaryBar: true } }); + + onEditorNodeResized.call(host, 304); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + classToggles: host.classToggles, + }, { + editorVisible: false, + events: [{ partId: Parts.EDITOR_PART, visible: false }], + layoutCount: 1, + saveCount: 1, + classToggles: [{ name: 'nomaineditorarea', force: true }], + }); + }); + + test('does not hide docked editor when node is squeezed but detail is also hidden', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 600, partVisibility: { editor: true, auxiliaryBar: false } }); + + onEditorNodeResized.call(host, 304); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + events: host.events, + layoutCount: host.counts.layout, + saveCount: host.counts.save, + }, { + editorVisible: true, + events: [], + layoutCount: 0, + saveCount: 0, + }); + }); + + test('clears stale snapshots and explicit-reveal flag when sash-collapse hides the editor', () => { + const host = createHost({ single: true, sessionsWidth: 1000, dockedWidth: 300, editorWidth: 600, partVisibility: { editor: true, auxiliaryBar: true } }); + host._memento.editorSizeGrownForSidebarHide = { width: 800, height: 600 }; + host._memento.detailWidthGrownForSidebarHide = 400; + host._editorRevealedExplicitly = true; + + onEditorNodeResized.call(host, 300); + + assert.deepStrictEqual({ + editorVisible: host.partVisibility.editor, + editorSizeGrownForSidebarHide: host._memento.editorSizeGrownForSidebarHide, + detailWidthGrownForSidebarHide: host._memento.detailWidthGrownForSidebarHide, + editorRevealedExplicitly: host._editorRevealedExplicitly, + }, { + editorVisible: false, + editorSizeGrownForSidebarHide: undefined, + detailWidthGrownForSidebarHide: undefined, + editorRevealedExplicitly: false, + }); + }); + + // --- DockedAuxiliaryBarController -------------------------------------- + + test('fills the narrowed docked detail node when editor content is hidden', () => { + + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const layouts: { width: number; height: number; top: number; left: number }[] = []; + const insets: number[] = []; + const persistedWidths: number[] = []; + let editorVisible = true; + let editorWidth = 800; + + Object.defineProperty(editorContainer, 'clientWidth', { get: () => editorWidth }); + Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + editorContainer.getBoundingClientRect = () => ({ + width: editorWidth, + height: 600, + top: 0, + right: editorWidth, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: (width: number, height: number, top: number, left: number) => { + layouts.push({ width, height, top, left }); + }, + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: width => persistedWidths.push(width), + isEditorAreaVisible: () => true, + isEditorVisible: () => editorVisible, + isAuxiliaryBarVisible: () => true, + hideAuxiliaryBar: () => { }, + setEditorContentRightInset: px => insets.push(px), + getHeaderHeight: () => 0, + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + editorWidth = 260; + editorVisible = false; + controller.layout(); + + const sash = Reflect.get(controller, '_sash') as { state: SashState } | undefined; + assert.deepStrictEqual({ + insets, + persistedWidths, + layouts, + style: { + top: auxiliaryBarContainer.style.top, + right: auxiliaryBarContainer.style.right, + width: auxiliaryBarContainer.style.width, + height: auxiliaryBarContainer.style.height, + }, + sashState: sash?.state, + }, { + insets: [260, 260], + persistedWidths: [], + layouts: [ + { width: 260, height: 565, top: 35, left: 540 }, + { width: 260, height: 565, top: 35, left: 0 }, + ], + style: { + top: '35px', + right: '0px', + width: '260px', + height: '565px', + }, + sashState: SashState.Disabled, + }); + + controller.dispose(); + }); + + test('uses persisted docked detail width when editor content is visible', () => { + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const layouts: { width: number; height: number; top: number; left: number }[] = []; + const insets: number[] = []; + + Object.defineProperty(editorContainer, 'clientWidth', { value: 800 }); + Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + editorContainer.getBoundingClientRect = () => ({ + width: 800, + height: 600, + top: 0, + right: 800, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: (width: number, height: number, top: number, left: number) => { + layouts.push({ width, height, top, left }); + }, + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: () => { }, + isEditorAreaVisible: () => true, + isEditorVisible: () => true, + isAuxiliaryBarVisible: () => true, + hideAuxiliaryBar: () => { }, + setEditorContentRightInset: px => insets.push(px), + getHeaderHeight: () => 0, + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + + const sash = Reflect.get(controller, '_sash') as { state: SashState } | undefined; + assert.deepStrictEqual({ + insets, + layouts, + style: { + width: auxiliaryBarContainer.style.width, + height: auxiliaryBarContainer.style.height, + }, + sashState: sash?.state, + }, { + insets: [260], + layouts: [{ width: 260, height: 565, top: 35, left: 540 }], + style: { + width: '260px', + height: '565px', + }, + sashState: SashState.Enabled, + }); + + controller.dispose(); + }); + + test('hides the docked detail panel when its sash collapses to zero width', () => { + const editorContainer = document.createElement('div'); + const auxiliaryBarContainer = document.createElement('div'); + const persistedWidths: number[] = []; + let hideCount = 0; + + Object.defineProperty(editorContainer, 'clientWidth', { value: 800 }); + Object.defineProperty(editorContainer, 'clientHeight', { value: 600 }); + editorContainer.getBoundingClientRect = () => ({ + width: 800, + height: 600, + top: 0, + right: 800, + bottom: 600, + left: 0, + x: 0, + y: 0, + toJSON: () => undefined, + }); + + const auxiliaryBarPart = { + getContainer: () => auxiliaryBarContainer, + layout: () => { }, + } as unknown as Part; + const host: IDockedAuxiliaryBarHost = { + getWidth: () => 260, + setWidth: width => persistedWidths.push(width), + isEditorAreaVisible: () => true, + isEditorVisible: () => true, + isAuxiliaryBarVisible: () => true, + hideAuxiliaryBar: () => hideCount++, + setEditorContentRightInset: () => { }, + getHeaderHeight: () => 0, + }; + const controller = new DockedAuxiliaryBarController(editorContainer, auxiliaryBarPart, host); + + controller.layout(); + const sash = Reflect.get(controller, '_sash'); + const start = Reflect.get(sash, '_onDidStart') as { fire(e: unknown): void }; + const change = Reflect.get(sash, '_onDidChange') as { fire(e: unknown): void }; + start.fire({ startX: 0, currentX: 0, startY: 0, currentY: 0, altKey: false }); + change.fire({ startX: 0, currentX: 270, startY: 0, currentY: 0, altKey: false }); + + assert.deepStrictEqual({ hideCount, persistedWidths }, { hideCount: 1, persistedWidths: [] }); + + controller.dispose(); + }); + + // --- Last-editor close --------------------------------------------------- + + test('docked last editor close hides the whole side pane under suppression', () => { + const editorHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const auxHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const host = createHost({ single: true, partVisibility: { editor: true, auxiliaryBar: true }, editorGroupService: { mainPart: { groups: [{ isEmpty: true }] } } }); + host.setEditorHidden = hidden => { + editorHiddenCalls.push({ hidden, suppression: host._editorPartAutoVisibilitySuppressionCount }); + host.partVisibility.editor = !hidden; + }; + host.setAuxiliaryBarHidden = hidden => { + auxHiddenCalls.push({ hidden, suppression: host._editorPartAutoVisibilitySuppressionCount }); + host.partVisibility.auxiliaryBar = !hidden; + }; + + handleDidCloseEditor.call(host); + + assert.deepStrictEqual({ + editorHiddenCalls, + auxHiddenCalls, + visibility: host.partVisibility, + suppression: host._editorPartAutoVisibilitySuppressionCount, + }, { + editorHiddenCalls: [{ hidden: true, suppression: 1 }], + auxHiddenCalls: [{ hidden: true, suppression: 1 }], + visibility: { sidebar: true, - auxiliaryBar: true, - editor: true, + auxiliaryBar: false, + editor: false, panel: false, sessions: true, }, - layoutPolicy: { - viewportClass: { - get: () => 'desktop', - }, - }, - storageService: { - store: () => { }, - }, + suppression: 0, + }); + }); + + test('docked last editor close hides lingering detail when editor is already hidden', () => { + const editorHiddenCalls: boolean[] = []; + const auxHiddenCalls: { hidden: boolean; suppression: number }[] = []; + const host = createHost({ single: true, partVisibility: { editor: false, auxiliaryBar: true }, editorGroupService: { mainPart: { groups: [{ isEmpty: true }] } } }); + host.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + host.partVisibility.editor = !hidden; + }; + host.setAuxiliaryBarHidden = hidden => { + auxHiddenCalls.push({ hidden, suppression: host._editorPartAutoVisibilitySuppressionCount }); + host.partVisibility.auxiliaryBar = !hidden; + }; + + handleDidCloseEditor.call(host); + + assert.deepStrictEqual({ + editorHiddenCalls, + auxHiddenCalls, + editorVisible: host.partVisibility.editor, + auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + }, { + editorHiddenCalls: [], + auxHiddenCalls: [{ hidden: true, suppression: 1 }], + editorVisible: false, + auxiliaryBarVisible: false, + }); + }); + + // --- Attached editor maximized state ----------------------------------- + + interface IWorkbenchTestHarness { + partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; + layoutPolicy: { viewportClass: { get(): 'phone' | 'tablet' | 'desktop' } }; + storageService: { store(...args: unknown[]): void }; + _editorPartAutoVisibilitySuppressionCount: number; + _editorMaximized: boolean; + _restoreAttachedEditorMaximizedOnShow: boolean; + setEditorMaximized(maximized: boolean): void; + _savePartVisibility(): void; + } + + function createWorkbenchHarness(): IWorkbenchTestHarness { + return { + partVisibility: { sidebar: true, auxiliaryBar: true, editor: true, panel: false, sessions: true }, + layoutPolicy: { viewportClass: { get: () => 'desktop' } }, + storageService: { store: () => { } }, + _editorPartAutoVisibilitySuppressionCount: 0, _editorMaximized: false, _restoreAttachedEditorMaximizedOnShow: false, setEditorMaximized: () => { }, - setAuxiliaryBarHidden: () => { }, _savePartVisibility: () => { }, }; } @@ -190,71 +1083,132 @@ suite('Sessions - Workbench', () => { test('does not restore after the auxiliary bar is hidden and shown again before reopen', () => { const maximizedStates: boolean[] = []; - const workbench = createWorkbenchHarness(); - workbench._editorMaximized = true; - workbench.setEditorMaximized = maximized => maximizedStates.push(maximized); - workbench.setAuxiliaryBarHidden = hidden => { - workbench.partVisibility.auxiliaryBar = !hidden; - }; - (workbench as IWorkbenchTestHarness & { - mainContainer: { classList: { toggle(): void } }; - workbenchGrid: { setViewVisible(): void }; - auxiliaryBarPartView: {}; - paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; - viewDescriptorService: { getDefaultViewContainer(): undefined }; - }).mainContainer = { classList: { toggle: () => { } } }; - (workbench as IWorkbenchTestHarness & { - workbenchGrid: { setViewVisible(): void }; - auxiliaryBarPartView: {}; - }).workbenchGrid = { setViewVisible: () => { } }; - (workbench as IWorkbenchTestHarness & { auxiliaryBarPartView: {} }).auxiliaryBarPartView = {}; - (workbench as IWorkbenchTestHarness & { - paneCompositeService: { getActivePaneComposite(): undefined; hideActivePaneComposite(): void; openPaneComposite(): void; getLastActivePaneCompositeId(): undefined }; - }).paneCompositeService = { - getActivePaneComposite: () => undefined, - hideActivePaneComposite: () => { }, - openPaneComposite: () => { }, - getLastActivePaneCompositeId: () => undefined, + const host = createHost({ single: true, partVisibility: { editor: true, auxiliaryBar: true } }); + host._editorMaximized = true; + (host as unknown as IWorkbenchTestHarness).setEditorMaximized = maximized => maximizedStates.push(maximized); + + rememberAttachedEditorMaximizedState.call(host as unknown as IWorkbenchTestHarness); + setAuxiliaryBarHidden.call(host, true); + setAuxiliaryBarHidden.call(host, false); + + host._editorMaximized = false; + restoreAttachedEditorMaximizedState.call(host as unknown as IWorkbenchTestHarness); + + assert.deepStrictEqual(maximizedStates, []); + assert.strictEqual(host._restoreAttachedEditorMaximizedOnShow, false); + }); + + // --- Docked auxiliary bar visibility ----------------------------------- + + test('docked auxiliary bar hide reveals hidden editor content', () => { + const editorHiddenCalls: boolean[] = []; + const host = createHost({ single: true, partVisibility: { editor: false, auxiliaryBar: true } }); + host.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + host.partVisibility.editor = !hidden; }; - (workbench as IWorkbenchTestHarness & { - viewDescriptorService: { getDefaultViewContainer(): undefined }; - }).viewDescriptorService = { - getDefaultViewContainer: () => undefined, + + setAuxiliaryBarHidden.call(host, true); + + assert.deepStrictEqual({ + editorHiddenCalls, + editorVisible: host.partVisibility.editor, + auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + gridVisible: host.visibilityChanges, + }, { + editorHiddenCalls: [false], + editorVisible: true, + auxiliaryBarVisible: false, + gridVisible: [true], + }); + }); + + test('docked auxiliary bar hide does not reveal editor while side pane toggle is suppressed', () => { + const editorHiddenCalls: boolean[] = []; + const host = createHost({ single: true, suppressionCount: 1, partVisibility: { editor: false, auxiliaryBar: true } }); + host.setEditorHidden = hidden => { + editorHiddenCalls.push(hidden); + host.partVisibility.editor = !hidden; }; - rememberAttachedEditorMaximizedState.call(workbench); - setAuxiliaryBarHidden.call(workbench, true); - setAuxiliaryBarHidden.call(workbench, false); + setAuxiliaryBarHidden.call(host, true); - workbench._editorMaximized = false; - restoreAttachedEditorMaximizedState.call(workbench); + assert.deepStrictEqual({ + editorHiddenCalls, + editorVisible: host.partVisibility.editor, + auxiliaryBarVisible: host.partVisibility.auxiliaryBar, + gridVisible: host.visibilityChanges, + }, { + editorHiddenCalls: [], + editorVisible: false, + auxiliaryBarVisible: false, + gridVisible: [false], + }); + }); - assert.deepStrictEqual(maximizedStates, []); - assert.strictEqual(workbench._restoreAttachedEditorMaximizedOnShow, false); + test('docked auxiliary bar show does not force-open an empty (gated-off) container', () => { + const openedContainers: string[] = []; + // The resolved default container is `hideIfEmpty` with no active views + // (e.g. Changes/Files gated off for a workspace-less quick chat). + const host = createHost({ + single: true, + partVisibility: { editor: true, auxiliaryBar: false }, + viewDescriptorService: { + getDefaultViewContainer: () => ({ id: 'empty.container' }), + getViewContainerById: () => ({ hideIfEmpty: true }), + getViewContainerModel: () => ({ activeViewDescriptors: [] }), + }, + }); + (host as unknown as { paneCompositeService: { openPaneComposite(id: string): void } }).paneCompositeService.openPaneComposite = (id: string) => { openedContainers.push(id); }; + + setAuxiliaryBarHidden.call(host, false); + + assert.deepStrictEqual(openedContainers, [], 'must not force-open an empty container in docked mode'); }); + test('docked auxiliary bar show opens a container that has active views', () => { + const openedContainers: string[] = []; + // The resolved default container has an active view descriptor, so it has + // content to render and must be opened normally. + const host = createHost({ + single: true, + partVisibility: { editor: true, auxiliaryBar: false }, + viewDescriptorService: { + getDefaultViewContainer: () => ({ id: 'active.container' }), + getViewContainerById: () => ({ hideIfEmpty: true }), + getViewContainerModel: () => ({ activeViewDescriptors: [{}] }), + }, + }); + (host as unknown as { paneCompositeService: { openPaneComposite(id: string): void } }).paneCompositeService.openPaneComposite = (id: string) => { openedContainers.push(id); }; + + setAuxiliaryBarHidden.call(host, false); + + assert.deepStrictEqual(openedContainers, ['active.container'], 'must open a container that has active views'); + }); + + // --- Editor maximize/un-maximize --------------------------------------- + interface IMaximizeTestHarness { partVisibility: { sidebar: boolean; auxiliaryBar: boolean; editor: boolean; panel: boolean; sessions: boolean }; readonly editorPartView: object; readonly workbenchGrid: { - getViewSize(view: object): { width: number; height: number }; - resizeView(view: object, size: { width: number; height: number }): void; + getViewSize(view: object): IViewSize; + resizeView(view: object, size: IViewSize): void; }; _editorMaximized: boolean; _editorLastNonMaximizedVisibility?: object; - _editorLastNonMaximizedSize?: { width: number; height: number }; + _editorLastNonMaximizedSize?: IViewSize; readonly _onDidChangeEditorMaximized: { fire(): void }; + _layoutSidePane(): void; setEditorHidden(hidden: boolean): void; setSideBarHidden(hidden: boolean): void; setSessionsHidden(hidden: boolean): void; setAuxiliaryBarHidden(hidden: boolean): void; } - const setEditorMaximized = Reflect.get(Workbench.prototype, 'setEditorMaximized') as (this: IMaximizeTestHarness, maximized: boolean) => void; - test('restores editor size and auxiliary bar visibility when un-maximizing', () => { const editorPartView = {}; - const resizes: { width: number; height: number }[] = []; + const resizes: IViewSize[] = []; const auxiliaryBarHiddenCalls: boolean[] = []; let editorSize = { width: 700, height: 800 }; const harness: IMaximizeTestHarness = { @@ -266,6 +1220,7 @@ suite('Sessions - Workbench', () => { }, _editorMaximized: false, _onDidChangeEditorMaximized: { fire: () => { } }, + _layoutSidePane: () => { }, setEditorHidden: () => { }, setSideBarHidden: hidden => { harness.partVisibility.sidebar = !hidden; }, setSessionsHidden: hidden => { harness.partVisibility.sessions = !hidden; }, @@ -296,6 +1251,8 @@ suite('Sessions - Workbench', () => { }); }); + // --- Persistence gating ------------------------------------------------- + test('does not restore saved desktop part visibility on phone layout', () => { let getCalled = false; const workbench = createWorkbenchHarness(); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 97faedfadc819d..fae7579f6f82b0 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -93,7 +93,6 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly entitlement = ChatEntitlement.Free; readonly entitlementObs: IObservable<ChatEntitlement> = observableValue('entitlement', ChatEntitlement.Free); - readonly previewFeaturesDisabled = false; readonly clientByokEnabled = false; readonly hasByokModels = false; readonly organisations: string[] | undefined = undefined; diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 667ead9edf5b7b..72e7f300878ed6 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -16,6 +16,7 @@ import { StatusBarItemsExtensionPoint } from './statusBarExtensionPoint.js'; import { CSSExtensionPoint } from '../../services/themes/browser/cssExtensionPoint.js'; // --- mainThread participants +import './mainThreadAgentEditorComments.js'; import './mainThreadLocalization.js'; import './mainThreadBulkEdits.js'; import './mainThreadLanguageModels.js'; diff --git a/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts b/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts new file mode 100644 index 00000000000000..5611ac5d05f0cd --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadAgentEditorComments.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DisposableMap, DisposableStore } from '../../../base/common/lifecycle.js'; +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { IRange } from '../../../editor/common/core/range.js'; +import { IAgentEditorCommentsBridge } from '../../services/agentEditorComments/common/agentEditorComments.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostAgentEditorCommentsShape, ExtHostContext, IAgentEditorCommentDto, MainContext, MainThreadAgentEditorCommentsShape } from '../common/extHost.protocol.js'; + +/** + * Bridges the {@link IAgentEditorCommentsBridge} (backed, in the Agents window, + * by the same store the code editor renders its session comments from) to the + * extension host, so custom editors (e.g. the Markdown editor) can render and + * contribute the same comments. Registered in every extension host; when no + * provider is installed (e.g. the regular workbench window) the bridge is a + * no-op and this customer simply reports no comments. + */ +@extHostNamedCustomer(MainContext.MainThreadAgentEditorComments) +export class MainThreadAgentEditorComments implements MainThreadAgentEditorCommentsShape { + + private readonly _proxy: ExtHostAgentEditorCommentsShape; + private readonly _resources = new Map<number, URI>(); + private readonly _disposables = new DisposableMap<number>(); + + constructor( + extHostContext: IExtHostContext, + @IAgentEditorCommentsBridge private readonly _bridge: IAgentEditorCommentsBridge, + ) { + this._proxy = extHostContext.getProxy(ExtHostContext.ExtHostAgentEditorComments); + } + + async $createAgentEditorComments(handle: number, uri: UriComponents): Promise<void> { + const resource = URI.revive(uri); + this._resources.set(handle, resource); + + const store = new DisposableStore(); + store.add(this._bridge.onDidChangeComments(() => this._sendComments(handle))); + this._disposables.set(handle, store); + + this._sendComments(handle); + } + + async $addComment(handle: number, range: IRange, body: string): Promise<void> { + const resource = this._resources.get(handle); + if (!resource) { + return; + } + this._bridge.addComment(resource, range, body); + } + + async $deleteComment(handle: number, id: string): Promise<void> { + const resource = this._resources.get(handle); + if (!resource) { + return; + } + this._bridge.deleteComment(resource, id); + } + + async $disposeAgentEditorComments(handle: number): Promise<void> { + this._resources.delete(handle); + this._disposables.deleteAndDispose(handle); + } + + private _sendComments(handle: number): void { + const resource = this._resources.get(handle); + if (!resource) { + return; + } + const comments: IAgentEditorCommentDto[] = this._bridge.getComments(resource).map(comment => ({ id: comment.id, range: comment.range, body: comment.body })); + this._proxy.$acceptAgentEditorComments(handle, comments, this._bridge.acceptsComments(resource)); + } + + dispose(): void { + this._disposables.dispose(); + this._resources.clear(); + } +} diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a42e2afdce8deb..1faa3281d250ae 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -47,7 +47,7 @@ import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatS import { NotebookDto } from './mainThreadNotebookDto.js'; import { getChatSessionType, isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { AICustomizationManagementSection, AICustomizationSources } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPlugin, IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -809,6 +809,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return folders.map(folder => ({ uri: URI.revive(folder.uri), label: folder.label, + source: folder.source, })); }, }; @@ -841,11 +842,6 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA label: metadata.label, icon: metadata.iconId ? ThemeIcon.fromId(metadata.iconId) : ThemeIcon.fromId(Codicon.extensions.id), hiddenSections, - getStorageSourceFilter: () => ({ - // Extension-provided harnesses manage their own items via the provider, - // so we show all sources for storage-filter-based flows. - sources: AICustomizationSources.all - }), itemProvider, }; diff --git a/src/vs/workbench/api/browser/mainThreadChatContext.ts b/src/vs/workbench/api/browser/mainThreadChatContext.ts index d3dbd11ede8ed3..a87f4fd2dd81da 100644 --- a/src/vs/workbench/api/browser/mainThreadChatContext.ts +++ b/src/vs/workbench/api/browser/mainThreadChatContext.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../base/common/cancellation.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostChatContextShape, ExtHostContext, IChatContextItemDto, IDocumentFilterDto, MainContext, MainThreadChatContextShape } from '../common/extHost.protocol.js'; +import { ExtHostChatContextShape, ExtHostContext, IChatContextItemDto, ITabSelectorDto, MainContext, MainThreadChatContextShape } from '../common/extHost.protocol.js'; import { IChatContextService } from '../../contrib/chat/browser/contextContrib/chatContextService.js'; import { URI } from '../../../base/common/uri.js'; import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; @@ -26,7 +26,7 @@ function reviveContextItems(items: IChatContextItemDto[]): IChatContextItem[] { @extHostNamedCustomer(MainContext.MainThreadChatContext) export class MainThreadChatContext extends Disposable implements MainThreadChatContextShape { private readonly _proxy: Proxied<ExtHostChatContextShape>; - private readonly _providers = new Map<number, { id: string; selector?: IDocumentFilterDto[] }>(); + private readonly _providers = new Map<number, { id: string; selector?: ITabSelectorDto }>(); constructor( extHostContext: IExtHostContext, @@ -61,11 +61,11 @@ export class MainThreadChatContext extends Disposable implements MainThreadChatC }); } - $registerChatResourceContextProvider(handle: number, id: string, selector: IDocumentFilterDto[]): void { + $registerChatResourceContextProvider(handle: number, id: string, selector: ITabSelectorDto): void { this._providers.set(handle, { id, selector }); this._chatContextService.registerChatResourceContextProvider(id, selector, { - provideChatContext: async (resource: URI, withValue: boolean, token: CancellationToken) => { - const result = await this._proxy.$provideResourceChatContext(handle, { resource, withValue }, token); + provideChatContext: async (resource: URI, withValue: boolean, viewType: string | undefined, token: CancellationToken) => { + const result = await this._proxy.$provideResourceChatContext(handle, { resource, withValue, viewType }, token); return result ? reviveContextItem(result) : undefined; }, resolveChatContext: async (context: IChatContextItem, token: CancellationToken) => { diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 520abbfadb6b27..bd05fda6eafba5 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -1513,6 +1513,7 @@ class ExtensionBackedInlineCompletionsProvider extends Disposable implements lan editKind: lifetimeSummary.editKind, longDistanceHintVisible: lifetimeSummary.longDistanceHintVisible, longDistanceHintDistance: lifetimeSummary.longDistanceHintDistance, + isForAnotherDocument: lifetimeSummary.isForAnotherDocument, ...forwardToChannelIf(isCopilotLikeExtension(this.providerId.extensionId!)), }; diff --git a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts index 1f952e1c0e6534..7b87a92cbb95c9 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageModels.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageModels.ts @@ -15,6 +15,7 @@ import { URI, UriComponents } from '../../../base/common/uri.js'; import { localize } from '../../../nls.js'; import { ExtensionIdentifier } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; +import { IProductService } from '../../../platform/product/common/productService.js'; import { resizeImage } from '../../contrib/chat/browser/chatImageUtils.js'; import { ILanguageModelIgnoredFilesService } from '../../contrib/chat/common/ignoredFiles.js'; import { IChatMessage, IChatResponsePart, ILanguageModelChatResponse, ILanguageModelChatSelector, ILanguageModelsService } from '../../contrib/chat/common/languageModels.js'; @@ -62,6 +63,7 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { extHostContext: IExtHostContext, @ILanguageModelsService private readonly _chatProviderService: ILanguageModelsService, @ILogService private readonly _logService: ILogService, + @IProductService private readonly _productService: IProductService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, @IAuthenticationAccessService private readonly _authenticationAccessService: IAuthenticationAccessService, @IExtensionService private readonly _extensionService: IExtensionService, @@ -100,12 +102,18 @@ export class MainThreadLanguageModels implements MainThreadLanguageModelsShape { onDidChange: Event.filter(this._lmProviderChange.event, e => e.vendor === vendor, disposables) as unknown as Event<void>, provideLanguageModelChatInfo: async (options, token) => { const modelsAndIdentifiers = await this._proxy.$provideLanguageModelChatInfo(vendor, options, token); - modelsAndIdentifiers.forEach(m => { + const copilotExtensionId = this._productService.defaultChatAgent?.chatExtensionId; + return modelsAndIdentifiers.map(m => { if (m.metadata.auth) { disposables.add(this._registerAuthenticationProvider(m.metadata.extension, m.metadata.auth)); } + if (m.metadata.isBYOK !== undefined) { + return m; // provider declared it explicitly + } + // Any contributed model that isn't from the built-in Copilot chat extension is BYOK. + const isBuiltinCopilot = !!copilotExtensionId && ExtensionIdentifier.equals(m.metadata.extension, copilotExtensionId); + return { ...m, metadata: { ...m.metadata, isBYOK: !isBuiltinCopilot } }; }); - return modelsAndIdentifiers; }, sendChatRequest: async (modelId, messages, from, options, token) => { const requestId = (Math.random() * 1e6) | 0; diff --git a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts index 2d15ed9a843dd1..1288c4b7dc47ac 100644 --- a/src/vs/workbench/api/browser/mainThreadQuickDiff.ts +++ b/src/vs/workbench/api/browser/mainThreadQuickDiff.ts @@ -4,10 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { DisposableMap, DisposableStore, IDisposable, IReference } from '../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; -import { ExtHostContext, ExtHostQuickDiffShape, IDocumentFilterDto, MainContext, MainThreadQuickDiffShape } from '../common/extHost.protocol.js'; +import { ExtHostContext, ExtHostQuickDiffShape, IDocumentFilterDto, ITextEditorChange, ITextEditorDiffInformation, MainContext, MainThreadQuickDiffShape } from '../common/extHost.protocol.js'; import { IQuickDiffService, QuickDiffProvider } from '../../contrib/scm/common/quickDiff.js'; +import { IQuickDiffModelService, QuickDiffModel } from '../../contrib/scm/browser/quickDiffModel.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; @extHostNamedCustomer(MainContext.MainThreadQuickDiff) @@ -15,10 +16,12 @@ export class MainThreadQuickDiff implements MainThreadQuickDiffShape { private readonly proxy: ExtHostQuickDiffShape; private providerDisposables = new DisposableMap<number, IDisposable>(); + private informationDisposables = new DisposableMap<number, IDisposable>(); constructor( extHostContext: IExtHostContext, - @IQuickDiffService private readonly quickDiffService: IQuickDiffService + @IQuickDiffService private readonly quickDiffService: IQuickDiffService, + @IQuickDiffModelService private readonly quickDiffModelService: IQuickDiffModelService ) { this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostQuickDiff); } @@ -44,7 +47,53 @@ export class MainThreadQuickDiff implements MainThreadQuickDiffShape { } } + async $createSourceControlDiffInformation(handle: number, uri: UriComponents): Promise<void> { + const reference = this.quickDiffModelService.createQuickDiffModelReference(URI.revive(uri)); + if (!reference) { + return; + } + + const store = new DisposableStore(); + store.add(reference); + store.add(reference.object.onDidChange(() => this.sendSourceControlDiffInformation(handle, reference))); + this.informationDisposables.set(handle, store); + + // Push the current state so the extension host has an initial value. + this.sendSourceControlDiffInformation(handle, reference); + } + + async $disposeSourceControlDiffInformation(handle: number): Promise<void> { + if (this.informationDisposables.has(handle)) { + this.informationDisposables.deleteAndDispose(handle); + } + } + + private sendSourceControlDiffInformation(handle: number, reference: IReference<QuickDiffModel>): void { + const model = reference.object; + const primaryResult = model.getQuickDiffResults().find(result => result.providerKind === 'primary'); + if (!primaryResult) { + this.proxy.$acceptSourceControlDiffInformation(handle, undefined); + return; + } + + const changes: ITextEditorChange[] = primaryResult.changes2.map(change => [ + change.original.startLineNumber, + change.original.endLineNumberExclusive, + change.modified.startLineNumber, + change.modified.endLineNumberExclusive, + ]); + + const diffInformation: ITextEditorDiffInformation = { + documentVersion: model.changesVersionId, + original: primaryResult.original, + modified: primaryResult.modified, + changes, + }; + this.proxy.$acceptSourceControlDiffInformation(handle, diffInformation); + } + dispose(): void { this.providerDisposables.dispose(); + this.informationDisposables.dispose(); } } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 5797fa97c2747f..bf42962ec0b01f 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -89,6 +89,7 @@ import { IExtHostOutputService } from './extHostOutput.js'; import { ExtHostProfileContentHandlers } from './extHostProfileContentHandler.js'; import { IExtHostProgress } from './extHostProgress.js'; import { ExtHostQuickDiff } from './extHostQuickDiff.js'; +import { ExtHostAgentEditorComments } from './extHostAgentEditorComments.js'; import { createExtHostQuickOpen } from './extHostQuickOpen.js'; import { IExtHostRpcService } from './extHostRpcService.js'; import { ExtHostSCM } from './extHostSCM.js'; @@ -228,7 +229,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostFileSystemEvent = rpcProtocol.set(ExtHostContext.ExtHostFileSystemEventService, new ExtHostFileSystemEventService(rpcProtocol, extHostLogService, extHostDocumentsAndEditors)); const extHostQuickOpen = rpcProtocol.set(ExtHostContext.ExtHostQuickOpen, createExtHostQuickOpen(rpcProtocol, extHostWorkspace, extHostCommands)); const extHostSCM = rpcProtocol.set(ExtHostContext.ExtHostSCM, new ExtHostSCM(rpcProtocol, extHostCommands, extHostDocuments, extHostLogService)); - const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, uriTransformer)); + const extHostQuickDiff = rpcProtocol.set(ExtHostContext.ExtHostQuickDiff, new ExtHostQuickDiff(rpcProtocol, extHostDocuments, uriTransformer)); + const extHostAgentEditorComments = rpcProtocol.set(ExtHostContext.ExtHostAgentEditorComments, new ExtHostAgentEditorComments(rpcProtocol)); const extHostShare = rpcProtocol.set(ExtHostContext.ExtHostShare, new ExtHostShare(rpcProtocol, uriTransformer)); const extHostComment = rpcProtocol.set(ExtHostContext.ExtHostComments, createExtHostComments(rpcProtocol, extHostCommands, extHostDocuments)); const extHostLabelService = rpcProtocol.set(ExtHostContext.ExtHostLabelService, new ExtHostLabelService(rpcProtocol)); @@ -246,7 +248,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostLanguageModelTools = rpcProtocol.set(ExtHostContext.ExtHostLanguageModelTools, new ExtHostLanguageModelTools(rpcProtocol, extHostLanguageModels)); const extHostChatSessions = rpcProtocol.set(ExtHostContext.ExtHostChatSessions, new ExtHostChatSessions(extHostCommands, extHostLanguageModels, rpcProtocol, extHostLogService)); const extHostChatAgents2 = rpcProtocol.set(ExtHostContext.ExtHostChatAgents2, new ExtHostChatAgents2(rpcProtocol, extHostLogService, extHostCommands, extHostDocuments, extHostDocumentsAndEditors, extHostLanguageModels, extHostDiagnostics, extHostLanguageModelTools, extHostChatSessions)); - const extHostChatContext = rpcProtocol.set(ExtHostContext.ExtHostChatContext, new ExtHostChatContext(rpcProtocol, extHostCommands)); + const extHostChatContext = rpcProtocol.set(ExtHostContext.ExtHostChatContext, new ExtHostChatContext(rpcProtocol, extHostCommands, extHostEditorTabs)); const extHostChatDebug = rpcProtocol.set(ExtHostContext.ExtHostChatDebug, new ExtHostChatDebug(rpcProtocol)); const extHostAiRelatedInformation = rpcProtocol.set(ExtHostContext.ExtHostAiRelatedInformation, new ExtHostRelatedInformation(rpcProtocol)); const extHostAiEmbeddingVector = rpcProtocol.set(ExtHostContext.ExtHostAiEmbeddingVector, new ExtHostAiEmbeddingVector(rpcProtocol)); @@ -1053,6 +1055,14 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'quickDiffProvider'); return extHostQuickDiff.registerQuickDiffProvider(extension, checkSelector(selector), quickDiffProvider, id, label, rootUri); }, + createSourceControlDiffInformation(uri: vscode.Uri): vscode.SourceControlDiffInformationProvider { + checkProposedApiEnabled(extension, 'textEditorDiffInformation'); + return extHostQuickDiff.createSourceControlDiffInformation(uri); + }, + createAgentEditorComments(uri: vscode.Uri): vscode.AgentEditorCommentsProvider { + checkProposedApiEnabled(extension, 'agentEditorComments'); + return extHostAgentEditorComments.createAgentEditorComments(uri); + }, get tabGroups(): vscode.TabGroups { return extHostEditorTabs.tabGroups; }, @@ -1749,18 +1759,21 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatContextProvider'); return extHostChatContext.registerChatWorkspaceContextProvider(`${extension.id}-${id}`, provider); }, - registerChatExplicitContextProvider(id: string, provider: vscode.ChatExplicitContextProvider): vscode.Disposable { + registerChatAttachContextProvider(id: string, provider: vscode.ChatAttachContextProvider): vscode.Disposable { + checkProposedApiEnabled(extension, 'chatContextProvider'); + return extHostChatContext.registerChatAttachContextProvider(`${extension.id}-${id}`, provider); + }, + registerChatTabContextProvider(selector: vscode.TabSelector, id: string, provider: vscode.ChatTabContextProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatContextProvider'); - return extHostChatContext.registerChatExplicitContextProvider(`${extension.id}-${id}`, provider); + return extHostChatContext.registerChatTabContextProvider(selector, `${extension.id}-${id}`, provider); }, - registerChatResourceContextProvider(selector: vscode.DocumentSelector, id: string, provider: vscode.ChatResourceContextProvider): vscode.Disposable { + registerChatExplicitContextProvider(_id: string, _provider: vscode.ChatAttachContextProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatContextProvider'); - return extHostChatContext.registerChatResourceContextProvider(checkSelector(selector), `${extension.id}-${id}`, provider); + return { dispose: () => { } }; }, - /** @deprecated Use registerChatWorkspaceContextProvider, registerChatExplicitContextProvider, or registerChatResourceContextProvider instead. */ - registerChatContextProvider(selector: vscode.DocumentSelector | undefined, id: string, provider: vscode.ChatContextProvider): vscode.Disposable { + registerChatResourceContextProvider(_selector: vscode.DocumentSelector, _id: string, _provider: vscode.ChatTabContextProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatContextProvider'); - return extHostChatContext.registerChatContextProvider(selector ? checkSelector(selector) : undefined, `${extension.id}-${id}`, provider); + return { dispose: () => { } }; }, registerCustomAgentProvider(provider: vscode.ChatCustomAgentProvider): vscode.Disposable { checkProposedApiEnabled(extension, 'chatPromptFiles'); @@ -2205,6 +2218,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatResponseProgressPart2: extHostTypes.ChatResponseProgressPart2, ChatResponseThinkingProgressPart: extHostTypes.ChatResponseThinkingProgressPart, ChatResponseHookPart: extHostTypes.ChatResponseHookPart, + ChatResponseAutoModeResolutionPart: extHostTypes.ChatResponseAutoModeResolutionPart, ChatResponseReferencePart: extHostTypes.ChatResponseReferencePart, ChatResponseReferencePart2: extHostTypes.ChatResponseReferencePart, ChatResponseCodeCitationPart: extHostTypes.ChatResponseCodeCitationPart, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 4e914803f807cb..a4b2b6372c252e 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -444,6 +444,8 @@ export interface IDocumentFilterDto { isBuiltin?: boolean; } +export type ITabSelectorDto = { uri: IDocumentFilterDto[] } | { viewType: string }; + export interface IShareableItemDto { resourceUri: UriComponents; selection?: IRange; @@ -1476,7 +1478,7 @@ export interface ExtHostChatContextShape { $provideWorkspaceChatContext(handle: number, token: CancellationToken): Promise<IChatContextItem[]>; $provideExplicitChatContext(handle: number, token: CancellationToken): Promise<IChatContextItem[]>; $resolveExplicitChatContext(handle: number, context: IChatContextItem, token: CancellationToken): Promise<IChatContextItem>; - $provideResourceChatContext(handle: number, options: { resource: UriComponents; withValue: boolean }, token: CancellationToken): Promise<IChatContextItem | undefined>; + $provideResourceChatContext(handle: number, options: { resource: UriComponents; withValue: boolean; viewType?: string }, token: CancellationToken): Promise<IChatContextItem | undefined>; $resolveResourceChatContext(handle: number, context: IChatContextItem, token: CancellationToken): Promise<IChatContextItem>; $executeChatContextItemCommand(itemHandle: number): Promise<void>; } @@ -1484,7 +1486,7 @@ export interface ExtHostChatContextShape { export interface MainThreadChatContextShape extends IDisposable { $registerChatWorkspaceContextProvider(handle: number, id: string): void; $registerChatExplicitContextProvider(handle: number, id: string): void; - $registerChatResourceContextProvider(handle: number, id: string, selector: IDocumentFilterDto[]): void; + $registerChatResourceContextProvider(handle: number, id: string, selector: ITabSelectorDto): void; $unregisterChatContextProvider(handle: number): void; $updateWorkspaceContextItems(handle: number, items: IChatContextItemDto[]): void; $executeChatContextItemCommand(itemHandle: number): Promise<void>; @@ -1828,6 +1830,7 @@ export interface IChatSessionCustomizationItemDto { export interface IChatSessionCustomizationSourceFolderDto { readonly uri: UriComponents; readonly label: string; + readonly source: IChatResourceSourceDto; } export interface IChatParticipantMetadata { participant: string; @@ -2194,6 +2197,26 @@ export interface MainThreadSCMShape extends IDisposable { export interface MainThreadQuickDiffShape extends IDisposable { $registerQuickDiffProvider(handle: number, selector: IDocumentFilterDto[], id: string, label: string, rootUri: UriComponents | undefined): Promise<void>; $unregisterQuickDiffProvider(handle: number): Promise<void>; + $createSourceControlDiffInformation(handle: number, uri: UriComponents): Promise<void>; + $disposeSourceControlDiffInformation(handle: number): Promise<void>; +} + +export interface IAgentEditorCommentDto { + id: string; + range: IRange; + body: string; + author?: string; +} + +export interface MainThreadAgentEditorCommentsShape extends IDisposable { + $createAgentEditorComments(handle: number, uri: UriComponents): Promise<void>; + $addComment(handle: number, range: IRange, body: string): Promise<void>; + $deleteComment(handle: number, id: string): Promise<void>; + $disposeAgentEditorComments(handle: number): Promise<void>; +} + +export interface ExtHostAgentEditorCommentsShape { + $acceptAgentEditorComments(handle: number, comments: IAgentEditorCommentDto[], acceptsComments: boolean): void; } export interface IDocumentDiffLineChangeDto { @@ -3190,6 +3213,7 @@ export interface ExtHostSCMShape { export interface ExtHostQuickDiffShape { $provideOriginalResource(sourceControlHandle: number, uri: UriComponents, token: CancellationToken): Promise<UriComponents | null>; + $acceptSourceControlDiffInformation(handle: number, diffInformation: ITextEditorDiffInformation | undefined): void; } export interface ExtHostShareShape { @@ -4038,6 +4062,7 @@ export const MainContext = { MainThreadOutputService: createProxyIdentifier<MainThreadOutputServiceShape>('MainThreadOutputService'), MainThreadProgress: createProxyIdentifier<MainThreadProgressShape>('MainThreadProgress'), MainThreadQuickDiff: createProxyIdentifier<MainThreadQuickDiffShape>('MainThreadQuickDiff'), + MainThreadAgentEditorComments: createProxyIdentifier<MainThreadAgentEditorCommentsShape>('MainThreadAgentEditorComments'), MainThreadDocumentDiff: createProxyIdentifier<MainThreadDocumentDiffShape>('MainThreadDocumentDiff'), MainThreadQuickOpen: createProxyIdentifier<MainThreadQuickOpenShape>('MainThreadQuickOpen'), MainThreadStatusBar: createProxyIdentifier<MainThreadStatusBarShape>('MainThreadStatusBar'), @@ -4114,6 +4139,7 @@ export const ExtHostContext = { ExtHostLanguageFeatures: createProxyIdentifier<ExtHostLanguageFeaturesShape>('ExtHostLanguageFeatures'), ExtHostQuickOpen: createProxyIdentifier<ExtHostQuickOpenShape>('ExtHostQuickOpen'), ExtHostQuickDiff: createProxyIdentifier<ExtHostQuickDiffShape>('ExtHostQuickDiff'), + ExtHostAgentEditorComments: createProxyIdentifier<ExtHostAgentEditorCommentsShape>('ExtHostAgentEditorComments'), ExtHostStatusBar: createProxyIdentifier<ExtHostStatusBarShape>('ExtHostStatusBar'), ExtHostShare: createProxyIdentifier<ExtHostShareShape>('ExtHostShare'), ExtHostExtensionService: createProxyIdentifier<ExtHostExtensionServiceShape>('ExtHostExtensionService'), diff --git a/src/vs/workbench/api/common/extHostAgentEditorComments.ts b/src/vs/workbench/api/common/extHostAgentEditorComments.ts new file mode 100644 index 00000000000000..f15f6f7c443eff --- /dev/null +++ b/src/vs/workbench/api/common/extHostAgentEditorComments.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. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { Emitter } from '../../../base/common/event.js'; +import { ExtHostAgentEditorCommentsShape, IAgentEditorCommentDto, IMainContext, MainContext, MainThreadAgentEditorCommentsShape } from './extHost.protocol.js'; +import * as typeConvert from './extHostTypeConverters.js'; + +class ExtHostAgentEditorCommentsProvider implements vscode.AgentEditorCommentsProvider { + + private readonly _onDidChange = new Emitter<void>(); + readonly onDidChange = this._onDidChange.event; + + private _comments: readonly vscode.AgentEditorComment[] = []; + get comments(): readonly vscode.AgentEditorComment[] { return this._comments; } + + private _acceptsComments = false; + get acceptsComments(): boolean { return this._acceptsComments; } + + constructor( + private readonly handle: number, + private readonly proxy: MainThreadAgentEditorCommentsShape, + private readonly onDispose: (handle: number) => void + ) { } + + $acceptComments(comments: IAgentEditorCommentDto[], acceptsComments: boolean): void { + this._comments = comments.map(comment => Object.freeze({ + id: comment.id, + range: typeConvert.Range.to(comment.range), + body: comment.body, + author: comment.author, + } satisfies vscode.AgentEditorComment)); + this._acceptsComments = acceptsComments; + this._onDidChange.fire(); + } + + addComment(range: vscode.Range, body: string): void { + this.proxy.$addComment(this.handle, typeConvert.Range.from(range), body); + } + + deleteComment(id: string): void { + this.proxy.$deleteComment(this.handle, id); + } + + dispose(): void { + this.proxy.$disposeAgentEditorComments(this.handle); + this._onDidChange.dispose(); + this.onDispose(this.handle); + } +} + +export class ExtHostAgentEditorComments implements ExtHostAgentEditorCommentsShape { + private static handlePool = 0; + + private readonly proxy: MainThreadAgentEditorCommentsShape; + private readonly providers = new Map<number, ExtHostAgentEditorCommentsProvider>(); + + constructor(mainContext: IMainContext) { + this.proxy = mainContext.getProxy(MainContext.MainThreadAgentEditorComments); + } + + createAgentEditorComments(uri: vscode.Uri): vscode.AgentEditorCommentsProvider { + const handle = ExtHostAgentEditorComments.handlePool++; + const provider = new ExtHostAgentEditorCommentsProvider(handle, this.proxy, h => this.providers.delete(h)); + this.providers.set(handle, provider); + this.proxy.$createAgentEditorComments(handle, uri); + return provider; + } + + $acceptAgentEditorComments(handle: number, comments: IAgentEditorCommentDto[], acceptsComments: boolean): void { + this.providers.get(handle)?.$acceptComments(comments, acceptsComments); + } +} diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 9bcbfff380fcf6..a580f8cbc061b7 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -399,6 +399,7 @@ export class ChatAgentResponseStream { part instanceof extHostTypes.ChatResponseExternalEditPart || part instanceof extHostTypes.ChatResponseThinkingProgressPart || part instanceof extHostTypes.ChatResponsePullRequestPart || + part instanceof extHostTypes.ChatResponseAutoModeResolutionPart || part instanceof extHostTypes.ChatResponseProgressPart2 ) { checkProposedApiEnabled(that._extension, 'chatParticipantAdditions'); @@ -413,6 +414,9 @@ export class ChatAgentResponseStream { } else if (part instanceof extHostTypes.ChatResponseThinkingProgressPart) { const dto = typeConvert.ChatResponseThinkingProgressPart.from(part); _report(dto); + } else if (part instanceof extHostTypes.ChatResponseAutoModeResolutionPart) { + const dto = typeConvert.ChatResponseAutoModeResolutionPart.from(part); + _report(dto); } else if (part instanceof extHostTypes.ChatResponseAnchorPart) { const dto = typeConvert.ChatResponseAnchorPart.from(part); @@ -875,6 +879,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return folders.map(folder => ({ uri: folder.uri, label: folder.label, + source: folder.source, } satisfies IChatSessionCustomizationSourceFolderDto)); } catch (err) { return undefined; diff --git a/src/vs/workbench/api/common/extHostChatContext.ts b/src/vs/workbench/api/common/extHostChatContext.ts index 74a9fab7e5f4c3..7b26ffd83af80d 100644 --- a/src/vs/workbench/api/common/extHostChatContext.ts +++ b/src/vs/workbench/api/common/extHostChatContext.ts @@ -6,9 +6,11 @@ import type * as vscode from 'vscode'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; +import { isEqual } from '../../../base/common/resources.js'; import { ExtHostChatContextShape, MainContext, MainThreadChatContextShape } from './extHost.protocol.js'; -import { DocumentSelector, MarkdownString } from './extHostTypeConverters.js'; +import { MarkdownString, TabSelector } from './extHostTypeConverters.js'; import { IExtHostRpcService } from './extHostRpcService.js'; +import { IExtHostEditorTabs } from './extHostEditorTabs.js'; import { IChatContextItem } from '../../contrib/chat/common/contextContrib/chatContext.js'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { IExtHostCommands } from './extHostCommands.js'; @@ -17,7 +19,7 @@ type ProviderType = 'workspace' | 'explicit' | 'resource'; interface ProviderEntry { type: ProviderType; - provider: vscode.ChatWorkspaceContextProvider | vscode.ChatExplicitContextProvider | vscode.ChatResourceContextProvider; + provider: vscode.ChatWorkspaceContextProvider | vscode.ChatAttachContextProvider | vscode.ChatTabContextProvider; disposables: DisposableStore; } @@ -36,6 +38,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext constructor( @IExtHostRpcService extHostRpc: IExtHostRpcService, @IExtHostCommands private readonly _commands: IExtHostCommands, + @IExtHostEditorTabs private readonly _editorTabs: IExtHostEditorTabs, ) { super(); this._proxy = extHostRpc.getProxy(MainContext.MainThreadChatContext); @@ -50,7 +53,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext throw new Error('Workspace context provider not found'); } const provider = entry.provider as vscode.ChatWorkspaceContextProvider; - const result = (await provider.provideWorkspaceChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; + const result = (await provider.provideWorkspaceChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -62,8 +65,8 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!entry || entry.type !== 'explicit') { throw new Error('Explicit context provider not found'); } - const provider = entry.provider as vscode.ChatExplicitContextProvider; - const result = (await provider.provideExplicitChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; + const provider = entry.provider as vscode.ChatAttachContextProvider; + const result = (await provider.provideAttachChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -72,24 +75,30 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!entry || entry.type !== 'explicit') { throw new Error('Explicit context provider not found'); } - const provider = entry.provider as vscode.ChatExplicitContextProvider; + const provider = entry.provider as vscode.ChatAttachContextProvider; const extItem = this._globalItems.get(context.handle); if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve((provider.resolveExplicitChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); + return this._doResolve((provider.resolveAttachChatContext)?.bind(provider), context, extItem, token); } // Resource context provider methods - async $provideResourceChatContext(handle: number, options: { resource: UriComponents; withValue: boolean }, token: CancellationToken): Promise<IChatContextItem | undefined> { + async $provideResourceChatContext(handle: number, options: { resource: UriComponents; withValue: boolean; viewType?: string }, token: CancellationToken): Promise<IChatContextItem | undefined> { const entry = this._providers.get(handle); if (!entry || entry.type !== 'resource') { throw new Error('Resource context provider not found'); } - const provider = entry.provider as vscode.ChatResourceContextProvider; + const provider = entry.provider as vscode.ChatTabContextProvider; - const result = (await provider.provideResourceChatContext?.({ resource: URI.revive(options.resource) }, token)) ?? (await provider.provideChatContext?.({ resource: URI.revive(options.resource) }, token)); + const resource = URI.revive(options.resource); + const tab = this._findTab(resource, options.viewType); + if (!tab) { + return undefined; + } + + const result = (await provider.provideChatTabContext?.({ tab }, token)); if (!result) { return undefined; } @@ -109,7 +118,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext command: result.command ? { id: result.command.command } : undefined }; if (options.withValue && !item.value) { - const resolved = await (provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider)(result, token); + const resolved = await provider.resolveChatTabContext?.bind(provider)(result, token); item.value = resolved?.value; item.tooltip = resolved?.tooltip ? MarkdownString.from(resolved.tooltip) : item.tooltip; } @@ -122,12 +131,12 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!entry || entry.type !== 'resource') { throw new Error('Resource context provider not found'); } - const provider = entry.provider as vscode.ChatResourceContextProvider; + const provider = entry.provider as vscode.ChatTabContextProvider; const extItem = this._globalItems.get(context.handle); if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve((provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); + return this._doResolve(provider.resolveChatTabContext?.bind(provider), context, extItem, token); } // Command execution @@ -165,7 +174,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext }; } - registerChatExplicitContextProvider(id: string, provider: vscode.ChatExplicitContextProvider): vscode.Disposable { + registerChatAttachContextProvider(id: string, provider: vscode.ChatAttachContextProvider): vscode.Disposable { const handle = this._handlePool++; const disposables = new DisposableStore(); this._providers.set(handle, { type: 'explicit', provider, disposables }); @@ -182,11 +191,11 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext }; } - registerChatResourceContextProvider(selector: vscode.DocumentSelector, id: string, provider: vscode.ChatResourceContextProvider): vscode.Disposable { + registerChatTabContextProvider(selector: vscode.TabSelector, id: string, provider: vscode.ChatTabContextProvider): vscode.Disposable { const handle = this._handlePool++; const disposables = new DisposableStore(); this._providers.set(handle, { type: 'resource', provider, disposables }); - this._proxy.$registerChatResourceContextProvider(handle, id, DocumentSelector.from(selector)); + this._proxy.$registerChatResourceContextProvider(handle, id, TabSelector.from(selector)); return { dispose: () => { @@ -200,53 +209,29 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext } /** - * @deprecated Use registerChatWorkspaceContextProvider, registerChatExplicitContextProvider, or registerChatResourceContextProvider instead. + * Finds the open {@link vscode.Tab tab} for the given resource. When a `viewType` is provided, + * webview and custom editor tabs are matched by their view type; otherwise tabs are matched by + * their input resource. When multiple tabs match by view type, the active tab is preferred. */ - registerChatContextProvider(selector: vscode.DocumentSelector | undefined, id: string, provider: vscode.ChatContextProvider): vscode.Disposable { - const disposables: vscode.Disposable[] = []; - - // Register workspace context provider if the provider supports it - if (provider.provideWorkspaceChatContext) { - const workspaceProvider: vscode.ChatWorkspaceContextProvider = { - onDidChangeWorkspaceChatContext: provider.onDidChangeWorkspaceChatContext, - provideWorkspaceChatContext: (token) => provider.provideWorkspaceChatContext!(token) - }; - disposables.push(this.registerChatWorkspaceContextProvider(id, workspaceProvider)); - } - - // Register explicit context provider if the provider supports it - if (provider.provideChatContextExplicit) { - const explicitProvider: vscode.ChatExplicitContextProvider = { - provideExplicitChatContext: (token) => provider.provideChatContextExplicit!(token), - resolveExplicitChatContext: provider.resolveChatContext - ? (context, token) => provider.resolveChatContext!(context, token) - : (context) => context - }; - disposables.push(this.registerChatExplicitContextProvider(id, explicitProvider)); - } - - // Register resource context provider if the provider supports it and has a selector - if (provider.provideChatContextForResource && selector) { - const resourceProvider: vscode.ChatResourceContextProvider = { - provideResourceChatContext: (options, token) => provider.provideChatContextForResource!(options, token), - resolveResourceChatContext: provider.resolveChatContext - ? (context, token) => provider.resolveChatContext!(context, token) - : (context) => context - }; - disposables.push(this.registerChatResourceContextProvider(selector, id, resourceProvider)); - } - - return { - dispose: () => { - for (const disposable of disposables) { - disposable.dispose(); + private _findTab(resource: URI, viewType?: string): vscode.Tab | undefined { + let viewTypeMatch: vscode.Tab | undefined; + for (const group of this._editorTabs.tabGroups.all) { + for (const tab of group.tabs) { + const input = tab.input as { uri?: unknown; viewType?: unknown } | undefined; + if (!input) { + continue; + } + if (URI.isUri(input.uri) && isEqual(input.uri, resource)) { + return tab; + } + if (viewType !== undefined && input.viewType === viewType && (!viewTypeMatch || tab.isActive)) { + viewTypeMatch = tab; } } - }; + } + return viewTypeMatch; } - // Helper methods - private _clearProviderItems(handle: number): void { const itemHandles = this._providerItems.get(handle); if (itemHandles) { @@ -315,7 +300,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext return; } const provideWorkspaceContext = async () => { - const workspaceContexts = (await provider.provideWorkspaceChatContext?.(CancellationToken.None) ?? await provider.provideChatContext?.(CancellationToken.None)); + const workspaceContexts = await provider.provideWorkspaceChatContext?.(CancellationToken.None); const resolvedContexts = this._convertItems(handle, workspaceContexts ?? []); return this._proxy.$updateWorkspaceContextItems(handle, resolvedContexts); }; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index 91f0f06764ddfd..2f0abdb6fb61e3 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -244,6 +244,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { statusIcon: m.statusIcon, targetChatSessionType: m.targetChatSessionType, configurationSchema: m.configurationSchema as IJSONSchema | undefined, + warningText: m.warningText, capabilities: m.capabilities ? { vision: m.capabilities.imageInput, editTools: m.capabilities.editTools, diff --git a/src/vs/workbench/api/common/extHostQuickDiff.ts b/src/vs/workbench/api/common/extHostQuickDiff.ts index b17b67c6a3d85d..3ce817c4af2848 100644 --- a/src/vs/workbench/api/common/extHostQuickDiff.ts +++ b/src/vs/workbench/api/common/extHostQuickDiff.ts @@ -5,21 +5,91 @@ import type * as vscode from 'vscode'; import { CancellationToken } from '../../../base/common/cancellation.js'; +import { Emitter } from '../../../base/common/event.js'; import { URI, UriComponents } from '../../../base/common/uri.js'; -import { ExtHostQuickDiffShape, IMainContext, MainContext, MainThreadQuickDiffShape } from './extHost.protocol.js'; +import { ExtHostQuickDiffShape, IMainContext, ITextEditorDiffInformation, MainContext, MainThreadQuickDiffShape } from './extHost.protocol.js'; import { asPromise } from '../../../base/common/async.js'; import { DocumentSelector } from './extHostTypeConverters.js'; +import { TextEditorChangeKind } from './extHostTypes.js'; +import { ExtHostDocuments } from './extHostDocuments.js'; import { IURITransformer } from '../../../base/common/uriIpc.js'; import { ExtensionIdentifier, IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; +class ExtHostSourceControlDiffInformation implements vscode.SourceControlDiffInformationProvider { + + private readonly _onDidChange = new Emitter<void>(); + readonly onDidChange = this._onDidChange.event; + + private _diffInformation: vscode.TextEditorDiffInformation | undefined; + get diffInformation(): vscode.TextEditorDiffInformation | undefined { return this._diffInformation; } + + constructor( + private readonly handle: number, + private readonly proxy: MainThreadQuickDiffShape, + private readonly documents: ExtHostDocuments, + private readonly onDispose: (handle: number) => void + ) { } + + $acceptDiffInformation(diffInformation: ITextEditorDiffInformation | undefined): void { + if (!diffInformation) { + this._diffInformation = undefined; + this._onDidChange.fire(); + return; + } + + const documents = this.documents; + const original = URI.revive(diffInformation.original); + const modified = URI.revive(diffInformation.modified); + + const changes = diffInformation.changes.map(change => { + const [originalStartLineNumber, originalEndLineNumberExclusive, modifiedStartLineNumber, modifiedEndLineNumberExclusive] = change; + + let kind: vscode.TextEditorChangeKind; + if (originalStartLineNumber === originalEndLineNumberExclusive) { + kind = TextEditorChangeKind.Addition; + } else if (modifiedStartLineNumber === modifiedEndLineNumberExclusive) { + kind = TextEditorChangeKind.Deletion; + } else { + kind = TextEditorChangeKind.Modification; + } + + return { + original: { startLineNumber: originalStartLineNumber, endLineNumberExclusive: originalEndLineNumberExclusive }, + modified: { startLineNumber: modifiedStartLineNumber, endLineNumberExclusive: modifiedEndLineNumberExclusive }, + kind + } satisfies vscode.TextEditorChange; + }); + + this._diffInformation = Object.freeze({ + documentVersion: diffInformation.documentVersion, + original, + modified, + changes, + get isStale(): boolean { + const document = documents.getDocumentData(modified); + return document?.document.version !== diffInformation.documentVersion; + } + }); + this._onDidChange.fire(); + } + + dispose(): void { + this.proxy.$disposeSourceControlDiffInformation(this.handle); + this._onDidChange.dispose(); + this.onDispose(this.handle); + } +} + export class ExtHostQuickDiff implements ExtHostQuickDiffShape { private static handlePool: number = 0; private proxy: MainThreadQuickDiffShape; private providers: Map<number, vscode.QuickDiffProvider> = new Map(); + private informations: Map<number, ExtHostSourceControlDiffInformation> = new Map(); constructor( mainContext: IMainContext, + private readonly documents: ExtHostDocuments, private readonly uriTransformer: IURITransformer | undefined ) { this.proxy = mainContext.getProxy(MainContext.MainThreadQuickDiff); @@ -37,6 +107,10 @@ export class ExtHostQuickDiff implements ExtHostQuickDiffShape { .then<UriComponents | null>(r => r || null); } + $acceptSourceControlDiffInformation(handle: number, diffInformation: ITextEditorDiffInformation | undefined): void { + this.informations.get(handle)?.$acceptDiffInformation(diffInformation); + } + registerQuickDiffProvider(extension: IExtensionDescription, selector: vscode.DocumentSelector, quickDiffProvider: vscode.QuickDiffProvider, id: string, label: string, rootUri?: vscode.Uri): vscode.Disposable { const handle = ExtHostQuickDiff.handlePool++; this.providers.set(handle, quickDiffProvider); @@ -50,4 +124,12 @@ export class ExtHostQuickDiff implements ExtHostQuickDiffShape { } }; } + + createSourceControlDiffInformation(uri: vscode.Uri): vscode.SourceControlDiffInformationProvider { + const handle = ExtHostQuickDiff.handlePool++; + const information = new ExtHostSourceControlDiffInformation(handle, this.proxy, this.documents, h => this.informations.delete(h)); + this.informations.set(handle, information); + this.proxy.$createSourceControlDiffInformation(handle, uri); + return information; + } } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 5100ecf840a34d..e3c247cff807ad 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -43,7 +43,7 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; @@ -216,6 +216,20 @@ export namespace DocumentSelector { } } +export namespace TabSelector { + + function isViewTypeSelector(value: vscode.TabSelector): value is { viewType: string } { + return (value as { viewType?: string }).viewType !== undefined; + } + + export function from(value: vscode.TabSelector, uriTransformer?: IURITransformer, extension?: IExtensionDescription): extHostProtocol.ITabSelectorDto { + if (isViewTypeSelector(value)) { + return { viewType: value.viewType }; + } + return { uri: DocumentSelector.from(value.uri, uriTransformer, extension) }; + } +} + export namespace DiagnosticTag { export function from(value: vscode.DiagnosticTag): MarkerTag | undefined { switch (value) { @@ -2840,6 +2854,26 @@ export namespace ChatResponseHookPart { } } +export namespace ChatResponseAutoModeResolutionPart { + const validLabels = new Set<IChatAutoModeResolutionPart['predictedLabel']>(['needs_reasoning', 'no_reasoning', 'fallback']); + + export function from(part: vscode.ChatResponseAutoModeResolutionPart): Dto<IChatAutoModeResolutionPart> { + const label = validLabels.has(part.predictedLabel as IChatAutoModeResolutionPart['predictedLabel']) + ? part.predictedLabel as IChatAutoModeResolutionPart['predictedLabel'] + : 'fallback'; + return { + kind: 'autoModeResolution', + resolvedModel: part.resolvedModel, + resolvedModelName: part.resolvedModelName, + predictedLabel: label, + confidence: Math.max(0, Math.min(1, part.confidence)), + }; + } + export function to(part: Dto<IChatAutoModeResolutionPart>): vscode.ChatResponseAutoModeResolutionPart { + return new types.ChatResponseAutoModeResolutionPart(part.resolvedModel, part.resolvedModelName, part.predictedLabel, part.confidence); + } +} + export namespace ChatResponseWarningPart { export function from(part: vscode.ChatResponseWarningPart): Dto<IChatWarningMessage> { return { @@ -3384,6 +3418,8 @@ export namespace ChatResponsePart { return ChatToolInvocationPart.from(part); } else if (part instanceof types.ChatResponseWorkspaceEditPart) { return ChatResponseWorkspaceEditPart.from(part); + } else if (part instanceof types.ChatResponseAutoModeResolutionPart) { + return ChatResponseAutoModeResolutionPart.from(part); } return { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index e3ea32f20876cb..40d6b2496d219b 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3270,6 +3270,19 @@ export class ChatResponseHookPart { } } +export class ChatResponseAutoModeResolutionPart { + resolvedModel: string; + resolvedModelName: string; + predictedLabel: string; + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number) { + this.resolvedModel = resolvedModel; + this.resolvedModelName = resolvedModelName; + this.predictedLabel = predictedLabel; + this.confidence = confidence; + } +} + export class ChatResponseWarningPart { value: vscode.MarkdownString; constructor(value: string | vscode.MarkdownString) { diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index f06627f88c2f8a..6b72e8ec75fc22 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -112,10 +112,11 @@ export function connectProxyResolver( }, env: process.env, }; - const { resolveProxyWithRequest, resolveProxyURL } = createProxyResolver(params); + const { resolveProxyWithRequest, resolveProxyURL, resolveProxyByURL } = createProxyResolver(params); // eslint-disable-next-line local/code-no-any-casts const target = (proxyAgent as any).default || proxyAgent; target.resolveProxyURL = resolveProxyURL; + target.resolveProxyByURL = resolveProxyByURL; patchGlobalFetch(params, configProvider, mainThreadTelemetry, initData, resolveProxyURL, disposables); patchGlobalWebSocket(params, resolveProxyURL); @@ -298,6 +299,7 @@ type ProxyResolveStatsClassification = { minDuration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Minimum resolution time (ms)' }; maxDuration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Maximum resolution time (ms)' }; avgDuration: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Average resolution time (ms)' }; + type: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Sorted, comma-separated list of resolved proxy types seen during the interval (e.g. DIRECT, PROXY, HTTPS, SOCKS, EMPTY, UNKNOWN)' }; }; type ProxyResolveStatsEvent = { @@ -306,6 +308,7 @@ type ProxyResolveStatsEvent = { minDuration: number; maxDuration: number; avgDuration: number; + type: string; }; const proxyResolveStats = { @@ -313,11 +316,20 @@ const proxyResolveStats = { totalDuration: 0, minDuration: Number.MAX_SAFE_INTEGER, maxDuration: 0, + types: new Set<string>(), lastSentTime: 0, }; const telemetryInterval = 60 * 60 * 1000; // 1 hour +function proxyResolveType(proxy: string | undefined): string { + const type = proxy ? String(proxy).trim().split(/\s+/, 1)[0] : 'EMPTY'; + if (['DIRECT', 'PROXY', 'HTTPS', 'SOCKS', 'EMPTY'].indexOf(type) === -1) { + return 'UNKNOWN'; + } + return type; +} + function sendProxyResolveStats(mainThreadTelemetry: MainThreadTelemetryShape) { if (proxyResolveStats.count > 0) { const avgDuration = proxyResolveStats.totalDuration / proxyResolveStats.count; @@ -327,12 +339,14 @@ function sendProxyResolveStats(mainThreadTelemetry: MainThreadTelemetryShape) { minDuration: proxyResolveStats.minDuration, maxDuration: proxyResolveStats.maxDuration, avgDuration, + type: [...proxyResolveStats.types].sort().join(','), }); // Reset stats after sending proxyResolveStats.count = 0; proxyResolveStats.totalDuration = 0; proxyResolveStats.minDuration = Number.MAX_SAFE_INTEGER; proxyResolveStats.maxDuration = 0; + proxyResolveStats.types.clear(); } proxyResolveStats.lastSentTime = Date.now(); } @@ -340,14 +354,17 @@ function sendProxyResolveStats(mainThreadTelemetry: MainThreadTelemetryShape) { function createTimedResolveProxy(extHostWorkspace: IExtHostWorkspaceProvider, mainThreadTelemetry: MainThreadTelemetryShape) { return async (url: string): Promise<string | undefined> => { const startTime = performance.now(); + let proxy: string | undefined; try { - return await extHostWorkspace.resolveProxy(url); + proxy = await extHostWorkspace.resolveProxy(url); + return proxy; } finally { const duration = performance.now() - startTime; proxyResolveStats.count++; proxyResolveStats.totalDuration += duration; proxyResolveStats.minDuration = Math.min(proxyResolveStats.minDuration, duration); proxyResolveStats.maxDuration = Math.max(proxyResolveStats.maxDuration, duration); + proxyResolveStats.types.add(proxyResolveType(proxy)); // Send telemetry if at least an hour has passed since last send const now = Date.now(); diff --git a/src/vs/workbench/api/test/browser/mainThreadChatAgents2.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatAgents2.test.ts index 35ed14285df20f..bf09fc9a631199 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatAgents2.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatAgents2.test.ts @@ -100,6 +100,7 @@ suite('MainThreadChatAgents2', function () { instantiationService.stub(IPromptsService, new class extends mock<IPromptsService>() { override onDidChangeCustomAgents = Event.None; override onDidChangeInstructions = Event.None; + override onDidChangeAgentInstructions = Event.None; override onDidChangeSkills = Event.None; override onDidChangeSlashCommands = Event.None; override onDidChangeHooks = Event.None; diff --git a/src/vs/workbench/api/test/browser/mainThreadLanguageModels.test.ts b/src/vs/workbench/api/test/browser/mainThreadLanguageModels.test.ts index 9c15b812ccf2a0..a81a345a30b498 100644 --- a/src/vs/workbench/api/test/browser/mainThreadLanguageModels.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadLanguageModels.test.ts @@ -16,7 +16,7 @@ import { IAuthenticationAccessService } from '../../../services/authentication/b import { ILanguageModelIgnoredFilesService } from '../../../contrib/chat/common/ignoredFiles.js'; import { ILanguageModelChatProvider, ILanguageModelsService, IChatMessage } from '../../../contrib/chat/common/languageModels.js'; import { SerializableObjectWithBuffers } from '../../../services/extensions/common/proxyIdentifier.js'; -import { TestExtensionService } from '../../../test/common/workbenchTestServices.js'; +import { TestExtensionService, TestProductService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadLanguageModels } from '../../browser/mainThreadLanguageModels.js'; import { ExtHostLanguageModelsShape } from '../../common/extHost.protocol.js'; import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js'; @@ -42,6 +42,7 @@ suite('MainThreadLanguageModels', function () { SingleProxyRPCProtocol(proxy), languageModelsService, new NullLogService(), + TestProductService, new class extends mock<IAuthenticationService>() { }, new class extends mock<IAuthenticationAccessService>() { }, new TestExtensionService(), @@ -84,6 +85,7 @@ suite('MainThreadLanguageModels', function () { SingleProxyRPCProtocol(proxy), languageModelsService, new NullLogService(), + TestProductService, new class extends mock<IAuthenticationService>() { }, new class extends mock<IAuthenticationAccessService>() { }, new TestExtensionService(), @@ -97,6 +99,102 @@ suite('MainThreadLanguageModels', function () { assert.strictEqual(onChatModelsChangeCount, 0); }); + test('defaults isBYOK in provideLanguageModelChatInfo for built-in and extension-contributed models', async () => { + const store = disposables.add(new DisposableStore()); + let provider: ILanguageModelChatProvider | undefined; + const copilotExtensionId = TestProductService.defaultChatAgent?.chatExtensionId; + const proxy: Partial<ExtHostLanguageModelsShape> = { + $provideLanguageModelChatInfo: async () => ([ + { + identifier: 'explicit-true', + metadata: { + extension: new ExtensionIdentifier('custom.explicit-true'), + name: 'explicit-true', + id: 'explicit-true', + vendor: 'test-vendor', + version: '1', + family: 'test-family', + maxInputTokens: 1, + maxOutputTokens: 1, + isDefaultForLocation: {}, + isBYOK: true + } + }, + { + identifier: 'explicit-false', + metadata: { + extension: new ExtensionIdentifier('custom.explicit-false'), + name: 'explicit-false', + id: 'explicit-false', + vendor: 'test-vendor', + version: '1', + family: 'test-family', + maxInputTokens: 1, + maxOutputTokens: 1, + isDefaultForLocation: {}, + isBYOK: false + } + }, + { + identifier: 'builtin-default', + metadata: { + extension: new ExtensionIdentifier(copilotExtensionId ?? 'builtin.copilot'), + name: 'builtin-default', + id: 'builtin-default', + vendor: 'test-vendor', + version: '1', + family: 'test-family', + maxInputTokens: 1, + maxOutputTokens: 1, + isDefaultForLocation: {} + } + }, + { + identifier: 'external-default', + metadata: { + extension: new ExtensionIdentifier('custom.external'), + name: 'external-default', + id: 'external-default', + vendor: 'test-vendor', + version: '1', + family: 'test-family', + maxInputTokens: 1, + maxOutputTokens: 1, + isDefaultForLocation: {} + } + } + ]), + }; + const languageModelsService = new class extends mock<ILanguageModelsService>() { + override readonly onDidChangeLanguageModels = store.add(new Emitter<string>()).event; + override getLanguageModelIds(): string[] { return []; } + override registerLanguageModelProvider(_vendor: string, value: ILanguageModelChatProvider) { + provider = value; + return Disposable.None; + } + }; + + const mainThread = store.add(new MainThreadLanguageModels( + SingleProxyRPCProtocol(proxy), + languageModelsService, + new NullLogService(), + TestProductService, + new class extends mock<IAuthenticationService>() { }, + new class extends mock<IAuthenticationAccessService>() { }, + new TestExtensionService(), + new class extends mock<ILanguageModelIgnoredFilesService>() { }, + )); + mainThread.$registerLanguageModelProvider('test-vendor'); + + const infos = await provider!.provideLanguageModelChatInfo({ silent: true }, CancellationToken.None); + assert.deepStrictEqual(infos.map(info => ({ identifier: info.identifier, isBYOK: info.metadata.isBYOK })), [ + { identifier: 'explicit-true', isBYOK: true }, + { identifier: 'explicit-false', isBYOK: false }, + { identifier: 'builtin-default', isBYOK: copilotExtensionId ? false : true }, + { identifier: 'external-default', isBYOK: true } + ]); + }); + test('$cancelLanguageModelChatRequest cancels the token passed to $tryStartChatRequest', async () => { const store = disposables.add(new DisposableStore()); let capturedToken: CancellationToken | undefined; @@ -118,6 +216,7 @@ suite('MainThreadLanguageModels', function () { SingleProxyRPCProtocol({}), languageModelsService, new NullLogService(), + TestProductService, new class extends mock<IAuthenticationService>() { }, new class extends mock<IAuthenticationAccessService>() { }, new TestExtensionService(), @@ -156,6 +255,7 @@ suite('MainThreadLanguageModels', function () { SingleProxyRPCProtocol({}), languageModelsService, new NullLogService(), + TestProductService, new class extends mock<IAuthenticationService>() { }, new class extends mock<IAuthenticationAccessService>() { }, new TestExtensionService(), @@ -192,6 +292,7 @@ suite('MainThreadLanguageModels', function () { SingleProxyRPCProtocol(proxy), languageModelsService, new NullLogService(), + TestProductService, new class extends mock<IAuthenticationService>() { }, new class extends mock<IAuthenticationAccessService>() { }, new TestExtensionService(), diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index 560f202f027ac8..e9e5399ba8133c 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -46,8 +46,8 @@ import { IDefaultAccountService } from '../../../platform/defaultAccount/common/ import { IAuthenticationService } from '../../services/authentication/common/authentication.js'; import { IAuthenticationAccessService } from '../../services/authentication/browser/authenticationAccessService.js'; import { IPolicyService } from '../../../platform/policy/common/policy.js'; -import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, INativeManagedSettingsService, IFileManagedSettingsService, ManagedSettingsSource, projectManagedSettings, selectManagedSettings } from '../../../platform/policy/common/copilotManagedSettings.js'; -import { IManagedSettingPolicyDefinition, ManagedSettingsData } from '../../../base/common/policy.js'; +import { COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_STRICT_MARKETPLACES_KEY, INativeManagedSettingsService, IFileManagedSettingsService, IManagedSettingResolution, MANAGED_SETTINGS_CHANNELS, ManagedSettingsChannel, ManagedSettingsSource, projectManagedSettings, pickManagedSettings } from '../../../platform/policy/common/copilotManagedSettings.js'; +import { IManagedSettingPolicyDefinition, ManagedSettingValue, ManagedSettingsData } from '../../../base/common/policy.js'; import { APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, IAccountPolicyGateService } from '../../services/policies/common/accountPolicyService.js'; import { adaptManagedSettings, IManagedSettingsResponse } from '../../services/accounts/browser/managedSettings.js'; import { isObject } from '../../../base/common/types.js'; @@ -696,6 +696,14 @@ function jsonBlock(value: unknown): string { return '```json\n' + JSON.stringify(value ?? {}, null, 2) + '\n```\n\n'; } +/** Render a managed-settings value for a Markdown table cell: compact JSON with pipes escaped, or a dash when absent. */ +function managedValueCell(value: ManagedSettingValue | undefined): string { + if (value === undefined) { + return '—'; + } + return `\`${JSON.stringify(value).replace(/\|/g, '\\|')}\``; +} + /** Header row + separator for the report's two-column `Property | Value` tables. */ const PROPERTY_VALUE_TABLE_HEADER = '| Property | Value |\n|----------|-------|\n'; @@ -830,10 +838,10 @@ class PolicyDiagnosticsAction extends Action2 { content += '## Managed Settings\n\n'; // Captured from the Managed Settings section below so the Policy-Controlled Settings table - // can attribute managed-settings-driven policies to their actual delivery channel instead - // of the generic AccountPolicyService that hosts the projection. - let managedSettingsActiveSource: ManagedSettingsSource = 'none'; - const activeManagedSettingKeys = new Set<string>(); + // can attribute each managed-settings-driven policy to the delivery channel that actually + // won its key (per-key precedence), instead of the generic AccountPolicyService that hosts + // the projection. Maps a winning managed-settings key -> the channel that supplied it. + const activeManagedSettingSources = new Map<string, ManagedSettingsChannel>(); try { const policyData = defaultAccountService.policyData; const serverManagedSettings = policyData?.managedSettings; @@ -841,17 +849,31 @@ class PolicyDiagnosticsAction extends Action2 { const nativeManagedSettings: ManagedSettingsData | undefined = nativeManagedSettingsService?.managedSettings; const fileManagedSettings: ManagedSettingsData | undefined = fileManagedSettingsService?.managedSettings; - // Reuse the same precedence as policy evaluation so this report can never drift from the - // source AccountPolicyService actually applies. - const selection = selectManagedSettings(serverManagedSettings, nativeManagedSettings, fileManagedSettings); + // Reuse the exact per-key resolution that policy evaluation applies so this report can + // never drift from what AccountPolicyService actually enforces. + const pick = pickManagedSettings(nativeManagedSettings, serverManagedSettings, fileManagedSettings); - content += `**Active source**: ${managedSettingsSourceLabel(selection.source)}\n\n`; + content += `**Active sources** (in precedence order): ${pick.activeSources.length > 0 ? pick.activeSources.map(managedSettingsSourceLabel).join(', ') : managedSettingsSourceLabel('none')}\n\n`; + content += '*Precedence is resolved per key: native MDM wins over the server endpoint, which wins over the file on disk. A key left unset by a higher channel is still filled in by a lower one.*\n\n'; // Collect non-fatal issues from every managed-settings parsing/normalization callback // (adapt, projection, JSON payload) so the report explains *why* a key was dropped. // jsonc-style: accumulate every error instead of failing on the first. const parseErrors: { stage: string; message: string }[] = []; + // Whether a channel supplied at least one *winning* key in the per-key resolution. + const channelContributes = (channel: ManagedSettingsChannel) => pick.activeSources.includes(channel); + + // Sections are listed in precedence order (highest first): native MDM wins over the + // server endpoint, which in turn wins over the file on disk. + content += '### Native MDM\n\n'; + content += PROPERTY_VALUE_TABLE_HEADER; + content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; + content += `| Contributes winning keys | ${channelContributes('nativeMdm') ? 'yes' : 'no'} |\n\n`; + if (nativeManagedSettingsService) { + content += jsonBlock(nativeManagedSettings); + } + content += '### GitHub Server API\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += '| Endpoint | `/copilot_internal/managed_settings` |\n'; @@ -859,7 +881,7 @@ class PolicyDiagnosticsAction extends Action2 { content += `| Last fetch | ${fetchStatus === null ? '*never*' : `\`${fetchStatus}\``} |\n`; const fetchedAt = defaultAccountService.managedSettingsFetchedAt; content += `| Last successful fetch | ${fetchedAt ? new Date(fetchedAt).toLocaleString() : '*n/a*'} |\n`; - content += `| Active | ${selection.source === 'server' ? 'yes' : 'no'} |\n\n`; + content += `| Contributes winning keys | ${channelContributes('server') ? 'yes' : 'no'} |\n\n`; const rawResponse = defaultAccountService.managedSettingsRawResponse; if (isObject(rawResponse)) { @@ -871,23 +893,40 @@ class PolicyDiagnosticsAction extends Action2 { content += '**Normalized bag**\n\n'; content += jsonBlock(serverManagedSettings); - content += '### Native MDM\n\n'; - content += PROPERTY_VALUE_TABLE_HEADER; - content += `| Available | ${nativeManagedSettingsService ? 'yes' : 'no'} |\n`; - content += `| Active | ${selection.source === 'nativeMdm' ? 'yes' : 'no'} |\n\n`; - if (nativeManagedSettingsService) { - content += jsonBlock(nativeManagedSettings); - } - content += '### File (managed-settings.json)\n\n'; content += PROPERTY_VALUE_TABLE_HEADER; content += `| Available | ${fileManagedSettingsService ? 'yes' : 'no'} |\n`; - content += `| Active | ${selection.source === 'file' ? 'yes' : 'no'} |\n\n`; + content += `| Contributes winning keys | ${channelContributes('file') ? 'yes' : 'no'} |\n\n`; if (fileManagedSettingsService) { content += jsonBlock(fileManagedSettings); } - // Mirror AccountPolicyService: project the winning bag onto the keys declared by policies + // Per-key resolution: what each channel supplied, which won, and (struck through) which + // were overridden — the authoritative "what came from where, what's effective, and why". + content += '### Resolution (per key)\n\n'; + if (pick.resolutions.size > 0) { + content += '| Key | Effective | Winning Source | Native MDM | Server | File |\n'; + content += '|-----|-----------|----------------|------------|--------|------|\n'; + const channelValue = (resolution: IManagedSettingResolution, channel: ManagedSettingsChannel): string => { + const contribution = resolution.contributions.find(c => c.channel === channel); + if (!contribution) { + return '—'; + } + // Strike through overridden contributions so the report explains *why* they don't apply. + const cell = managedValueCell(contribution.value); + return channel === resolution.source ? cell : `~~${cell}~~`; + }; + for (const key of [...pick.resolutions.keys()].sort()) { + const resolution = pick.resolutions.get(key)!; + content += `| ${key} | ${managedValueCell(resolution.value)} | ${managedSettingsSourceShortLabel(resolution.source)} | ${channelValue(resolution, 'nativeMdm')} | ${channelValue(resolution, 'server')} | ${channelValue(resolution, 'file')} |\n`; + } + content += '\n'; + content += '*Struck-through values were supplied by a channel but overridden by a higher-precedence channel for that key.*\n\n'; + } else { + content += '*No managed-settings keys are supplied by any channel.*\n\n'; + } + + // Mirror AccountPolicyService: project the merged bag onto the keys declared by policies // so the report shows exactly what reaches `policy.value(...)`. const declaredDefinitions: Record<string, IManagedSettingPolicyDefinition> = {}; for (const property of [...Object.values(configurationRegistry.getConfigurationProperties()), ...Object.values(configurationRegistry.getExcludedConfigurationProperties())]) { @@ -896,13 +935,15 @@ class PolicyDiagnosticsAction extends Action2 { Object.assign(declaredDefinitions, declared); } } - const effective = projectManagedSettings(selection.values ?? {}, declaredDefinitions, message => parseErrors.push({ stage: 'project', message })); + const effective = projectManagedSettings(pick.values, declaredDefinitions, message => parseErrors.push({ stage: 'project', message })); // Remember which managed-settings keys actually reached policy evaluation, and from which - // channel, so the Policy-Controlled Settings table can attribute them accurately. - managedSettingsActiveSource = selection.source; + // channel won each, so the Policy-Controlled Settings table can attribute them accurately. for (const key of Object.keys(effective)) { - activeManagedSettingKeys.add(key); + const resolution = pick.resolutions.get(key); + if (resolution) { + activeManagedSettingSources.set(key, resolution.source); + } } // JSON payloads: the structured keys carry a JSON string that PolicyConfiguration parses @@ -1006,19 +1047,29 @@ class PolicyDiagnosticsAction extends Action2 { }; // A managed-settings-driven policy is hosted by AccountPolicyService but its value really - // originates from a delivery channel (server / native MDM / file). Attribute it to that - // channel when its declared managed key actually reached policy evaluation, so the report - // doesn't misleadingly credit every such policy to AccountPolicyService. When the Account - // Policy Gate is actively restricting, the value comes from the gate's restricted value - // (which overrides managed settings), so don't credit the channel in that case. + // originates from a delivery channel (server / native MDM / file). With per-key precedence + // a policy's declared keys can even resolve to different channels, so attribute it to the + // channel(s) that actually won its declared keys. When the Account Policy Gate is actively + // restricting, the value comes from the gate's restricted value (which overrides managed + // settings), so don't credit any channel in that case. const gateInfo = accountPolicyGateService.gateInfo; const gateRestricted = gateInfo.state === AccountPolicyGateState.Restricted && gateInfo.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved; // eslint-disable-next-line @typescript-eslint/no-explicit-any const getRefinedPolicySource = (item: { name: string; property: any }): string => { const declaredKeys = item.property.policy?.managedSettings ? Object.keys(item.property.policy.managedSettings) : []; - if (!gateRestricted && managedSettingsActiveSource !== 'none' && declaredKeys.some(key => activeManagedSettingKeys.has(key))) { - return `Managed Settings: ${managedSettingsSourceShortLabel(managedSettingsActiveSource)}`; + if (!gateRestricted) { + const winningSources = new Set<ManagedSettingsChannel>(); + for (const key of declaredKeys) { + const source = activeManagedSettingSources.get(key); + if (source) { + winningSources.add(source); + } + } + if (winningSources.size > 0) { + const ordered = MANAGED_SETTINGS_CHANNELS.filter(channel => winningSources.has(channel)); + return `Managed Settings: ${ordered.map(managedSettingsSourceShortLabel).join(', ')}`; + } } return getPolicySource(item.name); }; diff --git a/src/vs/workbench/browser/animatedCounterWidget.ts b/src/vs/workbench/browser/animatedCounterWidget.ts new file mode 100644 index 00000000000000..abf38e70c7788b --- /dev/null +++ b/src/vs/workbench/browser/animatedCounterWidget.ts @@ -0,0 +1,150 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/animatedCounterWidget.css'; +import * as dom from '../../base/browser/dom.js'; +import { Throttler } from '../../base/common/async.js'; +import { Disposable } from '../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../base/common/observable.js'; +import { IAccessibilityService } from '../../platform/accessibility/common/accessibility.js'; + +export interface IAnimatedCounterWidgetOptions { + readonly prefix?: string; + readonly cssClassName?: string; + /** + * The direction of the animation when the count + * increases. The direction will be the opposite + * when the count decreases. + * */ + readonly direction?: 'topToBottom' | 'bottomToTop'; + readonly duration?: number; + readonly count: IObservable<number | undefined>; +} + +/** + * A small widget that renders a number and animates transitions between values: + * the container width tweens as the number of digits changes and the outgoing / + * incoming digits slide in the configured direction. Respects reduced motion. + */ +export class AnimatedCounterWidget extends Disposable { + private _element: HTMLElement; + private _count: number | undefined; + private _hasRendered = false; + private readonly _animationOptions: KeyframeAnimationOptions; + private readonly _updateThrottler = this._register(new Throttler()); + + constructor( + container: HTMLElement, + private readonly _options: IAnimatedCounterWidgetOptions, + @IAccessibilityService private readonly _accessibilityService: IAccessibilityService + ) { + super(); + + const { cssClassName, duration } = _options; + + this._element = cssClassName + ? dom.$(`div.monaco-animated-counter.${cssClassName}`) + : dom.$('div.monaco-animated-counter'); + + this._element.appendChild(dom.$(`div`)); + container.appendChild(this._element); + + this._animationOptions = { + duration: duration ?? 240, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'both', + } satisfies KeyframeAnimationOptions; + + this._register(autorun(reader => { + const count = this._options.count.read(reader); + this._updateThrottler.queue(() => this._update(count)); + })); + } + + private async _update(count: number | undefined): Promise<void> { + if (!this._element || this._element.children.length === 0) { + return; + } + + const outgoingElement = this._element.children[0]; + + if (count === undefined) { + outgoingElement.textContent = ''; + this._count = undefined; + this._hasRendered = false; + return; + } + + // Create incoming element + const incomingElementText = `${this._options.prefix ?? ''}${count}`; + + // Skip the animation when it is disabled (duration of 0), when the user + // prefers reduced motion, or on the first render (there is no previous + // value to animate from, so animating would look out of place). Just + // update the text content. + if (this._options.duration === 0 || !this._hasRendered || this._accessibilityService.isMotionReduced()) { + outgoingElement.textContent = incomingElementText; + this._count = count; + this._hasRendered = true; + return; + } + + // Measure the current width before adding the incoming element so + // that a change in the number of digits can be animated smoothly. + const previousWidth = this._element.getBoundingClientRect().width; + + const incomingElement = dom.$(`div`, undefined, incomingElementText); + this._element?.appendChild(incomingElement); + + // The incoming element is content-sized, so its width is the width the + // container will have once the outgoing element is removed. Animate the + // container between the two widths for both growing and shrinking digit + // counts. + const nextWidth = incomingElement.getBoundingClientRect().width; + + if (Math.abs(previousWidth - nextWidth) > 0.5) { + this._element.animate([ + { width: `${previousWidth}px` }, + { width: `${nextWidth}px` }, + ], this._animationOptions); + } + + const directionOption = this._options.direction ?? 'topToBottom'; + const directionTopBottom = directionOption === 'topToBottom' + ? count > (this._count ?? 0) + : count < (this._count ?? 0); + + const enterFrom = directionTopBottom ? '-100%' : '100%'; + const exitTo = directionTopBottom ? '100%' : '-100%'; + + incomingElement.animate([ + { transform: `translateY(${enterFrom})`, opacity: 0 }, + { transform: 'translateY(0)', opacity: 1 }, + ], this._animationOptions); + + const exit = outgoingElement.animate([ + { transform: 'translateY(0)', opacity: 1 }, + { transform: `translateY(${exitTo})`, opacity: 0 }, + ], this._animationOptions); + + await new Promise<void>(resolve => { + let didCleanup = false; + + const cleanup = () => { + if (didCleanup) { + return; + } + + didCleanup = true; + this._count = count; + this._element?.removeChild(outgoingElement); + resolve(); + }; + + exit.addEventListener('cancel', cleanup); + exit.addEventListener('finish', cleanup); + }); + } +} diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index c8a537999f0cd0..89fa20a1ebe733 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -238,14 +238,8 @@ export function fillEditorsDragData(accessor: ServicesAccessor, resourcesOrEdito if (!options?.disableStandardTransfer) { // Text: allows to paste into text-capable areas - // Only include file:// URIs — remote URIs are not meaningful to - // native apps and macOS would create .webloc URL bookmark files - // when these are dragged to Finder. - const nativeResources = fileSystemResources.filter(({ resource }) => resource.scheme === Schemas.file); - if (nativeResources.length) { - const lineDelimiter = isWindows ? '\r\n' : '\n'; - event.dataTransfer.setData(DataTransfers.TEXT, nativeResources.map(({ resource }) => labelService.getUriLabel(resource, { noPrefix: true })).join(lineDelimiter)); - } + const lineDelimiter = isWindows ? '\r\n' : '\n'; + event.dataTransfer.setData(DataTransfers.TEXT, fileSystemResources.map(({ resource }) => labelService.getUriLabel(resource, { noPrefix: true })).join(lineDelimiter)); // Download URL: enables support to drag a tab as file to desktop // Requirements: diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index f7701c3972b78f..b51ca0f28c36aa 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -102,7 +102,16 @@ enum LayoutClasses { MAXIMIZED = 'maximized', WINDOW_BORDER = 'border', NO_SHADOWS = 'no-shadows', - FLOATING_PANELS = 'floating-panels' + FLOATING_PANELS = 'floating-panels', + // Presentation class for the Modern UI Update experiment, owned/toggled at + // runtime by `StyleOverridesContribution`. It is *also* applied here at render + // time (see `getLayoutClasses`) because parts read it back during layout (e.g. + // the 32px vs 35px part title height in `PartLayout`, and the editor tab + // height) via `.closest('.style-override')`. The contribution runs in the + // `Restored` phase — after the first layout — so without this early + // application the first layout would size parts against the default (35px) + // title and leave them mismatched until the next relayout. + STYLE_OVERRIDE = 'style-override' } interface IPathToOpen extends IPath { @@ -1902,7 +1911,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi !this.isVisible(Parts.STATUSBAR_PART) ? LayoutClasses.STATUSBAR_HIDDEN : undefined, this.state.runtime.mainWindowFullscreen ? LayoutClasses.FULLSCREEN : undefined, this.isShadowsDisabled() ? LayoutClasses.NO_SHADOWS : undefined, - this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined + this.isFloatingPanelsEnabled() ? LayoutClasses.FLOATING_PANELS : undefined, + // Also seed the style-override class here (see `LayoutClasses.STYLE_OVERRIDE`). + this.isFloatingPanelsEnabled() ? LayoutClasses.STYLE_OVERRIDE : undefined, + `panel-position-${positionToString(this.getPanelPosition())}`, + `panel-alignment-${this.getPanelAlignment()}` ]); } @@ -2034,7 +2047,11 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi // panel alignment requires the editor part to be visible this.setAuxiliaryBarMaximized(false); + // Adjust CSS — capture old value before updating state model + const oldAlignmentValue = this.getPanelAlignment(); this.stateModel.setRuntimeValue(LayoutStateKeys.PANEL_ALIGNMENT, alignment); + this.mainContainer.classList.remove(`panel-alignment-${oldAlignmentValue}`); + this.mainContainer.classList.add(`panel-alignment-${alignment}`); this.adjustPartPositions(this.getSideBarPosition(), alignment, this.getPanelPosition()); @@ -2366,6 +2383,8 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelContainer = assertReturnsDefined(panelPart.getContainer()); panelContainer.classList.remove(oldPositionValue); panelContainer.classList.add(newPositionValue); + this.mainContainer.classList.remove(`panel-position-${oldPositionValue}`); + this.mainContainer.classList.add(`panel-position-${newPositionValue}`); // Update Styles panelPart.updateStyles(); diff --git a/src/vs/workbench/browser/media/animatedCounterWidget.css b/src/vs/workbench/browser/media/animatedCounterWidget.css new file mode 100644 index 00000000000000..6b96e4a59bca26 --- /dev/null +++ b/src/vs/workbench/browser/media/animatedCounterWidget.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +/* Structural styles for the AnimatedCounterWidget. The stacked incoming/outgoing + value elements share a single grid cell so they can cross-fade and slide while + the container width tweens. Callers add their own class for color/typography. */ + +.monaco-animated-counter { + position: relative; + display: inline-grid; + overflow: hidden; + justify-items: start; +} + +.monaco-animated-counter > div { + grid-area: 1 / 1; + display: block; + will-change: transform, opacity; +} diff --git a/src/vs/workbench/browser/media/floatingPanels.css b/src/vs/workbench/browser/media/floatingPanels.css index ab56c980c01d69..43f0814a4bda89 100644 --- a/src/vs/workbench/browser/media/floatingPanels.css +++ b/src/vs/workbench/browser/media/floatingPanels.css @@ -15,13 +15,13 @@ * card on a window edge gets a doubled outer gutter (`.floating-part-outer-*`). * Colors mirror the agents window (`agentsPanel.*` tokens). * - * Uses `--vscode-spacing-size60` (6px); keep this in sync with + * Uses `--vscode-spacing-size40` (4px); keep this in sync with * `FLOATING_PANEL_MARGIN` in code. */ .monaco-workbench.floating-panels .part.sidebar, .monaco-workbench.floating-panels .part.auxiliarybar { - margin: var(--vscode-spacing-size60); + margin: var(--vscode-spacing-size40); border: 1px solid var(--vscode-agentsPanel-border, var(--vscode-widget-border, transparent)) !important; border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-agentsPanel-background) !important; @@ -30,13 +30,17 @@ /* Keep the panel surface aligned with the panel theme background in light theme. */ .monaco-workbench.floating-panels .part.panel { - margin: var(--vscode-spacing-size60); + margin: var(--vscode-spacing-size40); border: 1px solid var(--vscode-agentsPanel-border, var(--vscode-widget-border, transparent)) !important; border-radius: var(--vscode-cornerRadius-large); background-color: var(--vscode-panel-background) !important; color: var(--vscode-agentsPanel-foreground); } +.monaco-workbench.floating-panels .part.editor > .content { + border-radius: var(--vscode-cornerRadius-large, 8px); +} + /* Keep the auxiliary bar background aligned with the primary sidebar in light theme. */ .monaco-workbench.floating-panels .part.auxiliarybar { background-color: var(--vscode-sideBar-background) !important; @@ -48,6 +52,35 @@ margin-top: 0; } +/* + * When panel is at TOP and sidebars are in the same grid row as the editor (sibling), + * give them a top margin matching the editor's gap from the panel. Alignment determines + * which bars are sibling — mirrors adjustPartPositions() in layout.ts. + * Uses `.panel-alignment-*` and `.left`/`.right` classes set in layout.ts. + */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel).panel-alignment-right .part.auxiliarybar.right { + margin-top: var(--vscode-spacing-size40); +} + +/* Panels in top/left/right positions are flush with the title bar (no top margin). + * The panel element itself carries the position class (e.g. `.top`). */ +.monaco-workbench.floating-panels .part.panel.top, +.monaco-workbench.floating-panels .part.panel.left, +.monaco-workbench.floating-panels .part.panel.right { + margin-top: 0; +} + +/* When the bottom panel is maximized (editor hidden) it spans the full content height + * and is flush with the title bar — remove the inter-card top margin. */ +.monaco-workbench.floating-panels.nomaineditorarea .part.panel.bottom { + margin-top: 0; +} + /* * When a floating pane composite (primary side bar, secondary side bar or a vertical * panel) is the outermost card on a window edge, it adopts a doubled outer gutter so @@ -55,30 +88,37 @@ * sync with the inset reservation in `AbstractPaneCompositePart`). */ .monaco-workbench.floating-panels .part.floating-part-outer-left { - margin-left: calc(var(--vscode-spacing-size60) * 2); + margin-left: calc(var(--vscode-spacing-size40) * 2); } .monaco-workbench.floating-panels .part.floating-part-outer-right { - margin-right: calc(var(--vscode-spacing-size60) * 2); + margin-right: calc(var(--vscode-spacing-size40) * 2); } /* Main editor: float like the other cards, but stay flush with the title bar. */ .monaco-workbench.floating-panels > .monaco-grid-view .part.editor { - margin: var(--vscode-spacing-size60); + margin: var(--vscode-spacing-size40); margin-top: 0; } +/* When the panel is at the top and visible the editor sits below it — give it a top margin + * so the inter-card gap matches the gaps between the other floating cards. + * Requires `.panel-position-top` on `.monaco-workbench` (set in layout.ts). */ +.monaco-workbench.floating-panels.panel-position-top:not(.nopanel) > .monaco-grid-view .part.editor { + margin-top: var(--vscode-spacing-size40); +} + /* * When the editor becomes the outermost card on a side (no floating part sits * between it and the window edge) it adopts the same doubled gutter the side/aux * bars use. Kept in sync with the margin reservation in `EditorPart.layout`. */ .monaco-workbench.floating-panels > .monaco-grid-view .part.editor.floating-editor-outer-left { - margin-left: calc(var(--vscode-spacing-size60) * 2); + margin-left: calc(var(--vscode-spacing-size40) * 2); } .monaco-workbench.floating-panels > .monaco-grid-view .part.editor.floating-editor-outer-right { - margin-right: calc(var(--vscode-spacing-size60) * 2); + margin-right: calc(var(--vscode-spacing-size40) * 2); } /* The shell backdrop behind the floating cards matches the editor background. */ @@ -104,8 +144,14 @@ /* Activity bar (default position) gets a left and bottom gutter to match the cards. */ .monaco-workbench.floating-panels .part.activitybar { - margin-left: var(--vscode-spacing-size60); - margin-bottom: var(--vscode-spacing-size60); + margin-left: var(--vscode-spacing-size40); + margin-bottom: var(--vscode-spacing-size40); +} + +/* When the activity bar is on the right, mirror the gutter so the bar stays centered. */ +.monaco-workbench.floating-panels .part.activitybar.right { + margin-left: 0; + margin-right: var(--vscode-spacing-size40); } /* The status bar draws its top border via a pseudo-element — hide it too. */ @@ -115,11 +161,67 @@ /* Inset the status bar items so they respect the same gutter as the cards. */ .monaco-workbench.floating-panels .part.statusbar { - padding-left: var(--vscode-spacing-size60); - padding-right: var(--vscode-spacing-size60); - padding-bottom: var(--vscode-spacing-size60); + padding-left: var(--vscode-spacing-size40); + padding-right: var(--vscode-spacing-size40); + padding-bottom: var(--vscode-spacing-size40); } -.monaco-workbench.floating-panels .activitybar > .content > .composite-bar { - margin-top: var(--vscode-spacing-size20); +/* + * When the status bar is hidden every floating card sits directly against the window + * bottom edge. Double the bottom margin (4px → 8px) to match the doubled outer gutter + * applied to other window-edge sides, so all gaps look consistent. + * Exceptions: + * - Panel at TOP: its bottom faces the editor (not the window edge) — keep 4px. + * `.part.panel.top` is intentionally absent from the selector list below. + * - Editor when a bottom panel is visible: it abuts the panel card — keep 4px. + * The code-side insets in AbstractPaneCompositePart and EditorPart are kept in sync. + */ +.monaco-workbench.floating-panels.nostatusbar .part.panel.bottom, +.monaco-workbench.floating-panels.nostatusbar .part.panel.left, +.monaco-workbench.floating-panels.nostatusbar .part.panel.right, +.monaco-workbench.floating-panels.nostatusbar .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar .part.auxiliarybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +.monaco-workbench.floating-panels.nostatusbar > .monaco-grid-view .part.editor { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +/* + * When a visible bottom panel directly abuts the sidebars and/or editor (they share + * the same grid row), keep the normal 4px inter-card gap instead of the doubled 8px. + * Which bars are in the same row as the editor depends on panel alignment: + * justify → both sidebars are in the top row (above the full-width panel) + * left → the bar on the LEFT is in the top row; the bar on the RIGHT is full-height + * right → the bar on the RIGHT is in the top row; the bar on the LEFT is full-height + * center → neither sidebar is in the top row (both span full height) + * Uses `.panel-alignment-*` and `.left`/`.right` position classes set in layout.ts. + */ +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.sidebar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-justify .part.auxiliarybar, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.sidebar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-left .part.auxiliarybar.left, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.sidebar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel).panel-alignment-right .part.auxiliarybar.right, +.monaco-workbench.floating-panels.nostatusbar.panel-position-bottom:not(.nopanel) > .monaco-grid-view .part.editor { + margin-bottom: var(--vscode-spacing-size40); +} + +.monaco-workbench.floating-panels.nostatusbar .part.activitybar { + margin-bottom: calc(var(--vscode-spacing-size40) * 2); +} + +/* + * When the bottom panel is hidden, the grid keeps a collapsed (zero-height) panel + * node whose reveal sash sits at the very bottom of the content region. Sashes are + * centered on their boundary, so the sash's lower half overflows the content region + * and is clipped where the region meets the status bar — leaving only the top half + * of the highlight visible. The reveal sash carries the `.maximum` state class; nudge + * its highlight up by half the sash size so the full line renders inside the content + * region. Scoped to `.nopanel` so a panel resized to its minimum (still visible, its + * sash fully on-screen) keeps its highlight centered on the real boundary. + */ +.monaco-workbench.floating-panels.nopanel .monaco-sash.horizontal.maximum:not(.part .monaco-sash)::before { + top: calc(50% - (var(--vscode-sash-hover-size) / 2) - (var(--vscode-sash-size) / 2)); } diff --git a/src/vs/workbench/browser/part.ts b/src/vs/workbench/browser/part.ts index e7b9278318f583..0d2e7c4fcb9b0a 100644 --- a/src/vs/workbench/browser/part.ts +++ b/src/vs/workbench/browser/part.ts @@ -217,6 +217,8 @@ class PartLayout { private static readonly HEADER_HEIGHT = 35; private static readonly TITLE_HEIGHT = 35; + // KEEP IN SYNC WITH: styleOverrides/browser/media/padding.css `.style-override .part > .title { height: 32px }` + private static readonly TITLE_HEIGHT_STYLE_OVERRIDE = 32; private static readonly Footer_HEIGHT = 35; private headerVisible: boolean = false; @@ -226,10 +228,16 @@ class PartLayout { layout(width: number, height: number): ILayoutContentResult { - // Title Size: Width (Fill), Height (Variable) + // Title Size: Width (Fill), Height (Variable). + // When the Modern UI style-override is active the title bar is 32 px + // (set in padding.css). Mirror that value here so the content area + // calculation stays in sync. Uses the same `.closest('.style-override')` + // check as EditorTabsControl.tabHeight. let titleSize: Dimension; if (this.options.hasTitle) { - titleSize = new Dimension(width, Math.min(height, PartLayout.TITLE_HEIGHT)); + const isStyleOverride = !!this.contentArea?.closest('.style-override'); + const titleHeight = isStyleOverride ? PartLayout.TITLE_HEIGHT_STYLE_OVERRIDE : PartLayout.TITLE_HEIGHT; + titleSize = new Dimension(width, Math.min(height, titleHeight)); } else { titleSize = Dimension.None; } diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index ee371523d83184..ca613df89247d7 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -48,6 +48,11 @@ export class ActivitybarPart extends Part { static readonly ACTIVITYBAR_WIDTH = 48; static readonly COMPACT_ACTIVITYBAR_WIDTH = 36; + /** Narrower dimensions used when the floating panels (Modern UI) experiment is enabled. */ + static readonly FLOATING_ACTION_HEIGHT = 44; + static readonly FLOATING_ACTIVITYBAR_WIDTH = 44; + static readonly FLOATING_COMPACT_ACTIVITYBAR_WIDTH = 32; + static readonly ICON_SIZE = 24; static readonly COMPACT_ICON_SIZE = 16; @@ -73,7 +78,20 @@ export class ActivitybarPart extends Part { //#endregion /** The intrinsic activity bar width (excludes any floating gutter). */ - private get baseWidth(): number { return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; } + private get baseWidth(): number { + if (this.layoutService.isFloatingPanelsEnabled()) { + return this._isCompact ? ActivitybarPart.FLOATING_COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH; + } + return this._isCompact ? ActivitybarPart.COMPACT_ACTIVITYBAR_WIDTH : ActivitybarPart.ACTIVITYBAR_WIDTH; + } + + /** The action (item) height that drives visible item sizing and the composite bar overflow size. */ + private get actionHeight(): number { + if (this._isCompact) { + return ActivitybarPart.COMPACT_ACTION_HEIGHT; + } + return this.layoutService.isFloatingPanelsEnabled() ? ActivitybarPart.FLOATING_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT; + } /** Extra space reserved around the part when the floating panels experiment is enabled. */ private get floatingGutter(): number { return this.layoutService.isFloatingPanelsEnabled() ? ActivitybarPart.FLOATING_MARGIN : 0; } @@ -106,6 +124,8 @@ export class ActivitybarPart extends Part { // Floating panels changes the reserved left/bottom gutter (and therefore // the fixed part width): signal the grid that the size constraint changed. if (e.affectsConfiguration(LayoutSettings.MODERN_UI)) { + this.updateCompactStyle(); + this.recreateCompositeBar(); this._onDidChange.fire(undefined); } })); @@ -115,7 +135,7 @@ export class ActivitybarPart extends Part { if (this.element) { this.element.classList.toggle('compact', this._isCompact); this.element.style.setProperty('--activity-bar-width', `${this.baseWidth}px`); - this.element.style.setProperty('--activity-bar-action-height', `${this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT}px`); + this.element.style.setProperty('--activity-bar-action-height', `${this.actionHeight}px`); this.element.style.setProperty('--activity-bar-icon-size', `${this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE}px`); } } @@ -136,7 +156,7 @@ export class ActivitybarPart extends Part { } private createCompositeBar(): PaneCompositeBar { - const actionHeight = this._isCompact ? ActivitybarPart.COMPACT_ACTION_HEIGHT : ActivitybarPart.ACTION_HEIGHT; + const actionHeight = this.actionHeight; const iconSize = this._isCompact ? ActivitybarPart.COMPACT_ICON_SIZE : ActivitybarPart.ICON_SIZE; return this.instantiationService.createInstance(ActivityBarCompositeBar, this.location, { diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts index 348ec3327859e1..8aefd111e61051 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbs.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbs.ts @@ -73,6 +73,7 @@ export abstract class BreadcrumbsConfig<T> { static readonly SymbolSortOrder = BreadcrumbsConfig._stub<'position' | 'name' | 'type'>('breadcrumbs.symbolSortOrder'); static readonly SymbolPathSeparator = BreadcrumbsConfig._stub<string>('breadcrumbs.symbolPathSeparator'); static readonly Icons = BreadcrumbsConfig._stub<boolean>('breadcrumbs.icons'); + static readonly ShowEditorType = BreadcrumbsConfig._stub<boolean>('breadcrumbs.showEditorType'); static readonly TitleScrollbarSizing = BreadcrumbsConfig._stub<IEditorPartOptions['titleScrollbarSizing']>('workbench.editor.titleScrollbarSizing'); static readonly TitleScrollbarVisibility = BreadcrumbsConfig._stub<IEditorPartOptions['titleScrollbarVisibility']>('workbench.editor.titleScrollbarVisibility'); @@ -167,6 +168,12 @@ Registry.as<IConfigurationRegistry>(Extensions.Configuration).registerConfigurat type: 'boolean', default: true }, + 'breadcrumbs.showEditorType': { + markdownDescription: localize('showEditorType', "Controls whether the breadcrumbs bar shows a dropdown to switch between the editors that can open the current file (for example the text editor and a custom editor). The dropdown only appears when a more specialized editor is available."), + type: 'boolean', + default: true, + tags: ['experimental'] + }, 'breadcrumbs.symbolPathSeparator': { description: localize('symbolPathSeparator', "The separator used when copying the breadcrumb symbol path."), type: 'string', diff --git a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts index c07787071f1413..cbf4a285f171c6 100644 --- a/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts +++ b/src/vs/workbench/browser/parts/editor/breadcrumbsControl.ts @@ -9,9 +9,13 @@ import { PixelRatio } from '../../../../base/browser/pixelRatio.js'; import { BreadcrumbsItem, BreadcrumbsWidget, IBreadcrumbsItemEvent, IBreadcrumbsWidgetStyles } from '../../../../base/browser/ui/breadcrumbs/breadcrumbsWidget.js'; import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; import { IHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegate.js'; +import { IManagedHover } from '../../../../base/browser/ui/hover/hover.js'; import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; import { timeout } from '../../../../base/common/async.js'; import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { IAction, Separator, SubmenuAction, toAction } from '../../../../base/common/actions.js'; import { Emitter } from '../../../../base/common/event.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { combinedDisposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -22,10 +26,11 @@ import { OutlineElement } from '../../../../editor/contrib/documentSymbols/brows import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { IContextViewService } from '../../../../platform/contextview/browser/contextView.js'; +import { IContextViewService, IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { fillInSymbolsDragData, LocalSelectionTransfer } from '../../../../platform/dnd/browser/dnd.js'; import { FileKind, IFileService, IFileStat } from '../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; @@ -36,8 +41,9 @@ import { IListService, WorkbenchAsyncDataTree, WorkbenchDataTree, WorkbenchListF import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { defaultBreadcrumbsWidgetStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { registerIcon } from '../../../../platform/theme/common/iconRegistry.js'; -import { EditorResourceAccessor, IEditorPartOptions, SideBySideEditor } from '../../../common/editor.js'; +import { EditorResourceAccessor, DEFAULT_EDITOR_ASSOCIATION, IEditorPartOptions, SideBySideEditor, isDiffEditorInput } from '../../../common/editor.js'; import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorResolverService, RegisteredEditorInfo } from '../../../services/editor/common/editorResolverService.js'; import { ACTIVE_GROUP, ACTIVE_GROUP_TYPE, IEditorService, SIDE_GROUP, SIDE_GROUP_TYPE } from '../../../services/editor/common/editorService.js'; import { IOutline, IOutlineService, OutlineTarget } from '../../../services/outline/browser/outline.js'; import { DraggedEditorIdentifier, fillEditorsDragData } from '../../dnd.js'; @@ -46,6 +52,7 @@ import { BreadcrumbsConfig, IBreadcrumbsService } from './breadcrumbs.js'; import { BreadcrumbsModel, FileElement, OutlineElement2 } from './breadcrumbsModel.js'; import { BreadcrumbsFilePicker, BreadcrumbsOutlinePicker } from './breadcrumbsPicker.js'; import { IEditorGroupView } from './editor.js'; +import { REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID } from './editorCommands.js'; import './media/breadcrumbscontrol.css'; import { ScrollbarVisibility } from '../../../../base/common/scrollable.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; @@ -211,6 +218,12 @@ export interface IBreadcrumbsControlOptions { readonly showPlaceholder: boolean; readonly dragEditor: boolean; readonly widgetStyles?: IBreadcrumbsWidgetStyles; + /** + * Whether to show a dropdown on the right-hand side that lets the user switch between the editors + * that can open the active resource (e.g. Text Editor vs. Markdown Preview). Only makes sense for + * the dedicated breadcrumbs bar below tabs, not the inline single-tab breadcrumbs. + */ + readonly showEditorTypePicker?: boolean; } const separatorIcon = registerIcon('breadcrumb-separator', Codicon.chevronRight, localize('separatorIcon', 'Icon for the separator in the breadcrumbs.')); @@ -246,11 +259,16 @@ export class BreadcrumbsControl { private readonly _cfUseQuickPick: BreadcrumbsConfig<boolean>; private readonly _cfShowIcons: BreadcrumbsConfig<boolean>; + private readonly _cfShowEditorType: BreadcrumbsConfig<boolean>; private readonly _cfTitleScrollbarSizing: BreadcrumbsConfig<IEditorPartOptions['titleScrollbarSizing']>; private readonly _cfTitleScrollbarVisibility: BreadcrumbsConfig<IEditorPartOptions['titleScrollbarVisibility']>; readonly domNode: HTMLDivElement; private readonly _widget: BreadcrumbsWidget; + private readonly _editorTypeNode: HTMLDivElement; + private readonly _editorTypeLabel: HTMLSpanElement; + private readonly _editorTypeHover: IManagedHover; + private _lastLayoutDimension: dom.Dimension | undefined; private readonly _disposables = new DisposableStore(); private readonly _breadcrumbsDisposables = new DisposableStore(); @@ -270,20 +288,26 @@ export class BreadcrumbsControl { private readonly _editorGroup: IEditorGroupView, @IContextKeyService private readonly _contextKeyService: IContextKeyService, @IContextViewService private readonly _contextViewService: IContextViewService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @IFileService private readonly _fileService: IFileService, @IEditorService private readonly _editorService: IEditorService, + @IEditorResolverService private readonly _editorResolverService: IEditorResolverService, + @ICommandService private readonly _commandService: ICommandService, @ILabelService private readonly _labelService: ILabelService, @IConfigurationService configurationService: IConfigurationService, + @IHoverService private readonly _hoverService: IHoverService, @IBreadcrumbsService breadcrumbsService: IBreadcrumbsService ) { this.domNode = document.createElement('div'); this.domNode.classList.add('breadcrumbs-control'); + this.domNode.classList.toggle('with-editor-type', !!_options.showEditorTypePicker); dom.append(container, this.domNode); this._cfUseQuickPick = BreadcrumbsConfig.UseQuickPick.bindTo(configurationService); this._cfShowIcons = BreadcrumbsConfig.Icons.bindTo(configurationService); + this._cfShowEditorType = BreadcrumbsConfig.ShowEditorType.bindTo(configurationService); this._cfTitleScrollbarSizing = BreadcrumbsConfig.TitleScrollbarSizing.bindTo(configurationService); this._cfTitleScrollbarVisibility = BreadcrumbsConfig.TitleScrollbarVisibility.bindTo(configurationService); @@ -304,6 +328,28 @@ export class BreadcrumbsControl { this._widget.onDidFocusItem(this._onFocusEvent, this, this._disposables); this._widget.onDidChangeFocus(this._updateCkBreadcrumbsActive, this, this._disposables); + // Editor type dropdown (right-aligned). Lets the user switch between the editors that can + // open the active resource (e.g. "Text Editor" vs. "Markdown Preview"). Only shown when a + // more specialized editor is available for the resource. + this._editorTypeNode = document.createElement('div'); + this._editorTypeNode.classList.add('breadcrumbs-editor-type', 'hidden'); + this._editorTypeNode.setAttribute('role', 'button'); + this._editorTypeLabel = document.createElement('span'); + this._editorTypeLabel.classList.add('label'); + this._editorTypeNode.appendChild(this._editorTypeLabel); + const editorTypeChevron = document.createElement('span'); + editorTypeChevron.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + this._editorTypeNode.appendChild(editorTypeChevron); + dom.append(this.domNode, this._editorTypeNode); + this._editorTypeHover = this._disposables.add(this._hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._editorTypeNode, '')); + this._disposables.add(dom.addDisposableListener(this._editorTypeNode, dom.EventType.CLICK, e => { + dom.EventHelper.stop(e, true); + this._showEditorTypePicker(); + })); + if (this._options.showEditorTypePicker) { + this._disposables.add(this._cfShowEditorType.onDidChange(() => this._updateEditorTypeControl())); + } + this._ckBreadcrumbsPossible = BreadcrumbsControl.CK_BreadcrumbsPossible.bindTo(this._contextKeyService); this._ckBreadcrumbsVisible = BreadcrumbsControl.CK_BreadcrumbsVisible.bindTo(this._contextKeyService); this._ckBreadcrumbsActive = BreadcrumbsControl.CK_BreadcrumbsActive.bindTo(this._contextKeyService); @@ -325,6 +371,7 @@ export class BreadcrumbsControl { this._ckBreadcrumbsHasSymbols.reset(); this._cfUseQuickPick.dispose(); this._cfShowIcons.dispose(); + this._cfShowEditorType.dispose(); this._cfTitleScrollbarSizing.dispose(); this._cfTitleScrollbarVisibility.dispose(); this._widget.dispose(); @@ -337,6 +384,15 @@ export class BreadcrumbsControl { } layout(dim: dom.Dimension | undefined): void { + if (dim) { + this._lastLayoutDimension = dim; + } + // When the editor type dropdown is visible it occupies space on the right, so shrink the + // breadcrumbs widget accordingly to avoid it rendering behind the dropdown. + if (dim && !this._editorTypeNode.classList.contains('hidden')) { + const editorTypeWidth = this._editorTypeNode.offsetWidth; + dim = new dom.Dimension(Math.max(0, dim.width - editorTypeWidth), dim.height); + } this._widget.layout(dim); } @@ -351,6 +407,7 @@ export class BreadcrumbsControl { this._ckBreadcrumbsVisible.set(false); this._ckBreadcrumbsHasSymbols.set(false); this.domNode.classList.toggle('hidden', true); + this._editorTypeNode.classList.toggle('hidden', true); if (!wasHidden) { this._onDidVisibilityChange.fire(); @@ -397,6 +454,7 @@ export class BreadcrumbsControl { this.show(); this._ckBreadcrumbsPossible.set(true); + this._updateEditorTypeControl(); const model = this._instantiationService.createInstance(BreadcrumbsModel, fileInfoUri ?? uri, @@ -472,6 +530,137 @@ export class BreadcrumbsControl { return wasHidden !== this.isHidden(); } + /** + * Determines the editors available for the active editor's resource. Returns `undefined` when + * there is nothing meaningful to switch between: no resource, only the default text editor, or an + * exclusive editor (e.g. the hex editor, for which `getEditors` returns an empty list). + */ + private _getAvailableEditors(): { resource: URI; isDiffEditor: boolean; originalResource?: URI; modifiedResource?: URI; currentId: string; editors: RegisteredEditorInfo[] } | undefined { + const activeEditor = this._editorGroup.activeEditor; + const resource = EditorResourceAccessor.getOriginalUri(activeEditor, { supportSideBySide: SideBySideEditor.PRIMARY }); + if (!resource) { + return undefined; + } + const editors = this._editorResolverService.getEditors(resource); + if (editors.length <= 1) { + return undefined; + } + const isDiffEditor = isDiffEditorInput(activeEditor); + return { + resource, + isDiffEditor, + originalResource: isDiffEditor ? activeEditor.original.resource : undefined, + modifiedResource: isDiffEditor ? activeEditor.modified.resource : undefined, + currentId: activeEditor?.editorId ?? DEFAULT_EDITOR_ASSOCIATION.id, + editors + }; + } + + /** + * The label to show for an editor type. In a diff context the default text editor is presented as + * "Text Diff Editor" to match how it actually opens. + */ + private _editorTypeDisplayLabel(editor: RegisteredEditorInfo, isDiffEditor: boolean): string { + if (isDiffEditor && editor.id === DEFAULT_EDITOR_ASSOCIATION.id) { + return localize('textDiffEditor', "Text Diff Editor"); + } + return editor.label; + } + + private _updateEditorTypeControl(): void { + const wasHidden = this._editorTypeNode.classList.contains('hidden'); + const previousWidth = wasHidden ? 0 : this._editorTypeNode.offsetWidth; + + const available = (this._options.showEditorTypePicker && this._cfShowEditorType.getValue()) ? this._getAvailableEditors() : undefined; + if (!available) { + this._editorTypeNode.classList.toggle('hidden', true); + } else { + const current = available.editors.find(editor => editor.id === available.currentId); + const label = current ? this._editorTypeDisplayLabel(current, available.isDiffEditor) : available.currentId; + this._editorTypeLabel.textContent = label; + this._editorTypeHover.update(localize('editorType.hover', "Editor: {0}", label)); + this._editorTypeNode.classList.toggle('hidden', false); + } + + // The dropdown width may have changed (different editor label or visibility toggled). Since the + // breadcrumbs widget uses an explicit pixel width that reserves room for the dropdown, re-run the + // layout so the widget shrinks/grows to match the new dropdown width. + const isHiddenNow = this._editorTypeNode.classList.contains('hidden'); + const currentWidth = isHiddenNow ? 0 : this._editorTypeNode.offsetWidth; + if (this._lastLayoutDimension && currentWidth !== previousWidth) { + this.layout(this._lastLayoutDimension); + } + } + + private _showEditorTypePicker(): void { + const available = this._getAvailableEditors(); + if (!available) { + return; + } + const glob = `*${extUri.extname(available.resource)}`; + // Show the contributing extension in parentheses, but only for extension-provided editors. + // Built-in providers share this localized label, so their (redundant) source is omitted. + const builtinProviderLabel = localize('builtinProviderDisplayName', "Built-in"); + const labelWithSource = (editor: RegisteredEditorInfo) => { + const label = this._editorTypeDisplayLabel(editor, available.isDiffEditor); + return editor.detail && editor.detail !== builtinProviderLabel + ? localize('editorType.labelWithSource', "{0} - {1}", label, editor.detail) + : label; + }; + + // Reopen the active editor with the chosen editor type. The currently active type is checked. + const reopenActions: IAction[] = available.editors.map(editor => toAction({ + id: editor.id, + label: labelWithSource(editor), + checked: editor.id === available.currentId, + run: () => this._commandService.executeCommand(REOPEN_ACTIVE_EDITOR_WITH_COMMAND_ID, editor.id) + })); + + // Persist the chosen editor as the default for this file type. For diffs this updates the + // specialized `workbench.diffEditorAssociations` setting instead of the general one. The + // currently configured default (if any) is checked. + const configuredDefault = this._editorResolverService.getConfiguredDefaultEditor(available.resource, available.isDiffEditor); + const setDefaultActions: IAction[] = available.editors.map(editor => toAction({ + id: `setDefault.${editor.id}`, + label: labelWithSource(editor), + checked: editor.id === configuredDefault, + run: () => this._editorResolverService.updateUserAssociations(glob, editor.id, available.isDiffEditor) + })); + const setDefaultSubmenu = new SubmenuAction( + 'breadcrumbs.editorType.setDefault', + available.isDiffEditor + ? localize('editorType.setDefaultDiff', "Set Default (Diff Only) for '{0}'", glob) + : localize('editorType.setDefault', "Set Default for '{0}'", glob), + setDefaultActions + ); + + const actions: IAction[] = [...reopenActions, new Separator(), setDefaultSubmenu]; + + // For diffs, offer to open either side as a standalone editor. + if (available.isDiffEditor) { + actions.push(new Separator()); + if (available.originalResource) { + actions.push(toAction({ + id: 'breadcrumbs.editorType.openOriginal', + label: localize('editorType.openOriginal', "Open Original"), + run: () => this._editorService.openEditor({ resource: available.originalResource! }) + })); + } + if (available.modifiedResource) { + actions.push(toAction({ + id: 'breadcrumbs.editorType.openModified', + label: localize('editorType.openModified', "Open Modified"), + run: () => this._editorService.openEditor({ resource: available.modifiedResource! }) + })); + } + } + + this._contextMenuService.showContextMenu({ + getAnchor: () => this._editorTypeNode, + getActions: () => actions + }); + } + private _onFocusEvent(event: IBreadcrumbsItemEvent): void { if (event.item && this._breadcrumbsPickerShowing) { this._breadcrumbsPickerIgnoreOnceItem = undefined; diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index 28d0480ab86a0c..469373a27b9e2b 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -5,6 +5,7 @@ import { GroupIdentifier, IWorkbenchEditorConfiguration, IEditorIdentifier, IEditorCloseEvent, IEditorPartOptions, IEditorPartOptionsChangeEvent, SideBySideEditor, EditorCloseContext, IEditorPane, IEditorPartLimitOptions, IEditorPartDecorationOptions, IEditorWillOpenEvent, EditorInputWithOptions } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; +import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IEditorGroup, GroupDirection, IMergeGroupOptions, GroupsOrder, GroupsArrangement, IAuxiliaryEditorPart, IEditorPart, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; import { IDisposable } from '../../../../base/common/lifecycle.js'; import { Dimension } from '../../../../base/browser/dom.js'; @@ -259,6 +260,23 @@ export interface IEditorGroupViewOptions { * after creation or not. */ readonly preserveFocus?: boolean; + + /** + * Optional menu ids used by the group header and tab bar. When unset the + * workbench uses the shared defaults and renders no full-width header. + */ + readonly menuIds?: IEditorGroupMenuIds; +} + +export interface IEditorGroupMenuIds { + /** Menu whose actions render as the leading (left) header toolbar. */ + readonly headerPrimary?: MenuId; + /** Menu whose actions render as the trailing (right) header toolbar. */ + readonly headerSecondary?: MenuId; + /** Menu whose actions render in the editor-actions toolbar on the tab bar. */ + readonly editorActions?: MenuId; + /** Menu shown when right-clicking the empty tab-bar area. */ + readonly tabsBarContext?: MenuId; } /** diff --git a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts index ff6df83a56c0ae..b2739bc4821bf4 100644 --- a/src/vs/workbench/browser/parts/editor/editorConfiguration.ts +++ b/src/vs/workbench/browser/parts/editor/editorConfiguration.ts @@ -9,13 +9,14 @@ import { IWorkbenchContribution } from '../../../common/contributions.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, IConfigurationNode, ConfigurationScope } from '../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../common/configuration.js'; -import { diffEditorsAssociationsSettingId, editorsAssociationsAgentsWindowDefault, editorsAssociationsSettingId, IEditorResolverService, RegisteredEditorInfo, RegisteredEditorPriority, toRegisteredEditorPriorityInfo } from '../../../services/editor/common/editorResolverService.js'; +import { diffEditorsAssociationsSettingId, editorsAssociationsAgentsWindowDefault, editorsAssociationsSettingId, IEditorResolverService, markdownDefaultEditorAgentsWindowSettingId, RegisteredEditorInfo, RegisteredEditorPriority, toRegisteredEditorPriorityInfo } from '../../../services/editor/common/editorResolverService.js'; import { IJSONSchemaMap } from '../../../../base/common/jsonSchema.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { coalesce } from '../../../../base/common/arrays.js'; import { Event } from '../../../../base/common/event.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { ByteSize, getLargeFileConfirmationLimit } from '../../../../platform/files/common/files.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class DynamicEditorConfigurations extends Disposable implements IWorkbenchContribution { @@ -76,7 +77,8 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc constructor( @IEditorResolverService private readonly editorResolverService: IEditorResolverService, @IExtensionService extensionService: IExtensionService, - @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService + @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, + @IConfigurationService private readonly configurationService: IConfigurationService ) { super(); @@ -96,6 +98,13 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc // Registered editors (debounced to reduce perf overhead) this._register(Event.debounce(this.editorResolverService.onDidChangeEditorRegistrations, (_, e) => e)(() => this.updateDynamicEditorConfigurations())); + + // Re-register when the Agents window Markdown default editor setting changes + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(markdownDefaultEditorAgentsWindowSettingId)) { + this.updateDynamicEditorConfigurations(); + } + })); } private updateDynamicEditorConfigurations(): void { @@ -150,6 +159,7 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc // Registers setting for editorAssociations const oldEditorAssociationsConfigurationNode = this.editorAssociationsConfigurationNode; + const markdownDefaultEditorEnabled = this.configurationService.getValue<boolean>(markdownDefaultEditorAgentsWindowSettingId) === true; this.editorAssociationsConfigurationNode = { ...workbenchConfigurationNodeBase, properties: { @@ -163,7 +173,7 @@ export class DynamicEditorConfigurations extends Disposable implements IWorkbenc } }, agentsWindow: { - default: editorsAssociationsAgentsWindowDefault + default: editorsAssociationsAgentsWindowDefault({ markdownDefaultEditor: markdownDefaultEditorEnabled }) } } } diff --git a/src/vs/workbench/browser/parts/editor/editorGroupView.ts b/src/vs/workbench/browser/parts/editor/editorGroupView.ts index 7b3e372a0772a2..5d28f70a9c5b0e 100644 --- a/src/vs/workbench/browser/parts/editor/editorGroupView.ts +++ b/src/vs/workbench/browser/parts/editor/editorGroupView.ts @@ -11,7 +11,7 @@ import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; import { Emitter, Event, Relay } from '../../../../base/common/event.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { Dimension, trackFocus, addDisposableListener, EventType, EventHelper, findParentWithClass, isAncestor, IDomNodePagePosition, isMouseEvent, isActiveElement, getWindow, getActiveElement, $ } from '../../../../base/browser/dom.js'; +import { Dimension, trackFocus, addDisposableListener, EventType, EventHelper, findParentWithClass, isAncestor, IDomNodePagePosition, isMouseEvent, isActiveElement, getWindow, getActiveElement, $, append } from '../../../../base/browser/dom.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; @@ -24,15 +24,16 @@ import { IEditorProgressService } from '../../../../platform/progress/common/pro import { EditorProgressIndicator } from '../../../services/progress/browser/progressIndicator.js'; import { localize } from '../../../../nls.js'; import { coalesce } from '../../../../base/common/arrays.js'; -import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { ITelemetryData, ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { DeferredPromise, Promises, RunOnceWorker } from '../../../../base/common/async.js'; import { EventType as TouchEventType, GestureEvent } from '../../../../base/browser/touch.js'; -import { IEditorGroupsView, IEditorGroupView, fillActiveEditorViewState, EditorServiceImpl, IEditorGroupTitleHeight, IInternalEditorOpenOptions, IInternalMoveCopyOptions, IInternalEditorCloseOptions, IInternalEditorTitleControlOptions, IEditorPartsView, IEditorGroupViewOptions } from './editor.js'; +import { IEditorGroupsView, IEditorGroupView, fillActiveEditorViewState, EditorServiceImpl, IEditorGroupTitleHeight, IInternalEditorOpenOptions, IInternalMoveCopyOptions, IInternalEditorCloseOptions, IInternalEditorTitleControlOptions, IEditorPartsView, IEditorGroupViewOptions, IEditorGroupMenuIds } from './editor.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { SubmenuAction } from '../../../../base/common/actions.js'; import { IMenuChangeEvent, IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; import { StandardMouseEvent } from '../../../../base/browser/mouseEvent.js'; import { getActionBarActions, PrimaryAndSecondaryActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -127,9 +128,36 @@ export class EditorGroupView extends Themable implements IEditorGroupView { private readonly progressBar: ProgressBar; + private readonly headerContainer: HTMLElement; private readonly editorContainer: HTMLElement; private readonly editorPane: EditorPanes; + /** + * Optional inset (in px) reserved on the right of the editor pane while the + * title control keeps the full group width. Used by the Agents window to dock + * the detail panel beside the editor content under one full-width tab bar. + * `0` (default) is a no-op for all other layouts. + */ + private _contentRightInset = 0; + + /** + * Height (in px) of the optional {@link headerContainer} rendered as a flow + * row between the tab bar and the editor pane. Used by the Agents window to + * host a full-width header below the tabs. `0` (default) hides the header. + */ + private _headerHeight = 0; + + /** The group's configured menu ids (see {@link IEditorGroupViewOptions.menuIds}). */ + private readonly _menuIds: IEditorGroupMenuIds | undefined; + + /** Renders and auto-sizes the optional header content (see {@link setHeaderContent}). */ + private readonly _headerContent = this._register(new MutableDisposable()); + private readonly _onDidChangeHeaderHeight = this._register(new Emitter<void>()); + readonly onDidChangeHeaderHeight = this._onDidChangeHeaderHeight.event; + + /** The active editor's declared header toolbars (see {@link IEditorPane.getHeaderActions}). */ + private readonly _editorHeaderContent = this._register(new MutableDisposable()); + private readonly disposedEditorsWorker = this._register(new RunOnceWorker<EditorInput>(editors => this.handleDisposedEditors(editors), 0)); private readonly mapEditorToPendingConfirmation = new Map<EditorInput, Promise<boolean>>(); @@ -165,6 +193,8 @@ export class EditorGroupView extends Themable implements IEditorGroupView { ) { super(themeService); + this._menuIds = options?.menuIds; + if (from instanceof EditorGroupView) { this.model = this._register(from.model.clone()); } else if (isSerializedEditorGroupModel(from)) { @@ -212,7 +242,12 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.element.appendChild(this.titleContainer); // Title control - this.titleControl = this._register(this.scopedInstantiationService.createInstance(EditorTitleControl, this.titleContainer, this.editorPartsView, this.groupsView, this, this.model)); + this.titleControl = this._register(this.scopedInstantiationService.createInstance(EditorTitleControl, this.titleContainer, this.editorPartsView, this.groupsView, this, this.model, this._menuIds)); + + // Header container (optional, below the tab bar; empty by default) + this.headerContainer = $('.editor-group-header'); + this.headerContainer.style.height = '0px'; + this.element.appendChild(this.headerContainer); // Editor container this.editorContainer = $('.editor-container'); @@ -225,6 +260,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Track Focus this.doTrackFocus(); + // Editor header (optional full-width toolbars declared by the active editor) + this._register(this.onDidActiveEditorChange(() => this._renderEditorHeader())); + // Update containers this.updateTitleContainer(); this.updateContainer(); @@ -2171,7 +2209,9 @@ export class EditorGroupView extends Themable implements IEditorGroupView { this.lastLayout = { width, height, top, left }; this.element.classList.toggle('max-height-478px', height <= 478); - // Layout the title control first to receive the size it occupies + // Layout the title control first to receive the size it occupies. The + // title always spans the full group width (so the tab strip and its + // toolbar can extend across any docked right inset). const titleControlSize = this.titleControl.layout({ container: new Dimension(width, height), available: new Dimension(width, height - this.editorPane.minimumHeight) @@ -2180,10 +2220,119 @@ export class EditorGroupView extends Themable implements IEditorGroupView { // Update progress bar location this.progressBar.getContainer().style.top = `${Math.max(this.titleHeight.offset - 2, 0)}px`; - // Pass the container width and remaining height to the editor layout - const editorHeight = Math.max(0, height - titleControlSize.height); + // The editor pane is inset on the right by `_contentRightInset` so a docked + // panel can sit beside it under the full-width title (0 = fill the group). + // The optional header row sits in flow between the tab bar and the editor + // pane, spanning the full group width. + const headerBoxHeight = this._headerHeight; + this.headerContainer.style.display = ''; + this.headerContainer.style.height = `${headerBoxHeight}px`; + + const contentWidth = Math.max(0, width - this._contentRightInset); + const editorHeight = Math.max(0, height - titleControlSize.height - headerBoxHeight); + this.editorContainer.style.width = `${contentWidth}px`; this.editorContainer.style.height = `${editorHeight}px`; - this.editorPane.layout({ width, height: editorHeight, top: top + titleControlSize.height, left }); + this.editorPane.layout({ width: contentWidth, height: editorHeight, top: top + titleControlSize.height + headerBoxHeight, left }); + } + + /** + * Sets the right inset (px) reserved beside the editor pane while the title + * keeps the full group width, then relayouts. `0` restores the default + * full-width content. + */ + setContentRightInset(inset: number): void { + const next = Math.max(0, Math.round(inset)); + if (next === this._contentRightInset) { + return; + } + this._contentRightInset = next; + this.relayout(); + } + + /** The reserved height of the header row (its content height). */ + get headerHeight(): number { + return this._headerHeight; + } + + /** + * Renders caller-provided content into a full-width header row between the tab + * bar and the editor pane, and keeps the row sized to that content (it wraps and + * grows automatically via a `ResizeObserver`, firing {@link onDidChangeHeaderHeight}). + * The returned disposable clears the header. Only one content is shown at a time. + */ + setHeaderContent(render: (container: HTMLElement) => IDisposable): IDisposable { + // Dispose any previous content first, so its cleanup cannot race (and remove) + // the new content node appended below. + this._headerContent.clear(); + + const store = new DisposableStore(); + const content = append(this.headerContainer, $('.editor-group-header-content')); + store.add(render(content)); + + const updateHeight = () => this._setHeaderHeight(content.offsetHeight); + const resizeObserver = new (getWindow(this.headerContainer).ResizeObserver)(() => updateHeight()); + resizeObserver.observe(content); + store.add(toDisposable(() => resizeObserver.disconnect())); + updateHeight(); + + store.add(toDisposable(() => { + content.remove(); + this._setHeaderHeight(0); + })); + + this._headerContent.value = store; + return toDisposable(() => { + if (this._headerContent.value === store) { + this._headerContent.clear(); + } + }); + } + + private _setHeaderHeight(height: number): void { + const next = Math.max(0, Math.round(height)); + if (next === this._headerHeight) { + return; + } + this._headerHeight = next; + this.relayout(); + this._onDidChangeHeaderHeight.fire(); + } + + /** + * Renders the group's configured header menus ({@link IEditorGroupViewOptions.menuIds}) + * as leading/trailing toolbars below the tab bar, but only while the active editor + * opts in ({@link IEditorPane.getHeaderActions}, which supplies the editor-scoped + * instantiation service). The header height follows its rendered content, and + * re-renders whenever the active editor changes. + */ + private _renderEditorHeader(): void { + const menuIds = this._menuIds; + const headerActions = this.activeEditorPane?.getHeaderActions?.(); + if ((!menuIds?.headerPrimary && !menuIds?.headerSecondary) || !headerActions) { + this._editorHeaderContent.clear(); + return; + } + const headerPrimaryMenuId = menuIds.headerPrimary; + const headerSecondaryMenuId = menuIds.headerSecondary; + + this._editorHeaderContent.value = this.setHeaderContent(container => { + const store = new DisposableStore(); + container.classList.add('editor-group-header-toolbars'); + // Keep both containers for the leading/trailing flex layout even when only + // one menu is provided; render a toolbar only for whichever id is defined. + const primaryContainer = append(container, $('.editor-group-header-primary')); + const secondaryContainer = append(container, $('.editor-group-header-secondary')); + + const toolbarOptions = { menuOptions: { shouldForwardArgs: true } }; + if (headerPrimaryMenuId) { + store.add(headerActions.instantiationService.createInstance(MenuWorkbenchToolBar, primaryContainer, headerPrimaryMenuId, toolbarOptions)); + } + if (headerSecondaryMenuId) { + store.add(headerActions.instantiationService.createInstance(MenuWorkbenchToolBar, secondaryContainer, headerSecondaryMenuId, toolbarOptions)); + } + + return store; + }); } relayout(): void { diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index 771cb6b3951883..4ff64da2cae28f 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -240,6 +240,38 @@ export class EditorPart extends Part<IEditorPartMemento> implements IEditorPart, private _contentDimension!: Dimension; get contentDimension(): Dimension { return this._contentDimension; } + private _contentRightInset = 0; + + /** + * Reserves an inset (px) on the right of the editor content of the group(s) at the + * right edge of the editor part, while the title stays full width, so a docked panel + * can sit beside the editor content under one full-width tab bar. Only the right-edge + * groups (no neighbor to the right) are inset; interior groups in a split layout keep + * full-width content. Recomputed when the group topology changes. `0` (default) + * restores full-width content for all groups. + */ + setContentRightInset(inset: number): void { + this._contentRightInset = Math.max(0, Math.round(inset)); + this.applyContentRightInset(); + } + + private applyContentRightInset(): void { + if (!this.gridWidget) { + return; + } + + for (const group of this.groupViews.values()) { + if (!(group instanceof EditorGroupView)) { + continue; + } + + // Only groups at the right edge of the editor part (no neighbor to the right) + // sit under the docked panel overlay; interior groups keep full-width content. + const atRightEdge = this._contentRightInset > 0 && this.gridWidget.getNeighborViews(group, Direction.Right).length === 0; + group.setContentRightInset(atRightEdge ? this._contentRightInset : 0); + } + } + private _activeGroup!: IEditorGroupView; get activeGroup(): IEditorGroupView { return this._activeGroup; @@ -645,16 +677,26 @@ export class EditorPart extends Part<IEditorPartMemento> implements IEditorPart, } } + /** + * Base {@link IEditorGroupViewOptions} applied to every group this part creates. + * Subclasses override to configure part-wide group behavior (e.g. header menus). + */ + protected getGroupViewOptions(): IEditorGroupViewOptions | undefined { + return undefined; + } + private doCreateGroupView(from?: IEditorGroupView | ISerializedEditorGroupModel | null, options?: IEditorGroupViewOptions): IEditorGroupView { + const resolvedOptions: IEditorGroupViewOptions | undefined = { ...this.getGroupViewOptions(), ...options }; + // Create group view let groupView: IEditorGroupView; if (from instanceof EditorGroupView) { - groupView = EditorGroupView.createCopy(from, this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, options); + groupView = EditorGroupView.createCopy(from, this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, resolvedOptions); } else if (isSerializedEditorGroupModel(from)) { - groupView = EditorGroupView.createFromSerialized(from, this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, options); + groupView = EditorGroupView.createFromSerialized(from, this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, resolvedOptions); } else { - groupView = EditorGroupView.createNew(this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, options); + groupView = EditorGroupView.createNew(this.editorPartsView, this, this.groupsLabel, this.count, this.scopedInstantiationService, resolvedOptions); } // Keep in map @@ -1109,14 +1151,22 @@ export class EditorPart extends Part<IEditorPartMemento> implements IEditorPart, this._register(this.onDidAddGroup(() => { updateContextKeys(); updateTopRightGroupContextKey(); + this.applyContentRightInset(); })); this._register(this.onDidRemoveGroup(() => { updateContextKeys(); updateTopRightGroupContextKey(); + this.applyContentRightInset(); + })); + this._register(this.onDidChangeGroupMaximized(() => { + updateContextKeys(); + this.applyContentRightInset(); })); - this._register(this.onDidChangeGroupMaximized(() => updateContextKeys())); this._register(this.onDidChangeEditorPartOptions(() => updateEditorTabsVisibleContext())); - this._register(this.onDidMoveGroup(() => updateTopRightGroupContextKey())); + this._register(this.onDidMoveGroup(() => { + updateTopRightGroupContextKey(); + this.applyContentRightInset(); + })); this._register(this.onDidLayout(() => updateTopRightGroupContextKey())); } @@ -1392,7 +1442,8 @@ export class EditorPart extends Part<IEditorPartMemento> implements IEditorPart, const rightMargin = outerRight ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; width = Math.max(0, width - leftMargin - rightMargin); - height = Math.max(0, height - FLOATING_PANEL_MARGIN); + const { topMargin, bottomMargin } = this.getFloatingPanelHeightInsets(); + height = Math.max(0, height - topMargin - bottomMargin); // Reserve space for the Modern UI editor border (styleOverrides/media/editorBorder.css) so content doesn't get clipped. if (!this.element.classList.contains('modal-editor-part')) { @@ -1413,6 +1464,26 @@ export class EditorPart extends Part<IEditorPartMemento> implements IEditorPart, this.doLayout(Dimension.lift(contentAreaSize), top, left); } + /** + * Returns the top and bottom margins (in pixels) to subtract from the editor height + * when the floating panels experiment is active. Accounts for panel position (a top + * panel pushes the editor down) and status bar visibility (hidden status bar means + * the editor is at the window bottom edge and gets a doubled bottom margin). + */ + private getFloatingPanelHeightInsets(): { topMargin: number; bottomMargin: number } { + const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); + // When the panel is positioned above the editor and visible, the editor is no longer + // adjacent to the title bar — reserve a top margin to match the inter-card gaps. + const panelAtTop = panelVisible && this.layoutService.getPanelPosition() === Position.TOP; + // When the status bar is hidden, the editor is at the window bottom edge — double the + // margin. Exception: when a bottom panel is visible the editor's bottom faces the panel + // card (not the window edge), so keep the normal inter-card gap. + const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; + const bottomMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, mainWindow) && !panelAtBottom + ? FLOATING_PANEL_MARGIN * 2 : FLOATING_PANEL_MARGIN; + return { topMargin: panelAtTop ? FLOATING_PANEL_MARGIN : 0, bottomMargin }; + } + private doLayout(dimension: Dimension, top = this.top, left = this.left): void { this._contentDimension = dimension; diff --git a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts index 2ba6b32bccf356..0ccf6da19e7b95 100644 --- a/src/vs/workbench/browser/parts/editor/editorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTabsControl.ts @@ -13,7 +13,7 @@ import { IAction, ActionRunner } from '../../../../base/common/actions.js'; import { ResolvedKeybinding } from '../../../../base/common/keybindings.js'; import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { createActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; import { IContextKeyService, IContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; @@ -23,7 +23,7 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; import { DraggedEditorGroupIdentifier, DraggedEditorIdentifier, fillEditorsDragData, isWindowDraggedOver } from '../../dnd.js'; import { EditorPane } from './editorPane.js'; -import { IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; +import { IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; import { IEditorCommandsContext, EditorResourceAccessor, IEditorPartOptions, SideBySideEditor, EditorsOrder, EditorInputCapabilities, IToolbarActions, GroupIdentifier, Verbosity } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { ResourceContextKey, ActiveEditorPinnedContext, ActiveEditorStickyContext, ActiveEditorGroupLockedContext, ActiveEditorCanSplitInGroupContext, SideBySideEditorActiveContext, ActiveEditorFirstInGroupContext, ActiveEditorAvailableEditorIdsContext, applyAvailableEditorIds, ActiveEditorLastInGroupContext } from '../../../common/contextkeys.js'; @@ -101,13 +101,20 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC private static readonly EDITOR_TAB_HEIGHT = { normal: 35 as const, - compact: 22 as const + compact: 22 as const, + // Style-override (Modern UI) multi-tab mode adds 4px top + 4px bottom padding to + // the tabs-and-actions-container (tabs.css), so the total title-bar height is the + // --editor-group-tab-height CSS value (24px / 14px) plus that 8px padding. + styleOverride: 32 as const, // 24px tab + 4px top + 4px bottom padding + styleOverrideCompact: 28 as const, // 20px tab + 4px top + 4px bottom padding (20px = minimum to fit 16px icon + 2px padding) }; protected editorActionsToolbarContainer: HTMLElement | undefined; private editorActionsToolbar: WorkbenchToolBar | undefined; private readonly editorActionsToolbarDisposables = this._register(new DisposableStore()); private readonly editorActionsDisposables = this._register(new DisposableStore()); + /** Whether the editor-actions toolbar currently has any actions (drives the layout-actions separator). */ + private editorActionsToolbarHasActions = false; private editorLayoutActionsSeparator: HTMLElement | undefined; protected editorLayoutActionsToolbarContainer: HTMLElement | undefined; @@ -137,6 +144,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC protected readonly groupsView: IEditorGroupsView, protected readonly groupView: IEditorGroupView, protected readonly tabsModel: IReadonlyEditorGroupModel, + protected readonly menuIds: IEditorGroupMenuIds | undefined, @IContextMenuService protected readonly contextMenuService: IContextMenuService, @IInstantiationService protected instantiationService: IInstantiationService, @IContextKeyService protected readonly contextKeyService: IContextKeyService, @@ -146,6 +154,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC @IThemeService themeService: IThemeService, @IEditorResolverService private readonly editorResolverService: IEditorResolverService, @IHostService private readonly hostService: IHostService, + @IMenuService protected readonly menuService: IMenuService, ) { super(themeService); @@ -245,6 +254,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC private doCreateEditorActionsToolBar(container: HTMLElement): void { const context: IEditorCommandsContext = { groupId: this.groupView.id }; + const editorActionsMenuId = this.menuIds?.editorActions ?? MenuId.EditorTitle; // Toolbar Widget this.editorActionsToolbar = this.editorActionsToolbarDisposables.add(this.instantiationService.createInstance(WorkbenchToolBar, container, { @@ -256,7 +266,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC anchorAlignmentProvider: () => AnchorAlignment.RIGHT, renderDropdownAsChildElement: this.renderDropdownAsChildElement, telemetrySource: 'editorPart', - resetMenu: MenuId.EditorTitle, + resetMenu: editorActionsMenuId, overflowBehavior: { maxItems: 9, exempted: EDITOR_CORE_NAVIGATION_COMMANDS }, highlightToggledItems: true })); @@ -329,12 +339,13 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC this.editorActionsDisposables.clear(); - const editorActions = this.groupView.createEditorActions(this.editorActionsDisposables); + const editorActions = this.groupView.createEditorActions(this.editorActionsDisposables, this.menuIds?.editorActions ?? MenuId.EditorTitle); this.editorActionsDisposables.add(editorActions.onDidChange(() => this.updateEditorActionsToolbar())); const editorActionsToolbar = assertReturnsDefined(this.editorActionsToolbar); const { primary, secondary } = this.prepareEditorActions(editorActions.actions); editorActionsToolbar.setActions(prepareActions(primary), prepareActions(secondary)); + this.editorActionsToolbarHasActions = primary.length > 0 || secondary.length > 0; this.updateEditorLayoutActionsToolbar(); } @@ -352,9 +363,11 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC const { primary, secondary } = this.prepareEditorLayoutActions(editorActions.actions); this.editorLayoutActionsToolbar.setActions(prepareActions(primary), prepareActions(secondary)); - // Only show the separator when the layout toolbar actually has actions. + // Only show the separator when the layout toolbar has actions AND there are + // editor actions to its left to separate from. if (this.editorLayoutActionsSeparator) { - setVisibility(primary.length > 0 || secondary.length > 0, this.editorLayoutActionsSeparator); + const hasLayoutActions = primary.length > 0 || secondary.length > 0; + setVisibility(hasLayoutActions && this.editorActionsToolbarHasActions, this.editorLayoutActionsSeparator); } } @@ -373,6 +386,7 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC const editorActionsToolbar = assertReturnsDefined(this.editorActionsToolbar); editorActionsToolbar.setActions([], []); + this.editorActionsToolbarHasActions = false; this.editorLayoutActionsToolbar?.setActions([], []); if (this.editorLayoutActionsSeparator) { @@ -548,7 +562,15 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC } protected get tabHeight() { - return this.groupsView.partOptions.tabHeight !== 'compact' ? EditorTabsControl.EDITOR_TAB_HEIGHT.normal : EditorTabsControl.EDITOR_TAB_HEIGHT.compact; + const isCompact = this.groupsView.partOptions.tabHeight === 'compact'; + // In style-override multi-tab mode the tabs-and-actions-container gains extra + // padding (tabs.css), so the total height differs from the base values. + // The `.tabs` class is present only when showTabs === 'multiple'; single-tab + // and no-tab modes are not affected by those CSS overrides. + if (this.parent.classList.contains('tabs') && this.parent.closest('.style-override')) { + return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.styleOverrideCompact : EditorTabsControl.EDITOR_TAB_HEIGHT.styleOverride; + } + return isCompact ? EditorTabsControl.EDITOR_TAB_HEIGHT.compact : EditorTabsControl.EDITOR_TAB_HEIGHT.normal; } protected getHoverTitle(editor: EditorInput): string | IManagedHoverTooltipMarkdownString { @@ -566,6 +588,9 @@ export abstract class EditorTabsControl extends Themable implements IEditorTabsC protected updateTabHeight(): void { this.parent.style.setProperty('--editor-group-tab-height', `${this.tabHeight}px`); + // Signal compact mode via a CSS class so the style-override rules in tabs.css + // can apply a proportionally smaller --editor-group-tab-height value. + this.parent.classList.toggle('compact-height', this.groupsView.partOptions.tabHeight === 'compact'); } updateOptions(oldOptions: IEditorPartOptions, newOptions: IEditorPartOptions): void { diff --git a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts index 132ca85f1656e8..1fb1c7bd326e38 100644 --- a/src/vs/workbench/browser/parts/editor/editorTitleControl.ts +++ b/src/vs/workbench/browser/parts/editor/editorTitleControl.ts @@ -8,7 +8,7 @@ import { $, Dimension, clearNode } from '../../../../base/browser/dom.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IThemeService, Themable } from '../../../../platform/theme/common/themeService.js'; import { BreadcrumbsControl, BreadcrumbsControlFactory } from './breadcrumbsControl.js'; -import { IEditorGroupsView, IEditorGroupTitleHeight, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; +import { IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupTitleHeight, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; import { IEditorTabsControl } from './editorTabsControl.js'; import { MultiEditorTabsControl } from './multiEditorTabsControl.js'; import { SingleEditorTabsControl } from './singleEditorTabsControl.js'; @@ -48,6 +48,7 @@ export class EditorTitleControl extends Themable { private readonly groupsView: IEditorGroupsView, private readonly groupView: IEditorGroupView, private readonly model: IReadonlyEditorGroupModel, + private readonly menuIds: IEditorGroupMenuIds | undefined, @IInstantiationService private instantiationService: IInstantiationService, @IThemeService themeService: IThemeService ) { @@ -72,7 +73,7 @@ export class EditorTitleControl extends Themable { break; } - const control = this.instantiationService.createInstance(tabsControlType, this.parent, this.editorPartsView, this.groupsView, this.groupView, this.model); + const control = this.instantiationService.createInstance(tabsControlType, this.parent, this.editorPartsView, this.groupsView, this.groupView, this.model, this.menuIds); return this.editorTabsControlDisposable.add(control); } @@ -91,6 +92,7 @@ export class EditorTitleControl extends Themable { showDecorationColors: false, showPlaceholder: true, dragEditor: false, + showEditorTypePicker: true, })); // Breadcrumbs enablement & visibility change have an impact on layout diff --git a/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css b/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css index a80a925d97ad7d..6ecaf6b0c00c3e 100644 --- a/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css @@ -11,6 +11,48 @@ display: none; } +.monaco-workbench .breadcrumbs-control.with-editor-type { + display: flex; + flex-direction: row; + align-items: stretch; +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .monaco-scrollable-element { + flex: 1 1 auto; + min-width: 0; +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .breadcrumbs-editor-type { + flex: 0 0 auto; + display: flex; + align-items: center; + max-width: 40%; + padding: 0 4px 0 8px; + cursor: pointer; + font-size: 11px; + background: var(--vscode-breadcrumb-background); + color: var(--vscode-breadcrumb-foreground); +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .breadcrumbs-editor-type.hidden { + display: none; +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .breadcrumbs-editor-type:hover { + color: var(--vscode-breadcrumb-focusForeground); +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .breadcrumbs-editor-type > .label { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.monaco-workbench .breadcrumbs-control.with-editor-type > .breadcrumbs-editor-type > .codicon { + font-size: 13px; + margin-left: 2px; +} + .monaco-workbench .part.editor > .content .editor-group-container .breadcrumbs-control .monaco-breadcrumb-item.selected .monaco-icon-label, .monaco-workbench .part.editor > .content .editor-group-container .breadcrumbs-control .monaco-breadcrumb-item.focused .monaco-icon-label { text-decoration-line: underline; diff --git a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css index b4162b193cd941..88f9b5089578b3 100644 --- a/src/vs/workbench/browser/parts/editor/media/editorgroupview.css +++ b/src/vs/workbench/browser/parts/editor/media/editorgroupview.css @@ -218,6 +218,75 @@ display: none; } +/* Optional full-width header rendered in flow between the tab bar and the editor + * pane (used by the Agents window single-pane layout). Height is set inline. */ +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header { + position: relative; + box-sizing: border-box; + width: 100%; + overflow: hidden; +} + +/* Menu-driven header content: leading (primary) + trailing (secondary) toolbars, + * wrapping when narrow (the trailing toolbar moves to a top row). */ +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-toolbars { + display: flex; + flex-wrap: wrap-reverse; + align-items: center; + align-content: center; + box-sizing: border-box; + width: 100%; + column-gap: var(--vscode-spacing-size80, 8px); + row-gap: var(--vscode-spacing-size40, 4px); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-toolbars:has(> .editor-group-header-primary:not(.has-no-actions), > .editor-group-header-secondary:not(.has-no-actions)) { + padding: 2px var(--vscode-spacing-size80, 8px); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-primary { + display: flex; + align-items: center; + overflow: hidden; + min-width: 0; + flex: 9999 1 auto; + gap: var(--vscode-spacing-size40, 4px); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + overflow: hidden; + min-width: 0; + flex: 1 2 auto; + gap: var(--vscode-spacing-size40, 4px); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar, +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar > .monaco-action-bar, +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar > .monaco-action-bar > .actions-container, +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar > .monaco-action-bar > .actions-container > .action-item { + display: flex; + flex: 1 1 auto; + min-width: 0; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar > .monaco-action-bar > .actions-container { + justify-content: stretch; +} + +/* Space the individual action items within each header toolbar (the picker, + * diff-stats, etc. sit in one action bar, so the container gap above does not + * reach them) — matches the inter-action spacing of the original header. Scoped + * to the toolbar's own action bar so it never affects nested custom widgets. */ +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-primary > .monaco-toolbar > .monaco-action-bar > .actions-container, +.monaco-workbench .part.editor > .content .editor-group-container > .editor-group-header .editor-group-header-secondary > .monaco-toolbar > .monaco-action-bar > .actions-container { + gap: var(--vscode-spacing-size40, 4px); +} + /* Toolbar */ .monaco-workbench .part.editor > .content .editor-group-container > .editor-group-container-toolbar { diff --git a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css index d57abaaf41293d..3756da20715b50 100644 --- a/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/multieditortabscontrol.css @@ -83,6 +83,22 @@ overflow: scroll !important; } +/* Add tab control */ +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab { + display: flex; + align-items: center; + flex: 0 0 auto; + height: var(--editor-group-tab-height); +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab.hidden { + display: none; +} + +.monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tabs-bar-add-tab .action-label { + margin: 0 4px; +} + .monaco-workbench .part.editor > .content .editor-group-container > .title > .tabs-and-actions-container.wrapping .tabs-container { /* Enable wrapping via flex layout and dynamic height */ diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 7e10c10bff1c9c..522f7967950e3a 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -29,7 +29,7 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart, GroupActivationReason } from '../../../services/editor/common/editorGroupsService.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorService, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from '../../../services/editor/common/editorService.js'; import { EditorPartModalContext, EditorPartModalMaximizedContext, EditorPartModalNavigationContext, EditorPartModalSidebarContext, EditorPartModalSidebarVisibleContext } from '../../../common/contextkeys.js'; import { EditorResourceAccessor, IEditorCommandsContext, SideBySideEditor, Verbosity } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; @@ -115,8 +115,6 @@ const defaultModalEditorAllowableCommands = new Set([ TOGGLE_MODAL_EDITOR_SIDEBAR_COMMAND_ID, ]); -const USE_MODAL_EDITOR_SETTING = 'workbench.editor.useModal'; - export interface ICreateModalEditorPartResult { readonly part: ModalEditorPartImpl; readonly instantiationService: IInstantiationService; @@ -178,10 +176,10 @@ export class ModalEditorPart { } })); - let useModalMode = this.configurationService.getValue<string>(USE_MODAL_EDITOR_SETTING); + let useModalMode = this.configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING); disposables.add(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(USE_MODAL_EDITOR_SETTING)) { - useModalMode = this.configurationService.getValue<string>(USE_MODAL_EDITOR_SETTING); + useModalMode = this.configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING); } })); @@ -948,7 +946,7 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { } enforceModalPartOptions(): void { - const useModalForAll = this.configurationService.getValue<string>(USE_MODAL_EDITOR_SETTING) === 'all'; + const useModalForAll = this.configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING) === 'all'; const editorCount = this.groups.reduce((count, group) => count + group.count, 0); const showTabs = useModalForAll && editorCount > 1 ? 'multiple' : 'none'; diff --git a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts index 1fe80afb1db18d..62b7b8785efc4c 100644 --- a/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiEditorTabsControl.ts @@ -12,13 +12,16 @@ import { computeEditorAriaLabel } from '../../editor.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { EventType as TouchEventType, GestureEvent, Gesture } from '../../../../base/browser/touch.js'; import { KeyCode } from '../../../../base/common/keyCodes.js'; +import { toAction } from '../../../../base/common/actions.js'; import { ResourceLabels, IResourceLabel, DEFAULT_LABELS_CONTAINER } from '../../labels.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { DropdownMenuActionViewItem } from '../../../../base/browser/ui/dropdown/dropdownActionViewItem.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { MenuId } from '../../../../platform/actions/common/actions.js'; +import { IMenuService, MenuId } from '../../../../platform/actions/common/actions.js'; +import { getFlatActionBarActions } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { EditorCommandsContextActionRunner, EditorTabsControl } from './editorTabsControl.js'; import { IQuickInputService } from '../../../../platform/quickinput/common/quickInput.js'; import { IDisposable, dispose, DisposableStore, combinedDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -34,7 +37,7 @@ import { INotificationService } from '../../../../platform/notification/common/n import { MergeGroupMode, IMergeGroupOptions } from '../../../services/editor/common/editorGroupsService.js'; import { addDisposableListener, EventType, EventHelper, Dimension, scheduleAtNextAnimationFrame, findParentWithClass, clearNode, DragAndDropObserver, isMouseEvent, getWindow, $ } from '../../../../base/browser/dom.js'; import { localize } from '../../../../nls.js'; -import { IEditorGroupsView, EditorServiceImpl, IEditorGroupView, IInternalEditorOpenOptions, IEditorPartsView, prepareMoveCopyEditors } from './editor.js'; +import { IEditorGroupMenuIds, IEditorGroupsView, EditorServiceImpl, IEditorGroupView, IInternalEditorOpenOptions, IEditorPartsView, prepareMoveCopyEditors } from './editor.js'; import { CloseEditorTabAction, UnpinEditorAction } from './editorActions.js'; import { assertReturnsAllDefined, assertReturnsDefined } from '../../../../base/common/types.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -58,6 +61,8 @@ import { IReadonlyEditorGroupModel } from '../../../common/editor/editorGroupMod import { IHostService } from '../../../services/host/browser/host.js'; import { BugIndicatingError } from '../../../../base/common/errors.js'; import { applyDragImage } from '../../../../base/browser/ui/dnd/dnd.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; interface IEditorInputLabel { readonly editor: EditorInput; @@ -109,6 +114,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { private tabsAndActionsContainer: HTMLElement | undefined; private tabsContainer: HTMLElement | undefined; private tabsScrollbar: ScrollableElement | undefined; + private addTabContainer: HTMLElement | undefined; private tabSizingFixedDisposables: DisposableStore | undefined; private readonly closeEditorAction = this._register(this.instantiationService.createInstance(CloseEditorTabAction, CloseEditorTabAction.ID, CloseEditorTabAction.LABEL)); @@ -140,6 +146,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { groupsView: IEditorGroupsView, groupView: IEditorGroupView, tabsModel: IReadonlyEditorGroupModel, + menuIds: IEditorGroupMenuIds | undefined, @IContextMenuService contextMenuService: IContextMenuService, @IInstantiationService instantiationService: IInstantiationService, @IContextKeyService contextKeyService: IContextKeyService, @@ -152,8 +159,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { @ITreeViewsDnDService private readonly treeViewsDragAndDropService: ITreeViewsDnDService, @IEditorResolverService editorResolverService: IEditorResolverService, @IHostService hostService: IHostService, + @IMenuService menuService: IMenuService, ) { - super(parent, editorPartsView, groupsView, groupView, tabsModel, contextMenuService, instantiationService, contextKeyService, keybindingService, notificationService, quickInputService, themeService, editorResolverService, hostService); + super(parent, editorPartsView, groupsView, groupView, tabsModel, menuIds, contextMenuService, instantiationService, contextKeyService, keybindingService, notificationService, quickInputService, themeService, editorResolverService, hostService, menuService); // Resolve the correct path library for the OS we are on // If we are connected to remote, this accounts for the @@ -190,6 +198,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Tabs Container listeners this.registerTabsContainerListeners(this.tabsContainer, this.tabsScrollbar); + // Create add tab control + this.createAddTabControl(); + // Create Editor Toolbar this.createEditorActionsToolBar(this.tabsAndActionsContainer, ['editor-actions']); @@ -199,6 +210,53 @@ export class MultiEditorTabsControl extends EditorTabsControl { return this.tabsAndActionsContainer; } + private createAddTabControl(): void { + const tabsContainer = assertReturnsDefined(this.tabsContainer); + const container = $('.tabs-bar-add-tab'); + tabsContainer.appendChild(container); + this.addTabContainer = container; + + const menu = this._register(this.menuService.createMenu(MenuId.EditorTabsBarAddTab, this.contextKeyService)); + const getActions = () => getFlatActionBarActions(menu.getActions({ shouldForwardArgs: true })); + + const addTabAction = toAction({ + id: 'editor.tabs.addTab', + label: localize('addTab', "Add Tab"), + class: ThemeIcon.asClassName(Codicon.add), + run: () => { } + }); + + const dropdown = this._register(new DropdownMenuActionViewItem(addTabAction, { getActions }, this.contextMenuService, { + classNames: ThemeIcon.asClassNameArray(Codicon.add) + })); + dropdown.render(container); + + const updateVisibility = () => this.addTabContainer?.classList.toggle('hidden', getActions().length === 0); + updateVisibility(); + this._register(menu.onDidChange(() => updateVisibility())); + } + + private get tabCount(): number { + const tabsContainer = assertReturnsDefined(this.tabsContainer); + return this.addTabContainer ? tabsContainer.children.length - 1 : tabsContainer.children.length; + } + + private appendTab(tab: HTMLElement, tabsContainer: HTMLElement): void { + if (this.addTabContainer) { + tabsContainer.insertBefore(tab, this.addTabContainer); + } else { + tabsContainer.appendChild(tab); + } + } + + private removeLastTab(tabsContainer: HTMLElement): void { + if (this.addTabContainer) { + this.addTabContainer.previousElementSibling?.remove(); + } else { + tabsContainer.lastChild?.remove(); + } + } + private createTabsScrollbar(scrollable: HTMLElement): ScrollableElement { const tabsScrollbar = this._register(new ScrollableElement(scrollable, { horizontal: this.getTabsScrollbarVisibility(), @@ -470,7 +528,7 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Show it this.contextMenuService.showContextMenu({ getAnchor: () => anchor, - menuId: MenuId.EditorTabsBarContext, + menuId: this.menuIds?.tabsBarContext ?? MenuId.EditorTabsBarContext, contextKeyService: this.contextKeyService, menuActionOptions: { shouldForwardArgs: true }, getActionsContext: () => ({ groupId: this.groupView.id }), @@ -520,8 +578,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Create tabs as needed const [tabsContainer, tabsScrollbar] = assertReturnsAllDefined(this.tabsContainer, this.tabsScrollbar); - for (let i = tabsContainer.children.length; i < this.tabsModel.count; i++) { - tabsContainer.appendChild(this.createTab(i, tabsContainer, tabsScrollbar)); + for (let i = this.tabCount; i < this.tabsModel.count; i++) { + this.appendTab(this.createTab(i, tabsContainer, tabsScrollbar), tabsContainer); } // Make sure to recompute tab labels and detect @@ -607,10 +665,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { // Remove tabs that got closed const tabsContainer = assertReturnsDefined(this.tabsContainer); - while (tabsContainer.children.length > this.tabsModel.count) { + while (this.tabCount > this.tabsModel.count) { // Remove one tab from container (must be the last to keep indexes in order!) - tabsContainer.lastChild?.remove(); + this.removeLastTab(tabsContainer); // Remove associated tab label and widget dispose(this.tabDisposables.pop()); @@ -627,6 +685,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { else { if (this.tabsContainer) { clearNode(this.tabsContainer); + if (this.addTabContainer) { + this.tabsContainer.appendChild(this.addTabContainer); + } } this.tabDisposables = dispose(this.tabDisposables); @@ -1860,8 +1921,8 @@ export class MultiEditorTabsControl extends EditorTabsControl { const newDimension = this.dimensions.used = new Dimension(dimensions.container.width, this.computeHeight()); // In case the height of the title control changed from before - // (currently only possible if wrapping changed on/off), we need - // to signal this to the outside via a `relayout` call so that + // (e.g. when wrapping toggles on/off or the tab height setting changes), + // we need to signal this to the outside via a `relayout` call so that // e.g. the editor control can be adjusted accordingly. if (oldDimension && oldDimension.height !== newDimension.height) { this.groupView.relayout(); @@ -1988,6 +2049,9 @@ export class MultiEditorTabsControl extends EditorTabsControl { let currentTabsPosY: number | undefined = undefined; let lastTab: HTMLElement | undefined = undefined; for (const child of tabsContainer.children) { + if (child === this.addTabContainer) { + continue; + } const tab = child as HTMLElement; const tabPosY = tab.offsetTop; @@ -2275,6 +2339,10 @@ export class MultiEditorTabsControl extends EditorTabsControl { if (this.isMoveOperation(e, de.identifier.groupId, editor)) { sourceGroup.moveEditor(editor, this.groupView, { ...options, index: targetEditorIndex }); + + if (this.tabsModel instanceof UnstickyEditorGroupModel && this.groupView.isSticky(editor)) { + this.groupView.unstickEditor(editor); + } } else { sourceGroup.copyEditor(editor, this.groupView, { ...options, index: targetEditorIndex }); } diff --git a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts index d4d58f1ef55f6f..667082db3544ff 100644 --- a/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts +++ b/src/vs/workbench/browser/parts/editor/multiRowEditorTabsControl.ts @@ -5,7 +5,7 @@ import { Dimension } from '../../../../base/browser/dom.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; +import { IEditorGroupMenuIds, IEditorGroupsView, IEditorGroupView, IEditorPartsView, IInternalEditorOpenOptions } from './editor.js'; import { IEditorTabsControl } from './editorTabsControl.js'; import { MultiEditorTabsControl } from './multiEditorTabsControl.js'; import { IEditorPartOptions } from '../../../common/editor.js'; @@ -28,6 +28,7 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont private readonly groupsView: IEditorGroupsView, private readonly groupView: IEditorGroupView, private readonly model: IReadonlyEditorGroupModel, + private readonly menuIds: IEditorGroupMenuIds | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService ) { super(); @@ -35,8 +36,8 @@ export class MultiRowEditorControl extends Disposable implements IEditorTabsCont const stickyModel = this._register(new StickyEditorGroupModel(this.model)); const unstickyModel = this._register(new UnstickyEditorGroupModel(this.model)); - this.stickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, stickyModel)); - this.unstickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, unstickyModel)); + this.stickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, stickyModel, this.menuIds)); + this.unstickyEditorTabsControl = this._register(this.instantiationService.createInstance(MultiEditorTabsControl, this.parent, editorPartsView, this.groupsView, this.groupView, unstickyModel, this.menuIds)); this.handleTabBarsStateChange(); } diff --git a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts index 34056393bbef4a..15c3d0787a75c1 100644 --- a/src/vs/workbench/browser/parts/editor/textDiffEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textDiffEditor.ts @@ -9,7 +9,7 @@ import { isObject, assertReturnsDefined } from '../../../../base/common/types.js import { ICodeEditor, IDiffEditor } from '../../../../editor/browser/editorBrowser.js'; import { IDiffEditorOptions, IEditorOptions as ICodeEditorOptions } from '../../../../editor/common/config/editorOptions.js'; import { AbstractTextEditor, IEditorConfiguration } from './textEditor.js'; -import { TEXT_DIFF_EDITOR_ID, IEditorFactoryRegistry, EditorExtensions, ITextDiffEditorPane, IEditorOpenContext, isEditorInput, isTextEditorViewState, createTooLargeFileError } from '../../../common/editor.js'; +import { TEXT_DIFF_EDITOR_ID, IEditorFactoryRegistry, EditorExtensions, ITextDiffEditorPane, IEditorOpenContext, isEditorInput, isEditorInputWithOptionsAndGroup, isTextEditorViewState, createTooLargeFileError } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { applyTextEditorOptions } from '../../../common/editor/editorOptions.js'; import { DiffEditorInput } from '../../../common/editor/diffEditorInput.js'; @@ -25,6 +25,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IEditorGroup, IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { IEditorResolverService } from '../../../services/editor/common/editorResolverService.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { EditorActivation, ITextEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; @@ -68,7 +69,8 @@ export class TextDiffEditor extends AbstractTextEditor<IDiffEditorViewState> imp @IThemeService themeService: IThemeService, @IEditorGroupsService editorGroupService: IEditorGroupsService, @IFileService fileService: IFileService, - @IPreferencesService private readonly preferencesService: IPreferencesService + @IPreferencesService private readonly preferencesService: IPreferencesService, + @IEditorResolverService private readonly editorResolverService: IEditorResolverService ) { super(TextDiffEditor.ID, group, telemetryService, instantiationService, storageService, configurationService, themeService, editorService, editorGroupService, fileService); } @@ -117,7 +119,7 @@ export class TextDiffEditor extends AbstractTextEditor<IDiffEditorViewState> imp // Fallback to open as binary if not text if (!(resolvedModel instanceof TextDiffEditorModel)) { - this.openAsBinary(input, options); + await this.openAsBinary(input, options); return undefined; } @@ -207,10 +209,43 @@ export class TextDiffEditor extends AbstractTextEditor<IDiffEditorViewState> imp return false; } - private openAsBinary(input: DiffEditorInput, options: ITextEditorOptions | undefined): void { + private async openAsBinary(input: DiffEditorInput, options: ITextEditorOptions | undefined): Promise<void> { const original = input.original; const modified = input.modified; + // The text diff editor cannot render binary content. Before falling back to the generic binary + // "cannot display" panel, check whether a custom editor can render a diff for this resource and + // use it instead. This intentionally includes editors that opted out of diffs via a `never` + // priority: they opt out for text files, but a custom diff editor is strictly better than the + // binary fallback when the content is binary (e.g. an image or hex diff editor). + const modifiedResource = modified.resource; + if (modifiedResource) { + const fallbackEditorId = this.editorResolverService.getBinaryDiffFallbackEditor(modifiedResource); + const originalResource = original.resource; + if (fallbackEditorId && originalResource) { + const resolved = await this.editorResolverService.resolveEditor({ + original: { resource: originalResource }, + modified: { resource: modifiedResource }, + // Passing an explicit `override` bypasses the automatic `never` filtering and the diff + // special-casing, so the resolver returns the custom diff editor directly. + options: { ...options, override: fallbackEditorId } + }, this.group); + if (isEditorInputWithOptionsAndGroup(resolved)) { + this.group.replaceEditors([{ + editor: input, + replacement: resolved.editor, + options: { + ...resolved.options, + activation: EditorActivation.PRESERVE, + pinned: this.group.isPinned(input), + sticky: this.group.isSticky(input) + } + }]); + return; + } + } + } + const binaryDiffInput = this.instantiationService.createInstance(DiffEditorInput, input.getName(), input.getDescription(), original, modified, true); // Forward binary flag to input if supported diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index 9a6763399ae701..1763669da6a1e7 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -12,7 +12,7 @@ import { IPaneComposite } from '../../common/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../common/views.js'; import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; -import { IWorkbenchLayoutService, Parts, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges } from '../../services/layout/browser/layoutService.js'; +import { IWorkbenchLayoutService, Parts, Position, SINGLE_WINDOW_PARTS, FLOATING_PANEL_MARGIN, getFloatingOuterGutterEdges, getFloatingSidebarSiblingToEditorStatus } from '../../services/layout/browser/layoutService.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; @@ -639,6 +639,59 @@ export abstract class AbstractPaneCompositePart extends CompositePart<PaneCompos return this.floatingLayoutDimension ?? super.getRelayoutDimension(); } + /** + * Returns true when this sidebar/aux bar is in the same grid row as the editor + * (a sibling), meaning it abuts the panel above or below rather than the window edge. + * Delegates to the shared formula exported from layoutService.ts. + */ + private isSidebarSiblingToEditor(): boolean { + const { sideBar, auxBar } = getFloatingSidebarSiblingToEditorStatus(this.layoutService); + return this.partId === Parts.SIDEBAR_PART ? sideBar : auxBar; + } + + /** + * Returns the top margin (in pixels) this part should receive when floating panels + * are enabled. Only the bottom-panel and sibling side bars (when the panel is at the + * top) need a top margin; all other parts sit flush with the title bar. + */ + private getFloatingPartTopMargin(panelVisible: boolean, margin: number): number { + // Bottom panel: needs a top margin only when the editor is visible (inter-card gap). + // When maximized (editor hidden) the panel is flush with the title bar — no top margin. + if (this.partId === Parts.PANEL_PART && this.layoutService.getPanelPosition() === Position.BOTTOM) { + return this.layoutService.isVisible(Parts.EDITOR_PART, getWindow(this.element)) ? margin : 0; + } + // Sidebar / aux bar that is in the same grid row as the editor (sibling) and the panel + // is at the top: needs a top margin matching the editor's gap from the panel card. + if (panelVisible && + this.layoutService.getPanelPosition() === Position.TOP && + (this.partId === Parts.SIDEBAR_PART || this.partId === Parts.AUXILIARYBAR_PART) && + this.isSidebarSiblingToEditor()) { + return margin; + } + return 0; + } + + /** + * Returns whether this part's bottom edge faces the window edge rather than another + * floating card. When the status bar is hidden and this returns `true`, a doubled + * bottom margin is applied so the outer gap matches the doubled side gutters. + */ + private isFloatingPartAtWindowBottomEdge(panelVisible: boolean): boolean { + // Panel at TOP: its bottom faces the editor card, not the window edge. + if (this.partId === Parts.PANEL_PART && this.layoutService.getPanelPosition() === Position.TOP) { + return false; + } + // A sidebar/aux bar that is a sibling to the editor sits above a bottom panel row, + // so its bottom faces the panel card rather than the window edge. + const panelAtBottom = panelVisible && this.layoutService.getPanelPosition() === Position.BOTTOM; + if (panelAtBottom && + (this.partId === Parts.SIDEBAR_PART || this.partId === Parts.AUXILIARYBAR_PART) && + this.isSidebarSiblingToEditor()) { + return false; + } + return true; + } + /** * Amount (in pixels) to subtract from each axis when the floating panels * experiment is enabled: a margin on each side plus a 1px border on each side @@ -655,13 +708,17 @@ export abstract class AbstractPaneCompositePart extends CompositePart<PaneCompos const borderTotal = 2; // 1px border on each side const margin = FLOATING_PANEL_MARGIN; - const topMargin = this.partId === Parts.PANEL_PART ? margin : 0; // side bars are flush with the title bar + const panelVisible = this.layoutService.isVisible(Parts.PANEL_PART); + const topMargin = this.getFloatingPartTopMargin(panelVisible, margin); + const isAtWindowBottom = this.isFloatingPartAtWindowBottomEdge(panelVisible); + const bottomMargin = !this.layoutService.isVisible(Parts.STATUSBAR_PART, getWindow(this.element)) && isAtWindowBottom + ? margin * 2 : margin; const outerGutter = this.getFloatingOuterGutterEdges(); const leftMargin = outerGutter.left ? margin * 2 : margin; const rightMargin = outerGutter.right ? margin * 2 : margin; return { width: leftMargin + rightMargin + borderTotal, - height: topMargin + margin + borderTotal + height: topMargin + bottomMargin + borderTotal }; } diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index c472fe1e2f0917..4353f96dbfd865 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -409,6 +409,9 @@ export class CustomMenubarControl extends MenubarControl { case StateType.Updating: return toAction({ id: 'update.updating', label: localize('installingUpdate', "Installing Update..."), enabled: false, run: () => { } }); + case StateType.Cancelling: + return toAction({ id: 'update.cancelling', label: localize('cancellingUpdate', "Cancelling Update..."), enabled: false, run: () => { } }); + case StateType.Ready: return toAction({ id: 'update.restart', label: localize({ key: 'restartToUpdate', comment: ['&& denotes a mnemonic'] }, "Restart to &&Update"), enabled: true, run: () => diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index c2ae0b4a117847..bf917583d16166 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -1475,6 +1475,8 @@ class TreeRenderer extends Disposable implements ITreeRenderer<ITreeItem, FuzzyS iconClass = ThemeIcon.asClassName(node.themeIcon); if (node.themeIcon.color) { templateData.icon.style.color = this.themeService.getColorTheme().getColor(node.themeIcon.color.id)?.toString() ?? ''; + } else { + iconClass = iconClass + ' codicon-colored'; } } templateData.icon.className = iconClass ? `custom-view-tree-node-item-icon ${iconClass}` : ''; @@ -1679,6 +1681,12 @@ class Aligner extends Disposable { if (icon) { return true; } + // `file` and `folder` ThemeIcons defer to the file icon theme only when the item has a resource. + // Any other ThemeIcon, or a `file`/`folder` ThemeIcon on an item without a resource, is always + // rendered as a codicon and therefore always has an icon regardless of the file icon theme. + if (node.themeIcon && (!node.resourceUri || (node.themeIcon.id !== FileThemeIcon.id && node.themeIcon.id !== FolderThemeIcon.id))) { + return true; + } if (node.resourceUri || node.themeIcon) { const fileIconTheme = this.themeService.getFileIconTheme(); const isFolder = node.themeIcon ? node.themeIcon.id === FolderThemeIcon.id : node.collapsibleState !== TreeItemCollapsibleState.None; diff --git a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts index 45e2b137b20765..72b446e0156413 100644 --- a/src/vs/workbench/browser/parts/views/viewPaneContainer.ts +++ b/src/vs/workbench/browser/parts/views/viewPaneContainer.ts @@ -38,7 +38,7 @@ import { IAddedViewDescriptorRef, ICustomViewDescriptor, IView, IViewContainerMo import { IViewsService } from '../../../services/views/common/viewsService.js'; import { FocusedViewContext } from '../../../common/contextkeys.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; -import { isHorizontal, IWorkbenchLayoutService, LayoutSettings } from '../../../services/layout/browser/layoutService.js'; +import { isHorizontal, IWorkbenchLayoutService, LayoutSettings, FLOATING_PANEL_MARGIN, Position } from '../../../services/layout/browser/layoutService.js'; import { IBaseActionViewItemOptions } from '../../../../base/browser/ui/actionbar/actionViewItems.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ViewContainerMenuActions } from './viewMenuActions.js'; @@ -628,7 +628,21 @@ export class ViewPaneContainer<MementoType extends object = object> extends Comp this.paneview.flipOrientation(dimension.height, dimension.width); } - this.paneview.layout(dimension.height, dimension.width); + // In Modern UI (floating panels) reserve a small bottom gap so the last + // pane does not sit flush against the part edge, matching the 4px + // horizontal margins on the pane headers. Add 1px for the part's bottom + // border so the visible gap lines up with the horizontal margins. + // Exception: when the panel is at the TOP, the bottom of the panel + // faces the editor card. A 1px inner gap keeps the pane content off the + // border, while the CSS inter-card margins (panel 4px + editor 4px) + // provide the remaining separation. This totals 10px (1 inner + 1 border + // + 4 + 4), matching the bottom panel's bottom-to-status-bar gap + // (5 inner + 1 border + 4 CSS = 10px) for visual consistency. + const bottomGap = !this.layoutService.isFloatingPanelsEnabled() ? 0 + : (this.viewDescriptorService.getViewContainerLocation(this.viewContainer) === ViewContainerLocation.Panel + && this.layoutService.getPanelPosition() === Position.TOP) ? 1 + : FLOATING_PANEL_MARGIN + 1; + this.paneview.layout(Math.max(0, dimension.height - bottomGap), dimension.width); } this.dimension = dimension; diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index f2a3b35e6eac48..8e79bdaecc2607 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -364,7 +364,9 @@ const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Con ], 'description': localize('useModal', "Controls whether editors open in a modal overlay."), 'default': 'some', - agentsWindow: { default: 'all' }, + experiment: { + mode: 'startup' + } }, 'workbench.editor.swipeToNavigate': { 'type': 'boolean', @@ -812,6 +814,7 @@ const registry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Con 'default': false, 'tags': ['experimental'], 'description': localize('modernUI', "Controls whether the experimental Modern UI Update is enabled. When on, the side bars and bottom panel are shown as floating cards with rounded corners and gaps, and a set of refreshed workbench styles is applied, matching the Agents window design."), + experiment: { mode: 'auto' }, }, } }); diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index ae10cc6224b392..2f0188a6aff9e6 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -82,6 +82,15 @@ export interface IEditorDescriptor<T extends IEditorPane> { describes(editorPane: T): boolean; } +/** + * Declares that an editor hosts the full-width group header (rendered by the + * editor group below the tab bar, using the group's configured header menus). + */ +export interface IEditorHeaderActions { + /** Editor-scoped instantiation service so the header toolbars' `when` clauses see the editor's context. */ + readonly instantiationService: IInstantiationService; +} + /** * The editor pane is the container for workbench editors. */ @@ -176,6 +185,15 @@ export interface IEditorPane extends IComposite { */ getViewState(): object | undefined; + /** + * An optional method to declare that this editor hosts the full-width group + * header (rendered by the editor group below the tab bar using the group's + * configured header menus), providing the editor-scoped instantiation service + * so the header actions' `when` clauses evaluate in the editor's context. + * Return `undefined` for no header (the default). + */ + getHeaderActions?(): IEditorHeaderActions | undefined; + /** * An optional method to return the current selection in * the editor pane in case the editor pane has a selection diff --git a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts index b7aba2326e6bc8..aa900f30f6ce5a 100644 --- a/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts +++ b/src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts @@ -69,8 +69,10 @@ export const enum AccessibilityVerbositySettingId { SourceControl = 'accessibility.verbosity.sourceControl', Find = 'accessibility.verbosity.find', SessionsChat = 'accessibility.verbosity.sessionsChat', + SessionsChanges = 'accessibility.verbosity.sessionsChanges', ChatQuestionCarousel = 'accessibility.verbosity.chatQuestionCarousel', - Survey = 'accessibility.verbosity.survey' + Survey = 'accessibility.verbosity.survey', + Automations = 'accessibility.verbosity.automations' } const baseVerbosityProperty: IConfigurationPropertySchema = { @@ -212,6 +214,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.sessionsChat', 'Provide information about how to access the Agents window accessibility help menu when the chat input is focused.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.SessionsChanges]: { + description: localize('verbosity.sessionsChanges', 'Provide information about how to access the Changes view accessibility help menu when the Changes view is focused.'), + ...baseVerbosityProperty + }, [AccessibilityVerbositySettingId.ChatQuestionCarousel]: { description: localize('verbosity.chatQuestionCarousel', 'Provide information about how to navigate and interact with the chat question carousel, including how to focus the terminal when applicable.'), ...baseVerbosityProperty @@ -220,6 +226,10 @@ const configuration: IConfigurationNode = { description: localize('verbosity.survey', 'Provide information about how to navigate and interact with the survey editor pane.'), ...baseVerbosityProperty }, + [AccessibilityVerbositySettingId.Automations]: { + description: localize('verbosity.automations', 'Provide information about how to use the Automations section of the Agent Customizations editor, including keyboard navigation and how to inspect scheduled runs.'), + ...baseVerbosityProperty + }, 'accessibility.signalOptions.volume': { 'description': localize('accessibility.signalOptions.volume', "The volume of the sounds in percent (0-100)."), 'type': 'number', diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts index 4c71956bed25d4..613713638f4e60 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoice.contribution.ts @@ -41,8 +41,10 @@ import { import { mainWindow } from '../../../../base/browser/window.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; +import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { ChatAgentLocation } from '../../chat/common/constants.js'; import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; // --- Context Keys --- @@ -50,7 +52,6 @@ export const AGENTS_VOICE_WIDGET_FOCUSED = new RawContextKey<boolean>('agentsVoi const AGENTS_VOICE_CONNECTED = new RawContextKey<boolean>('agentsVoiceConnected', false); const AGENTS_VOICE_CONNECTING = new RawContextKey<boolean>('agentsVoiceConnecting', false); const AGENTS_VOICE_LISTENING = new RawContextKey<boolean>('agentsVoiceListening', false); -const AGENTS_VOICE_ACTIVE = new RawContextKey<boolean>('agentsVoiceActive', false); /** Set on the specific widget where voice was initiated — used to scope connecting/connected UI to that widget only. */ const AGENTS_VOICE_INITIATED_HERE = new RawContextKey<boolean>('agentsVoiceInitiatedHere', false); @@ -72,7 +73,6 @@ class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkben const connectedKey = AGENTS_VOICE_CONNECTED.bindTo(contextKeyService); const connectingKey = AGENTS_VOICE_CONNECTING.bindTo(contextKeyService); const listeningKey = AGENTS_VOICE_LISTENING.bindTo(contextKeyService); - const activeKey = AGENTS_VOICE_ACTIVE.bindTo(contextKeyService); let wasConnected = false; this._register(autorun(reader => { const connected = voiceSessionController.isConnected.read(reader); @@ -80,7 +80,6 @@ class AgentsVoiceConnectedKeyContribution extends Disposable implements IWorkben connectingKey.set(voiceSessionController.isConnecting.read(reader)); const state = voiceSessionController.voiceState.read(reader); listeningKey.set(state === 'listening'); - activeKey.set(state === 'listening' || state === 'speaking'); // Clear per-widget "initiated here" key when voice disconnects if (wasConnected && !connected) { @@ -174,7 +173,7 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.currentlyEditing.negate(), - AGENTS_VOICE_ACTIVE.negate(), + AGENTS_VOICE_LISTENING.negate(), AGENTS_VOICE_CONNECTING.negate(), ), group: 'navigation', @@ -215,7 +214,7 @@ registerAction2(class extends Action2 { icon: Codicon.voiceMode, precondition: ContextKeyExpr.and( ContextKeyExpr.equals('config.agents.voice.enabled', true), - AGENTS_VOICE_ACTIVE.isEqualTo(true), + AGENTS_VOICE_LISTENING.isEqualTo(true), ), menu: { id: MenuId.ChatExecute, @@ -223,7 +222,7 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), ChatContextKeys.currentlyEditing.negate(), - AGENTS_VOICE_ACTIVE.isEqualTo(true), + AGENTS_VOICE_LISTENING.isEqualTo(true), AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), ), group: 'navigation', @@ -235,24 +234,17 @@ registerAction2(class extends Action2 { when: ContextKeyExpr.and( ContextKeyExpr.equals('config.agents.voice.enabled', true), ChatContextKeys.inChatInput, - AGENTS_VOICE_ACTIVE.isEqualTo(true), + AGENTS_VOICE_LISTENING.isEqualTo(true), ), }, }); } async run(accessor: ServicesAccessor): Promise<void> { const voiceController = accessor.get(IVoiceSessionController); - // In auto-send mode, toggling voice mode off disconnects entirely. - // The auto-listen loop means there's no natural "idle" state to return to. - const configService = accessor.get(IConfigurationService); - const autoSendDelay = configService.getValue<number>('agents.voice.autoSendDelay') ?? 500; - if (autoSendDelay >= 0) { - voiceController.disconnect(); - } else { - // Manual mode: just stop recording - voiceController.pttDown(); - voiceController.pttUp(); - } + // Stop recording and the auto-listen loop but keep the WebSocket + // connected so the user can resume without reconnecting. Use the + // separate "Disconnect Voice Mode" button to fully end the session. + voiceController.stopListening(); } }); @@ -269,6 +261,33 @@ registerAction2(class extends Action2 { ContextKeyExpr.equals('config.agents.voice.enabled', true), AGENTS_VOICE_CONNECTED.isEqualTo(true), ), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.currentlyEditing.negate(), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), + ), + group: 'navigation', + order: -9 + }, + keybinding: { + // Keep this below the editor widgets and negate their contexts so + // Escape still dismisses IntelliSense/hover and clears selections + // while the user is typing in the chat input. + weight: KeybindingWeight.EditorContrib - 5, + primary: KeyCode.Escape, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.inChatInput, + AGENTS_VOICE_CONNECTED.isEqualTo(true), + EditorContextKeys.hoverVisible.toNegated(), + EditorContextKeys.hasNonEmptySelection.toNegated(), + EditorContextKeys.hasMultipleSelections.toNegated(), + ), + }, }); } async run(accessor: ServicesAccessor): Promise<void> { @@ -277,6 +296,37 @@ registerAction2(class extends Action2 { } }); +// --- Open Voice Mode Settings (gear button, shown left of Disconnect when connected) --- + +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'agentsVoice.openSettings', + title: nls.localize2('agentsVoice.openSettings', "Voice Mode Settings"), + icon: Codicon.settingsGear, + f1: true, + precondition: ContextKeyExpr.equals('config.agents.voice.enabled', true), + menu: { + id: MenuId.ChatExecute, + when: ContextKeyExpr.and( + ContextKeyExpr.equals('config.agents.voice.enabled', true), + ChatContextKeys.location.isEqualTo(ChatAgentLocation.Chat), + ChatContextKeys.currentlyEditing.negate(), + AGENTS_VOICE_CONNECTED.isEqualTo(true), + AGENTS_VOICE_INITIATED_HERE.isEqualTo(true), + ), + group: 'navigation', + // Just before the Disconnect button (order -9) and after the mic/stop button (order -10). + order: -9.5 + }, + }); + } + async run(accessor: ServicesAccessor): Promise<void> { + const commandService = accessor.get(ICommandService); + await commandService.executeCommand('workbench.action.openSettings', { query: 'agents.voice' }); + } +}); + // --- Simulate Voice Connection (dev utility, backend down) --- registerAction2(class extends Action2 { @@ -357,7 +407,20 @@ registerAction2(class extends Action2 { const storageService = accessor.get(IStorageService); const devices = await navigator.mediaDevices.enumerateDevices(); - const audioInputs = devices.filter(d => d.kind === 'audioinput' && d.deviceId !== 'default'); + + // Filter out the virtual "default"/"communications" entries (which duplicate a real + // device) and de-duplicate by deviceId so a single microphone shows up only once. + const seenDeviceIds = new Set<string>(); + const audioInputs = devices.filter(d => { + if (d.kind !== 'audioinput' || d.deviceId === 'default' || d.deviceId === 'communications') { + return false; + } + if (seenDeviceIds.has(d.deviceId)) { + return false; + } + seenDeviceIds.add(d.deviceId); + return true; + }); if (audioInputs.length === 0) { quickInputService.pick([{ label: nls.localize('noMicrophones', "No microphones found") }]); @@ -428,32 +491,72 @@ configurationRegistry.registerConfiguration({ description: nls.localize('agents.voice.textToSpeech', "When enabled, the assistant reads responses aloud. When disabled, responses appear as text transcripts only."), default: true, scope: ConfigurationScope.APPLICATION, - included: false, + }, + 'agents.voice.voice': { + type: 'string', + enum: ['victoria_neutral', 'kevin_neutral', 'maya_neutral', 'daniel_neutral'], + enumItemLabels: ['Victoria', 'Kevin', 'Maya', 'Daniel'], + enumDescriptions: [ + nls.localize('agents.voice.voice.victoria', "Victoria."), + nls.localize('agents.voice.voice.kevin', "Kevin."), + nls.localize('agents.voice.voice.maya', "Maya."), + nls.localize('agents.voice.voice.daniel', "Daniel."), + ], + description: nls.localize('agents.voice.voice', "The voice used when the assistant reads responses aloud. Changing this while voice mode is connected takes effect immediately."), + default: 'maya_neutral', + scope: ConfigurationScope.APPLICATION, }, 'agents.voice.showTranscript': { type: 'boolean', description: nls.localize('agents.voice.showTranscript', "Show the voice transcript overlay in the chat input area while voice mode is active."), default: false, scope: ConfigurationScope.APPLICATION, - included: false, - tags: ['advanced'], }, - 'agents.voice.autoSendDelay': { + 'agents.voice.handsFree': { + type: 'boolean', + description: nls.localize('agents.voice.handsFree', "When enabled, voice mode automatically re-enters listening after the assistant finishes speaking, so you can hold a hands-free back-and-forth conversation. When disabled, you start each turn manually. This controls only the auto-listen loop; how a turn ends is controlled by `agents.voice.turn.autoEndMode`."), + default: true, + scope: ConfigurationScope.APPLICATION, + }, + 'agents.voice.turn.autoEndMode': { + type: 'string', + enum: ['off', 'vad', 'phrase', 'both'], + enumDescriptions: [ + nls.localize('agents.voice.turn.autoEndMode.off', "Never end the turn automatically; it ends only when you release (or, in toggle mode, tap again) push-to-talk."), + nls.localize('agents.voice.turn.autoEndMode.vad', "End the turn automatically after a period of trailing silence (see `agents.voice.turn.silenceMs`)."), + nls.localize('agents.voice.turn.autoEndMode.phrase', "End the turn automatically when a stop phrase is spoken (see `agents.voice.turn.stopPhrases`)."), + nls.localize('agents.voice.turn.autoEndMode.both', "End the turn automatically on either trailing silence or a spoken stop phrase."), + ], + description: nls.localize('agents.voice.turn.autoEndMode', "Controls whether and how the voice backend ends a held turn on its own. The backend is the single source of truth for turn-ending: `vad` ends on trailing silence (`agents.voice.turn.silenceMs`), `phrase` ends on a spoken stop phrase (`agents.voice.turn.stopPhrases`), `both` enables either, and `off` requires you to end the turn manually."), + default: 'vad', + scope: ConfigurationScope.APPLICATION, + }, + 'agents.voice.turn.silenceMs': { type: 'number', - description: nls.localize('agents.voice.autoSendDelay', "In toggle voice mode (short tap), automatically finish recording after this many milliseconds of silence. Set to -1 to disable."), - default: 500, - minimum: -1, + description: nls.localize('agents.voice.turn.silenceMs', "Trailing silence in milliseconds before the backend ends the turn. Applies only when `agents.voice.turn.autoEndMode` is `vad` or `both`; ignored otherwise. The backend clamps this to its supported range (currently 200-5000 ms) and is the source of truth."), + default: 800, + minimum: 200, + maximum: 5000, scope: ConfigurationScope.APPLICATION, - included: false, - tags: ['advanced'], }, - 'agents.voice.sendKeyword': { + 'agents.voice.turn.stopPhrases': { + type: 'array', + items: { type: 'string' }, + description: nls.localize('agents.voice.turn.stopPhrases', "Phrases that end the turn when spoken at the end of an utterance. Applies only when `agents.voice.turn.autoEndMode` is `phrase` or `both`; ignored otherwise. The backend strips the matched phrase from the transcript before it reaches the agent."), + default: ['send it'], + scope: ConfigurationScope.APPLICATION, + }, + 'agents.voice.turn.vadGateAsr': { type: 'string', - description: nls.localize('agents.voice.sendKeyword', "A keyword phrase (e.g. \"send it\") that, when spoken at the end of an utterance in toggle mode, triggers sending the request immediately. The keyword is stripped from the sent message. Leave empty to disable."), - default: '', + enum: ['default', 'on', 'off'], + enumDescriptions: [ + nls.localize('agents.voice.turn.vadGateAsr.default', "Let the backend decide (gates speech recognition only when `agents.voice.turn.autoEndMode` is `off`)."), + nls.localize('agents.voice.turn.vadGateAsr.on', "Always gate: only forward audio to speech recognition when the backend voice-activity detector hears speech."), + nls.localize('agents.voice.turn.vadGateAsr.off', "Never gate: forward all captured audio to speech recognition."), + ], + description: nls.localize('agents.voice.turn.vadGateAsr', "Controls voice-activity noise-gating of the audio sent to speech recognition. Independent of `agents.voice.turn.autoEndMode`, except that `default` derives its behavior from it (gating only when `autoEndMode` is `off`). Use `on`/`off` to force gating regardless of `autoEndMode`."), + default: 'default', scope: ConfigurationScope.APPLICATION, - included: false, - tags: ['advanced'], }, } }); diff --git a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts index 5a7ee77fb0bed0..ae88bff0a3a29e 100644 --- a/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts +++ b/src/vs/workbench/contrib/agentsVoice/browser/agentsVoiceWindowService.ts @@ -148,7 +148,7 @@ export class AgentsVoiceWindowService extends Disposable implements IAgentsVoice // expand them via the chevron. const widget = new AgentsVoiceWidget(auxiliaryWindow.container, { copilotIconSrc: FileAccess.asBrowserUri('vs/sessions/browser/media/sessions-icon.svg').toString(true), - hideDisconnect: (this.configurationService.getValue<number>('agents.voice.autoSendDelay') ?? 500) >= 0, + hideDisconnect: this.configurationService.getValue<boolean>('agents.voice.handsFree') !== false, connect: () => { // Connecting from any surface marks onboarding as completed so // the main panel drops it too. diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/daniel_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/daniel_neutral.mp3 new file mode 100644 index 00000000000000..79adf9ba256cde Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/daniel_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/kevin_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/kevin_neutral.mp3 new file mode 100644 index 00000000000000..e887a3189e0a0b Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/kevin_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/maya_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/maya_neutral.mp3 new file mode 100644 index 00000000000000..6904cc54d4db2d Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/maya_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/agentsVoice/browser/media/victoria_neutral.mp3 b/src/vs/workbench/contrib/agentsVoice/browser/media/victoria_neutral.mp3 new file mode 100644 index 00000000000000..aa57741062847a Binary files /dev/null and b/src/vs/workbench/contrib/agentsVoice/browser/media/victoria_neutral.mp3 differ diff --git a/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedMcpServersForAccountAction.ts b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedMcpServersForAccountAction.ts index 539d91fe14ade4..c27113e5310a6d 100644 --- a/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedMcpServersForAccountAction.ts +++ b/src/vs/workbench/contrib/authentication/browser/actions/manageTrustedMcpServersForAccountAction.ts @@ -112,33 +112,55 @@ class ManageTrustedMcpServersForAccountActionImpl { private async _getItems(accountQuery: IAccountQuery) { const allowedMcpServers = accountQuery.mcpServers().getAllowedMcpServers(); const serverIdToLabel = new Map<string, string>(this._mcpServerService.servers.get().map(s => [s.definition.id, s.definition.label])); - const filteredMcpServers = allowedMcpServers - // Filter out MCP servers that are not in the current list of servers - .filter(server => serverIdToLabel.has(server.id)) - .map(server => { - const usage = accountQuery.mcpServer(server.id).getUsage(); - return { - ...server, - // Use the server name from the MCP service - name: serverIdToLabel.get(server.id)!, - lastUsed: usage.length > 0 ? Math.max(...usage.map(u => u.lastUsed)) : server.lastUsed - }; - }); - - if (!filteredMcpServers.length) { + const withLastUsed = <T extends AllowedMcpServer>(server: T): T => { + const usage = accountQuery.mcpServer(server.id).getUsage(); + return { ...server, lastUsed: usage.length > 0 ? Math.max(...usage.map(u => u.lastUsed)) : server.lastUsed }; + }; + + // Agent-host MCP servers run inside the agent-host process and are not registered with the + // workbench `IMcpService`, so they are kept regardless of the registry and surfaced in their + // own per-host section(s) rather than being filtered out. + const agentHostServers = allowedMcpServers + .filter((server): server is AllowedMcpServer & { agentHost: NonNullable<AllowedMcpServer['agentHost']> } => !!server.agentHost) + .map(withLastUsed); + + // Workbench MCP servers: filter out any not in the current list of servers, and use the + // server name from the MCP service. + const workbenchServers = allowedMcpServers + .filter(server => !server.agentHost && serverIdToLabel.has(server.id)) + .map(server => withLastUsed({ ...server, name: serverIdToLabel.get(server.id)! })); + + if (!agentHostServers.length && !workbenchServers.length) { this._dialogService.info(localize('noTrustedMcpServers', "This account has not been used by any MCP servers.")); return []; } - const trustedServers = filteredMcpServers.filter(s => s.trusted); - const otherServers = filteredMcpServers.filter(s => !s.trusted); + const trustedServers = workbenchServers.filter(s => s.trusted); + const otherServers = workbenchServers.filter(s => !s.trusted); const sortByLastUsed = (a: AllowedMcpServer, b: AllowedMcpServer) => (b.lastUsed || 0) - (a.lastUsed || 0); - return [ - ...otherServers.sort(sortByLastUsed).map(this._toQuickPickItem), - { type: 'separator', label: localize('trustedMcpServers', "Trusted by Microsoft") } satisfies IQuickPickSeparator, - ...trustedServers.sort(sortByLastUsed).map(this._toQuickPickItem) + const items: Array<TrustedMcpServersQuickPickItem | IQuickPickSeparator> = [ + ...otherServers.sort(sortByLastUsed).map(this._toQuickPickItem) ]; + + // One section per distinct agent host, keyed by the stable authority + // (labels can collide across hosts) but displayed using the label. + const byAuthority = new Map<string, { label: string; servers: AllowedMcpServer[] }>(); + for (const server of agentHostServers) { + const group = byAuthority.get(server.agentHost.authority) ?? { label: server.agentHost.label, servers: [] }; + group.servers.push(server); + byAuthority.set(server.agentHost.authority, group); + } + const sortedGroups = [...byAuthority.values()].sort((a, b) => a.label.localeCompare(b.label)); + for (const { label, servers } of sortedGroups) { + items.push({ type: 'separator', label: localize({ key: 'agentHostMcpServers', comment: ['The placeholder {0} is the name of an agent host, e.g. a remote machine or the local machine'] }, "MCP Servers in {0}", label) } satisfies IQuickPickSeparator); + items.push(...servers.sort(sortByLastUsed).map(this._toQuickPickItem)); + } + + items.push({ type: 'separator', label: localize('trustedMcpServers', "Trusted by Microsoft") } satisfies IQuickPickSeparator); + items.push(...trustedServers.sort(sortByLastUsed).map(this._toQuickPickItem)); + + return items; } private _toQuickPickItem(mcpServer: AllowedMcpServer): TrustedMcpServersQuickPickItem { diff --git a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts index 0b42c2a823ec3b..a75e75f7b6fa2e 100644 --- a/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts +++ b/src/vs/workbench/contrib/browserView/browser/browserView.contribution.ts @@ -5,6 +5,7 @@ import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js'; import { IBrowserViewWorkbenchService, IBrowserViewCDPService, IBrowserViewModel, IBrowserEditorViewState, IBrowserViewContextualFilter, IBrowserViewOpenHandler } from '../common/browserView.js'; +import type { PreferredGroup } from '../../../services/editor/common/editorService.js'; import { Event } from '../../../../base/common/event.js'; import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../../../../platform/browserView/common/cdp/types.js'; @@ -38,6 +39,10 @@ class WebBrowserViewWorkbenchService implements IBrowserViewWorkbenchService { return this._known; } + async getPreferredGroup(preferredGroup?: PreferredGroup): Promise<PreferredGroup | undefined> { + return preferredGroup; + } + registerOpenHandler(_handler: IBrowserViewOpenHandler): IDisposable { return Disposable.None; } diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index 0945c528b3c007..cf6f1a0559bf6c 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -25,6 +25,7 @@ import { IPermissionCategoryState, } from '../../../../platform/browserView/common/browserPermissions.js'; import type { BrowserEditorInput } from './browserEditorInput.js'; +import type { PreferredGroup } from '../../../services/editor/common/editorService.js'; import { IBrowserViewBounds, IBrowserViewNavigationEvent, @@ -255,6 +256,19 @@ export interface IBrowserViewWorkbenchService { */ getContextualBrowserViews(context?: IBrowserViewFilterContext): Map<string, BrowserEditorInput>; + /** + * Resolve the preferred editor group for opening an integrated browser + * editor. Honors the `workbench.browser.newTabPlacement` setting, routing new + * tabs into a dedicated (locked) side group or auxiliary window when + * configured. When the workbench forces editors into a modal part + * (`workbench.editor.useModal: 'all'`), browser opens that target the active + * group (or leave it unspecified) are + * redirected to the main editor area so the browser docks instead of opening + * as a modal overlay. Explicit placements (side group, auxiliary window, a + * specific group) are left untouched. + */ + getPreferredGroup(preferredGroup?: PreferredGroup): Promise<PreferredGroup | undefined>; + /** * Register a handler that decides whether an editor should be opened for a * newly created browser view. The editor is opened only when every diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index e5731ec37a6070..533745a7a1fe5b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -431,6 +431,8 @@ export class BrowserEditor extends EditorPane { private readonly _inputDisposables = this._register(new DisposableStore()); private _currentPadding: { top: number; right: number; bottom: number; left: number } = { top: 0, right: 0, bottom: 0, left: 0 }; + override get input(): BrowserEditorInput | undefined { return super.input as BrowserEditorInput | undefined; } + constructor( group: IEditorGroup, @ITelemetryService telemetryService: ITelemetryService, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts index ebe0c631d65d4d..1efe07f5eb1cbc 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewWorkbenchService.ts @@ -12,12 +12,12 @@ import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/w import { Emitter, Event } from '../../../../base/common/event.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; import { Disposable, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { AUX_WINDOW_GROUP, IEditorService, PreferredGroup } from '../../../services/editor/common/editorService.js'; +import { ACTIVE_GROUP, AUX_WINDOW_GROUP, IEditorService, PreferredGroup, SIDE_GROUP, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from '../../../services/editor/common/editorService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; +import { IWorkspaceTrustEnablementService, IWorkspaceTrustManagementService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { BrowserEditorInput } from '../common/browserEditorInput.js'; -import { IEditorGroupsService } from '../../../services/editor/common/editorGroupsService.js'; +import { IEditorGroup, IEditorGroupsService, preferredSideBySideGroupDirection } from '../../../services/editor/common/editorGroupsService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { ChatContextKeys } from '../../chat/common/actions/chatContextKeys.js'; @@ -37,14 +37,20 @@ import { localChatSessionType } from '../../chat/common/chatSessionsService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { ITunnelProxyInfo } from '../../../../platform/tunnel/common/tunnelProxy.js'; -/** - * When enabled, integrated browser tools are exposed as client-provided tools - * to agent host sessions in the Sessions window. Has no effect outside the - * Sessions window or when the agent host is disabled. - */ -export const AgentHostChatToolsEnabledSettingId = 'workbench.browser.agentHostChatToolsEnabled'; export const BrowserMaxHistoryEntriesSettingId = 'workbench.browser.maxHistoryEntries'; export const BrowserRemoteProxyEnabledSettingId = 'workbench.browser.enableRemoteProxy'; +export const BrowserNewTabPlacementSettingId = 'workbench.browser.newTabPlacement'; + +/** + * Where new integrated browser tabs are opened. + * - `activeGroup`: the currently active editor group (default). + * - `sideGroup`: a dedicated editor group to the side, locked so that other editors are not opened into it. + * - `window`: a dedicated auxiliary window, locked so that other editors are not opened into it. + */ +export type BrowserNewTabPlacement = 'activeGroup' | 'sideGroup' | 'window'; + +/** The placement kinds that resolve to a new group. */ +type DedicatedGroupPlacement = Exclude<BrowserNewTabPlacement, 'activeGroup'>; /** Command IDs whose accelerators are shown in browser view context menus. */ const browserViewContextMenuCommands = [ @@ -65,6 +71,14 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV /** Latest tunnel-proxy credentials pushed from the local extension host. */ private _remoteProxyInfo: ITunnelProxyInfo | undefined; + /** + * In-flight creation of the dedicated browser window group, used to coalesce + * concurrent requests so we don't spawn multiple auxiliary windows. The group + * itself is not tracked in memory: it is rediscovered dynamically via + * {@link _findDedicatedGroup} so that it survives window reloads. + */ + private _dedicatedWindowGroupPromise: Promise<IEditorGroup> | undefined; + private readonly _onDidChangeBrowserViews = this._register(new Emitter<void>()); readonly onDidChangeBrowserViews: Event<void> = this._onDidChangeBrowserViews.event; @@ -75,12 +89,9 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV // If we're in Sessions Window, we require some additional conditions. ContextKeyExpr.or( IsSessionsWindowContext.negate(), - ContextKeyExpr.and( - ContextKeyExpr.has(`config.${AgentHostChatToolsEnabledSettingId}`), - ContextKeyExpr.or( - ContextKeyExpr.equals('sessionType', localChatSessionType), - ContextKeyExpr.equals('sessions.isAgentHostSession', true), - ) + ContextKeyExpr.or( + ContextKeyExpr.equals('sessionType', localChatSessionType), + ContextKeyExpr.equals('sessions.isAgentHostSession', true), ), ), )!; @@ -103,6 +114,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IWorkspaceTrustEnablementService private readonly workspaceTrustEnablementService: IWorkspaceTrustEnablementService, @ILogService private readonly logService: ILogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @@ -214,6 +226,91 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV return result; } + async getPreferredGroup(preferredGroup?: PreferredGroup): Promise<PreferredGroup | undefined> { + // "Open to side" requests are routed into the dedicated side group. + if (preferredGroup === SIDE_GROUP) { + return this._getOrCreateDedicatedGroup('sideGroup'); + } + + // Other explicit placements are always honored as-is. + if (preferredGroup !== undefined && preferredGroup !== ACTIVE_GROUP) { + return preferredGroup; + } + + // Honor the user-configured default for new browser tabs. + const placement = this.configurationService.getValue<BrowserNewTabPlacement>(BrowserNewTabPlacementSettingId); + if (placement === 'sideGroup' || placement === 'window') { + return this._getOrCreateDedicatedGroup(placement); + } + + // When editors are forced modal via `workbench.editor.useModal: 'all'`, + // redirect active/unspecified browser opens to the main editor area so the + // browser docks instead of opening as a modal overlay. + if (this.configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING) === 'all') { + return this.editorGroupsService.mainPart.activeGroup; + } + + return preferredGroup; + } + + /** + * Resolve the dedicated editor group for the given placement, reusing an + * existing locked browser group if one is found (so it survives window + * reloads) or creating and locking a new one otherwise. Side-group creation + * is synchronous; window creation is asynchronous. + */ + private _getOrCreateDedicatedGroup(placement: DedicatedGroupPlacement): IEditorGroup | Promise<IEditorGroup> { + const existing = this._findDedicatedGroup(placement); + if (existing) { + return existing; + } + + if (placement === 'sideGroup') { + const direction = preferredSideBySideGroupDirection(this.configurationService); + const group = this.editorGroupsService.addGroup(this.editorGroupsService.activeGroup, direction); + // Lock the group so that other (non-browser) editors are not opened + // into it. Browser tabs still open here because we target it directly. + group.lock(true); + return group; + } + + // Auxiliary-window creation is async; coalesce concurrent requests so we don't spawn multiple windows. + if (!this._dedicatedWindowGroupPromise) { + this._dedicatedWindowGroupPromise = this.editorGroupsService.createAuxiliaryEditorPart() + .then(part => { + part.activeGroup.lock(true); + return part.activeGroup; + }) + .finally(() => this._dedicatedWindowGroupPromise = undefined); + } + return this._dedicatedWindowGroupPromise; + } + + /** + * Find an existing dedicated browser group for the given placement. A group + * qualifies when it is locked and contains a browser editor (or is empty), + * which lets us rediscover the dedicated group after a window reload + * without tracking it in memory. Side groups live in the main editor part; + * window groups live in an auxiliary editor part. + */ + private _findDedicatedGroup(placement: DedicatedGroupPlacement): IEditorGroup | undefined { + const mainPart = this.editorGroupsService.mainPart; + for (const group of this.editorGroupsService.groups) { + if (!group.isLocked) { + continue; + } + if (group.editors.length > 0 && !group.editors.some(editor => editor instanceof BrowserEditorInput)) { + continue; + } + const inMainPart = this.editorGroupsService.getPart(group) === mainPart; + const matchesPlacement = placement === 'sideGroup' ? inMainPart : !inMainPart; + if (matchesPlacement) { + return group; + } + } + return undefined; + } + registerOpenHandler(handler: IBrowserViewOpenHandler): IDisposable { this._openHandlers.add(handler); return toDisposable(() => { @@ -341,6 +438,10 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV if (targetGroup === undefined) { return; // If the parent isn't open, don't open the child either } + } else { + // Keep the browser docked in the main editor area even when editors + // are forced modal via `workbench.editor.useModal: 'all'`. + targetGroup = await this.getPreferredGroup(); } const editorOptions = { @@ -393,6 +494,7 @@ export class BrowserViewWorkbenchService extends Disposable implements IBrowserV maxHistoryEntries: this.configurationService.getValue<number>(BrowserMaxHistoryEntriesSettingId), proxyInfo: this._remoteProxyInfo, trustedFileRoots: this._getTrustedFileRoots(), + trustAllFiles: !this.workspaceTrustEnablementService.isWorkspaceTrustEnabled(), }); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index ffb7e62730a6b5..1799b6fe15d875 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -36,14 +36,18 @@ import { BrowserEditor, BrowserEditorContribution, BrowserWidgetLocation, IBrows import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; -import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; -import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; +import { Extensions as ConfigurationMigrationExtensions, IConfigurationMigrationRegistry, workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; -import { AgentHostChatToolsEnabledSettingId } from '../browserViewWorkbenchService.js'; // Register tools import '../tools/browserTools.contribution.js'; +/** + * Setting that controls whether a screenshot of the selected element is attached + * to the chat when sending elements from the Integrated Browser. + */ +const BrowserSendElementsToChatAttachImagesSettingId = 'workbench.browser.sendElementsToChat.attachImages'; + /** * Format an array of element ancestors into a CSS-selector-like path string. */ @@ -348,7 +352,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { innerText, }); - const attachImages = this.configurationService.getValue<boolean>('chat.sendElementsToChat.attachImages'); + const attachImages = this.configurationService.getValue<boolean>(BrowserSendElementsToChatAttachImagesSettingId); if (attachImages) { const screenshotBuffer = await model.captureScreenshot({ quality: 90, @@ -376,7 +380,7 @@ export class BrowserEditorChatIntegration extends BrowserEditorContribution { }; type IntegratedBrowserAddElementToChatAddedClassification = { - attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether chat.sendElementsToChat.attachImages was enabled.' }; + attachImages: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether workbench.browser.sendElementsToChat.attachImages was enabled.' }; owner: 'jruales'; comment: 'An element was successfully added to chat from Integrated Browser.'; }; @@ -757,13 +761,6 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis }, agentsWindow: { default: true }, }, - [AgentHostChatToolsEnabledSettingId]: { - type: 'boolean', - markdownDescription: localize('workbench.browser.agentHostChatToolsEnabled', "When enabled, integrated browser tools are exposed as client-provided tools to agent host sessions in the Sessions window. Requires {0} and {1}.", `\`#${AgentHostEnabledSettingId}#\``, '`#workbench.browser.enableChatTools#`'), - default: false, - experiment: { mode: 'startup' }, - tags: ['experimental', 'advanced'], - }, 'workbench.browser.experimentalUserTools.enabled': { type: 'boolean', default: false, @@ -773,6 +770,26 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis { comment: ['This is the description for a setting.'], key: 'browser.experimentalUserTools.enabled' }, "When enabled, experimental user-facing tools are available in the Integrated Browser's Add to Chat menu." ), + }, + [BrowserSendElementsToChatAttachImagesSettingId]: { + type: 'boolean', + default: true, + markdownDescription: localize('workbench.browser.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), } } }); + +Registry.as<IConfigurationMigrationRegistry>(ConfigurationMigrationExtensions.ConfigurationMigration).registerConfigurationMigrations([ + { + key: 'chat.sendElementsToChat.attachImages', + migrateFn: value => { + const result: [string, { value: unknown | undefined }][] = [ + ['chat.sendElementsToChat.attachImages', { value: undefined }], + ]; + if (typeof value === 'boolean') { + result.push([BrowserSendElementsToChatAttachImagesSettingId, { value }]); + } + return result; + } + } +]); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts index 3fa1932c744a2a..6dd045a3340309 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserNavigationFeatures.ts @@ -23,7 +23,6 @@ import { BrowserViewCommandId } from '../../../../../platform/browserView/common import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IPreferencesService } from '../../../../services/preferences/common/preferences.js'; -import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; import { IBrowserViewModel } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { @@ -34,7 +33,6 @@ import { getBrowserSearchEngineLabel, resolveAddressBarInputType, } from '../../common/browserSearch.js'; -import { AgentHostChatToolsEnabledSettingId } from '../browserViewWorkbenchService.js'; import { BROWSER_EDITOR_ACTIVE, BrowserActionCategory, @@ -220,6 +218,13 @@ export class BrowserNavigationFeatures extends BrowserEditorContribution { private readonly _canGoForwardContext: IContextKey<boolean>; private readonly _pendingTryFocus = this._register(new MutableDisposable()); + /** + * Whether a navigation has been initiated on the current tab. Once true, + * an empty URL means "navigation in flight" rather than "fresh tab", so + * {@link tryFocus} keeps focus on the page instead of reopening the picker. + */ + private _hasInitiatedNavigation = false; + constructor( editor: BrowserEditor, @IInstantiationService instantiationService: IInstantiationService, @@ -262,12 +267,19 @@ export class BrowserNavigationFeatures extends BrowserEditorContribution { } protected override onModelAttached(model: IBrowserViewModel, store: DisposableStore): void { + // A model that is already loading on attach (e.g. switching back to a + // tab mid-navigation) counts as having initiated navigation. + this._hasInitiatedNavigation = model.loading; this._updateFromModel(model); store.add(model.onDidNavigate(() => this._updateFromModel(model))); - store.add(model.onWillNavigate(url => this._navbar.previewUrl(url))); + store.add(model.onWillNavigate(url => { + this._hasInitiatedNavigation = true; + this._navbar.previewUrl(url); + })); } override onModelDetached(): void { + this._hasInitiatedNavigation = false; this._navbar.clear(); this._canGoBackContext.reset(); this._canGoForwardContext.reset(); @@ -283,16 +295,13 @@ export class BrowserNavigationFeatures extends BrowserEditorContribution { return; } - // A new tab (no URL loaded) auto-opens the picker so the user can - // immediately type / browse suggestions. For tabs that already have a - // URL (e.g. error or loading state — page-renderer focus didn't claim - // us, or input is still prerendering before the model attaches) we - // just focus the display so the URL stays visible. + // A new tab (no URL loaded) auto-opens the picker so the user can immediately type / browse suggestions. + // Otherwise we move focus into the browser editor so it doesn't stay on the tab control. const url = this.editor.model?.url ?? (input instanceof BrowserEditorInput ? input.url : undefined); - if (!url) { + if (!url && !this._hasInitiatedNavigation) { this._navbar.openUrlPicker(); } else { - this._navbar.focusUrlInput(); + this.editor.ensureBrowserFocus(); } }, 0); return true; @@ -527,12 +536,7 @@ class OpenBrowserSettingsAction extends Action2 { async run(accessor: ServicesAccessor): Promise<void> { const preferencesService = accessor.get(IPreferencesService); - const contextKeyService = accessor.get(IContextKeyService); - const ids = ['workbench.browser.*', 'chat.sendElementsToChat.*']; - if (IsSessionsWindowContext.getValue(contextKeyService)) { - ids.push(AgentHostChatToolsEnabledSettingId); - } - await preferencesService.openSettings({ query: `@id:${ids.join(',')}` }); + await preferencesService.openSettings({ query: `@id:workbench.browser.*` }); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts index 592c72917ef924..dc8a9956881d44 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserPermissionsFeature.ts @@ -131,7 +131,11 @@ export class BrowserPermissionsFeature extends BrowserEditorContribution { cancelButton: true, }); if (result === 'allow' || result === 'deny') { - model.setPermissions(origin, [{ category, state: result }]); + void model.setPermissions(origin, [{ category, state: result }]); + } else { + // Signal an explicit cancel so the pending page request rejects + // immediately without recording a persisted decision. + void model.setPermissions(origin, [{ category, state: null }]); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts index 4236f0d1d919ed..fc39687ad061d3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserRemoteFeatures.ts @@ -15,6 +15,7 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { BrowserRemoteProxyEnabledSettingId } from '../browserViewWorkbenchService.js'; +import product from '../../../../../platform/product/common/product.js'; class BrowserRemoteIndicatorContribution extends BrowserEditorContribution { private readonly _container: HTMLElement; @@ -91,7 +92,7 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis properties: { [BrowserRemoteProxyEnabledSettingId]: { type: 'boolean', - default: false, + default: product.quality !== 'stable', tags: ['experimental'], scope: ConfigurationScope.WINDOW, experiment: { mode: 'startup' }, diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserSearchFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserSearchFeatures.ts index c240ab10732754..d57ca1090b0ae4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserSearchFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserSearchFeatures.ts @@ -17,7 +17,6 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis enum: [BROWSER_SEARCH_NONE, ...BROWSER_SEARCH_ENGINES.map(e => e.id)], enumItemLabels: [localize('browser.search.engine.none', "None"), ...BROWSER_SEARCH_ENGINES.map(e => e.label)], default: BrowserSearchEngineId.Bing, - experiment: { mode: 'startup' }, markdownDescription: localize( 'browser.searchEngine', "Controls the search engine used to search the web from the address bar of the integrated browser. Select 'None' to disable search." diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts index b7933f99009b99..8c0dfd27b3bf33 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserTabManagementFeatures.ts @@ -25,7 +25,8 @@ import { ContextKeyExpr, IContextKeyService, RawContextKey } from '../../../../. import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { IBrowserViewModel, IBrowserViewWorkbenchService } from '../../common/browserView.js'; -import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../../platform/configuration/common/configurationRegistry.js'; +import { BrowserNewTabPlacementSettingId } from '../browserViewWorkbenchService.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { IExternalOpener, IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { isLocalhostAuthority, isAllInterfacesAuthority } from '../../../../../platform/url/common/trustedDomains.js'; @@ -118,9 +119,9 @@ class BrowserTabQuickPick extends Disposable { this._quickPick.hide(); await this._editorService.openEditor({ resource: BrowserViewUri.forId(generateUuid()), - }); + }, await this._browserViewService.getPreferredGroup()); } else { - await this._editorService.openEditor(selected.editor, selected.groupId); + await this._editorService.openEditor(selected.editor, await this._browserViewService.getPreferredGroup(selected.groupId)); } })); @@ -288,7 +289,7 @@ class OpenIntegratedBrowserAction extends Action2 { // Parse arguments const options = typeof urlOrOptions === 'string' ? { url: urlOrOptions } : (urlOrOptions ?? {}); const resource = BrowserViewUri.forId(generateUuid()); - const group = options.openToSide ? SIDE_GROUP : ACTIVE_GROUP; + const group = await browserViewService.getPreferredGroup(options.openToSide ? SIDE_GROUP : undefined); if (options.reuseUrlFilter) { const filterUri = URI.parse(options.reuseUrlFilter); @@ -318,6 +319,9 @@ class OpenIntegratedBrowserAction extends Action2 { if (options.url) { matchingEditor.navigate(options.url); } + // Reveal the existing browser tab where it already lives rather than + // relocating it into the docked group (which would move a tab out of a + // modal group when `workbench.editor.useModal: 'all'`). await editorService.openEditor(matchingEditor); return; } @@ -373,6 +377,7 @@ class OpenFileInIntegratedBrowserAction extends Action2 { async run(accessor: ServicesAccessor, resource?: URI): Promise<void> { const editorService = accessor.get(IEditorService); const telemetryService = accessor.get(ITelemetryService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); // Resolve the file URI from the context or the active editor const fileUri = resource ?? EditorResourceAccessor.getOriginalUri(editorService.activeEditor, { filterByScheme: [Schemas.file], supportSideBySide: SideBySideEditor.PRIMARY }); @@ -383,7 +388,7 @@ class OpenFileInIntegratedBrowserAction extends Action2 { logBrowserOpen(telemetryService, 'openFileCommand'); const browserUri = BrowserViewUri.forId(generateUuid()); - await editorService.openEditor({ resource: browserUri, options: { viewState: { url: fileUri.toString() } } }); + await editorService.openEditor({ resource: browserUri, options: { viewState: { url: fileUri.toString() } } }, await browserViewService.getPreferredGroup()); } } @@ -413,11 +418,12 @@ class NewTabAction extends Action2 { async run(accessor: ServicesAccessor, _browserEditor = accessor.get(IEditorService).activeEditorPane): Promise<void> { const editorService = accessor.get(IEditorService); const telemetryService = accessor.get(ITelemetryService); + const browserViewService = accessor.get(IBrowserViewWorkbenchService); const resource = BrowserViewUri.forId(generateUuid()); logBrowserOpen(telemetryService, 'newTabCommand'); - await editorService.openEditor({ resource }); + await editorService.openEditor({ resource }, await browserViewService.getPreferredGroup()); } } @@ -620,7 +626,7 @@ class LocalhostLinkOpenerContribution extends Disposable implements IWorkbenchCo const isDefaultLinkOpen = !isConfigured(this.configurationService.inspect('workbench.browser.openLocalhostLinks')); const browserUri = BrowserViewUri.forId(generateUuid()); - await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href, isDefaultLinkOpen } } }); + await this.editorService.openEditor({ resource: browserUri, options: { pinned: true, viewState: { url: href, isDefaultLinkOpen } } }, await this.browserViewWorkbenchService.getPreferredGroup()); return true; } } @@ -923,6 +929,21 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis 'When enabled, localhost links (`localhost`, `127.0.0.1`, `[::1]`) and all-interfaces links (`0.0.0.0`, `[0:0:0:0:0:0:0:0]`, `[::]`) from the terminal, chat, and other sources will open in the Integrated Browser instead of the system browser.' ), agentsWindow: { default: true }, + }, + [BrowserNewTabPlacementSettingId]: { + type: 'string', + enum: ['activeGroup', 'sideGroup', 'window'], + enumDescriptions: [ + localize({ comment: ['This is the description for a setting.'], key: 'browser.newTabPlacement.activeGroup' }, "New browser tabs open in the currently active editor group."), + localize({ comment: ['This is the description for a setting.'], key: 'browser.newTabPlacement.sideGroup' }, "New browser tabs open in a dedicated editor group to the side that is reused for subsequent tabs. The group is locked so other editors are not opened into it."), + localize({ comment: ['This is the description for a setting.'], key: 'browser.newTabPlacement.window' }, "New browser tabs open in a dedicated window that is reused for subsequent tabs. The window is locked so other editors are not opened into it.") + ], + default: 'activeGroup', + markdownDescription: localize( + { comment: ['This is the description for a setting.'], key: 'browser.newTabPlacement' }, + "Controls where new Integrated Browser tabs are opened." + ), + scope: ConfigurationScope.WINDOW, } } }); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts index 34d986cea26ef4..becc6e518c02d7 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/webContentsViewRendererFeature.ts @@ -120,14 +120,7 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { override onContainerCreated(container: HTMLElement): void { this._container = container; - this._register(addDisposableListener(container, EventType.FOCUS, (event: FocusEvent) => { - // When the browser container gets focus, make sure the browser view also gets focused — - // but only if focus was already in the workbench (and not e.g. clicking back into the - // workbench from the browser view itself). - if (event.relatedTarget) { - this.tryFocus(); - } - })); + this._register(addDisposableListener(container, EventType.FOCUS, () => this.tryFocus())); this._register(addDisposableListener(container, EventType.BLUR, () => this._cancelFocusTimeout())); // Cross-window focus logic uses this checker because the WCV lives @@ -158,17 +151,23 @@ class WebContentsViewRendererFeature extends BrowserEditorContribution { } override tryFocus(): boolean { - if (!this._shouldShowPage()) { + if (!this.editor.input?.url) { return false; } - this.editor.ensureBrowserFocus(); + this._container?.focus(); if (this._focusTimeout || !this._model) { return true; } this._focusTimeout = setTimeout(() => { this._focusTimeout = undefined; - if (this._model) { + const doc = this._container?.ownerDocument; + if (!doc?.hasFocus() || doc.activeElement !== this._container) { + return; + } + if (this._model?.visible) { void this._model.focus(); + } else { + this.editor.ensureBrowserFocus(); } }, 0); return true; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css index edde9a5f9bbab1..11bb749c50caf4 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css +++ b/src/vs/workbench/contrib/browserView/electron-browser/media/browser.css @@ -498,7 +498,7 @@ right: 0; bottom: 0; background-image: none; - background-size: cover; + background-size: contain; background-repeat: no-repeat; } diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index e3846ba0b0f42a..d5128fab101fda 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -389,6 +389,17 @@ class ChatResponseAccessibleProvider extends Disposable implements IAccessibleVi } break; } + case 'autoModeResolution': { + if (part.predictedLabel === 'fallback') { + contentParts.push(localize('autoModeResolutionA11yFallback', "Routed to {0}. Unable to resolve.", part.resolvedModelName)); + } else { + const label = part.predictedLabel === 'needs_reasoning' + ? localize('autoModeResolutionA11yReasoning', "Reasoning") + : localize('autoModeResolutionA11yNonReasoning', "Non-reasoning"); + contentParts.push(localize('autoModeResolutionA11y', "Routed to {0}. {1} - Confidence {2}%", part.resolvedModelName, label, (part.confidence * 100).toFixed(0))); + } + break; + } } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index c40ce7948d584b..fa2d3f515a3c6e 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -26,7 +26,6 @@ import { ICommandService } from '../../../../../platform/commands/common/command import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsLinuxContext, IsWindowsContext } from '../../../../../platform/contextkey/common/contextkeys.js'; -import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -36,6 +35,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import product from '../../../../../platform/product/common/product.js'; import { GitHubPaths, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { ActiveEditorContext } from '../../../../common/contextkeys.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../common/views.js'; @@ -57,7 +57,7 @@ import { ElicitationState, IChatService, IChatToolInvocation } from '../../commo import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatLastUsedEditorSessionTypeStorageKey, ChatModeKind, getDefaultNewChatSessionType, getNewChatEditorSessionResource } from '../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../common/constants.js'; import { AICustomizationManagementCommands } from '../aiCustomization/aiCustomizationManagement.js'; import { ILanguageModelChatSelector, ILanguageModelsService } from '../../common/languageModels.js'; import { CopilotUsageExtensionFeatureId } from '../../common/languageModelStats.js'; @@ -452,7 +452,7 @@ abstract class OpenChatGlobalAction extends Action2 { if (!chatModeCheck) { return; } - chatWidget.input.setChatMode(switchToMode.id); + chatWidget.input.setChatMode(switchToMode.id, true, true); if (chatModeCheck.needToClearSession) { await commandService.executeCommand(ACTION_ID_NEW_CHAT); @@ -574,14 +574,10 @@ export abstract class ModeOpenChatGlobalAction extends OpenChatGlobalAction { export function registerChatActions() { /** * Returns the session URI to use when opening a brand-new chat editor, - * honoring the experimental {@link ChatConfiguration.EditorDefaultProvider} - * setting. Falls back to a new local session when the setting selects - * `local` or the chosen provider is unavailable. + * honoring the remembered harness preference and then the configured default. */ function getNewChatEditorSessionUri(accessor: ServicesAccessor): URI { - const storageService = accessor.get(IStorageService); - const lastUsedSessionType = storageService.get(ChatLastUsedEditorSessionTypeStorageKey, StorageScope.PROFILE); - return getNewChatEditorSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), lastUsedSessionType); + return getDefaultNewChatSessionResource(accessor.get(IConfigurationService), accessor.get(IChatSessionsService), accessor.get(IStorageService)); } registerAction2(PrimaryOpenChatGlobalAction); @@ -1763,15 +1759,13 @@ export interface IClearEditingSessionConfirmationOptions { } /** - * Clears the current chat session and starts a new one, preserving - * the session type (e.g. Claude, Cloud, Background) for non-local sessions - * in the sidebar. + * Clears the current chat session and starts a new one using the shared + * new-session harness resolver. */ -export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType: string | undefined, configurationService: IConfigurationService, chatSessionsService: IChatSessionsService): Promise<void> { +export async function clearChatSessionPreservingType(widget: IChatWidget, viewsService: IViewsService, sessionType: string | undefined, configurationService: IConfigurationService, chatSessionsService: IChatSessionsService, storageService: IStorageService): Promise<void> { const currentResource = widget.viewModel?.model.sessionResource; - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService); const currentSessionType = currentResource ? getChatSessionType(currentResource) : undefined; - const newSessionType = sessionType ?? (currentSessionType === localChatSessionType && defaultType !== localChatSessionType ? defaultType : currentSessionType ?? defaultType); + const newSessionType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { explicitOverride: sessionType, currentSessionType }); if (isIChatViewViewContext(widget.viewContext) && newSessionType !== localChatSessionType) { // For the sidebar, we need to explicitly load a session with the same type const newResource = URI.from({ scheme: newSessionType, path: `/untitled-${generateUuid()}` }); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts index 4f50a80996066f..366b044d9a6bb5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatClear.ts @@ -7,6 +7,7 @@ import { Schemas } from '../../../../../base/common/network.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { getDefaultNewChatSessionResource } from '../../common/constants.js'; @@ -17,6 +18,7 @@ export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInpu const editorService = accessor.get(IEditorService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); if (!chatEditorInput) { const editorInput = editorService.activeEditor; @@ -28,7 +30,7 @@ export async function clearChatEditor(accessor: ServicesAccessor, chatEditorInpu // Otherwise create a generic new chat editor. const resource = chatEditorInput.sessionResource && chatEditorInput.sessionResource.scheme !== Schemas.vscodeLocalChatSession ? chatEditorInput.sessionResource.with({ path: `/untitled-${generateUuid()}` }) - : getDefaultNewChatSessionResource(configurationService, chatSessionsService); + : getDefaultNewChatSessionResource(configurationService, chatSessionsService, storageService); // A chat editor can only be open in one group const identifier = editorService.findEditors(chatEditorInput.resource)[0]; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts index f758e3b155a403..7c07e24253f541 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContext.ts @@ -8,6 +8,8 @@ import { Disposable, DisposableStore } from '../../../../../base/common/lifecycl import { isElectron } from '../../../../../base/common/platform.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; +import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; +import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; @@ -17,6 +19,7 @@ import { EditorResourceAccessor, SideBySideEditor } from '../../../../common/edi import { DiffEditorInput } from '../../../../common/editor/diffEditorInput.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../services/host/browser/host.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; import { UntitledTextEditorInput } from '../../../../services/untitled/common/untitledTextEditorInput.js'; import { FileEditorInput } from '../../../files/browser/editors/fileEditorInput.js'; import { NotebookEditorInput } from '../../../notebook/common/notebookEditorInput.js'; @@ -34,6 +37,7 @@ import { ITerminalService } from '../../../terminal/browser/terminal.js'; import { URI } from '../../../../../base/common/uri.js'; import { ITerminalCommand, TerminalCapability } from '../../../../../platform/terminal/common/capabilities/capabilities.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; +import { buildHostLocalEventsPath } from '../copilotCliEventsUri.js'; /** * Command ID that extensions can call to enable debug tools for the current @@ -325,6 +329,8 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { constructor( @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IPathService private readonly _pathService: IPathService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, ) { } isEnabled(widget: IChatWidget): boolean { @@ -333,11 +339,12 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { asPicker(widget: IChatWidget): IChatContextPicker { const currentSessionResource = widget.viewModel?.sessionResource; + const onlyShowAttachableCopilotCliSessions = !!currentSessionResource && isAgentHostTarget(getChatSessionType(currentSessionResource)); return { placeholder: localize('chatContext.sessions.placeholder', 'Select a session'), picks: (async () => { const picks: IChatContextPickerPickItem[] = []; - const sessionProviderFilter = [AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Claude]; + const sessionProviderFilter = [AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Claude, AgentSessionProviders.AgentHostCopilot]; for await (const group of this._chatSessionsService.getChatSessionItems(sessionProviderFilter, CancellationToken.None)) { const providerIcon = getAgentSessionProviderIcon(group.chatSessionType); for (const item of group.items) { @@ -345,6 +352,9 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { continue; } const sessionResource = item.resource; + if (onlyShowAttachableCopilotCliSessions && !this._canAttachCopilotCliSession(sessionResource)) { + continue; + } const icon = item.iconPath ?? providerIcon; picks.push({ label: item.label, @@ -364,4 +374,13 @@ class SessionReferenceContextPickerPick implements IChatContextPickerItem { })() }; } + + private _canAttachCopilotCliSession(sessionResource: URI): boolean { + // For now, attachments while in an Agent Host Copilot harness are attachable when backed by Copilot CLI events.jsonl. + return !!buildHostLocalEventsPath( + sessionResource, + this._pathService.userHome({ preferLocal: true }), + authority => this._remoteAgentHostService.connections.find(connection => agentHostAuthority(connection.address) === authority), + ); + } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index a3b95ca77901d3..b8ce9c2ff31639 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -37,7 +37,7 @@ import { ChatModel } from '../../common/model/chatModel.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; import { getDynamicVariablesForWidget, getSelectedToolAndToolSetsForWidget } from '../attachments/chatVariables.js'; import { ChatSendResult, IChatService } from '../../common/chatService/chatService.js'; -import { ResolvedChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { ResolvedChatSessionsExtensionPoint, IChatSessionsService, SessionType } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { PROMPT_LANGUAGE_ID } from '../../common/promptSyntax/promptTypes.js'; import { AgentSessionProviders, AgentSessionTarget, CHAT_DELEGATE_TO_AGENT_HOST_SESSION_COMMAND_ID, getAgentSessionProvider, getAgentSessionProviderIcon, getAgentSessionProviderName, IAgentHostDelegationRequest, isAgentHostTarget } from '../agentSessions/agentSessions.js'; @@ -50,6 +50,8 @@ import { CHAT_SETUP_ACTION_ID } from './chatActions.js'; import { IChatRequestPasteVariableEntry, PromptFileVariableKind, toPasteVariableEntry, toPromptFileVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { ChatSessionPosition, openChatSession } from '../chatSessions/chatSessions.contribution.js'; +import { importedTurnsFromChatModel } from '../agentSessions/agentHost/importLocalConversationToAgentSession.js'; +import type { ModelSelection } from '../../../../../platform/agentHost/common/state/protocol/state.js'; /** * Extracts the "owner/repo" name-with-owner from a git remote URL. @@ -539,7 +541,22 @@ export class CreateRemoteAgentJobAction { const sourceName = sourceContribution?.displayName ?? getAgentSessionProviderName(sourceSessionType); const continuationContext = attachedContext.asArray(); let handoffPrompt = userPrompt; - if (transcript) { + // Continuing a local chat into Copilot CLI (main window) imports the + // prior conversation as real, editable turns seeded into the new + // session, instead of handing it over as a read-only transcript + // attachment. The turns are threaded through the normal + // `openChatSession` flow so the session lifecycle (model picker, + // config chips, etc.) is unchanged. + const importConversationTurns = (continuationTargetType === SessionType.AgentHostCopilot && !isSessionsWindow) + ? importedTurnsFromChatModel(chatModel) + : undefined; + // Carry the source session's selected model so the imported session + // resumes on the same model rather than the host default. The raw + // model id (`metadata.id`) matches the Copilot catalog shared by the + // local models and Copilot CLI. + const importConversationModelId = importConversationTurns ? widget.input.selectedLanguageModel.get()?.metadata.id : undefined; + const importConversationModel: ModelSelection | undefined = importConversationModelId ? { id: importConversationModelId } : undefined; + if (transcript && !importConversationTurns) { if (isAgentHostTarget(continuationTargetType)) { const transcriptAttachment = createDelegationTranscriptAttachment(transcript, sourceName); if (transcriptAttachment) { @@ -583,6 +600,7 @@ export class CreateRemoteAgentJobAction { prompt: handoffPrompt, attachedContext: continuationContext, initialSessionOptions: initialSessionOptions.size > 0 ? initialSessionOptions : undefined, + importConversation: importConversationTurns ? { turns: importConversationTurns, model: importConversationModel } : undefined, } )); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index d48676bc038b05..9709530790063b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -21,6 +21,7 @@ import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.j import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; @@ -33,7 +34,7 @@ import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { isInClaudeAgentsFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; -import { IChatWidget, IChatWidgetService } from '../chat.js'; +import { type IChatAcceptInputOptions, IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders, AgentSessionTarget } from '../agentSessions/agentSessions.js'; import { getEditingSessionContext } from '../chatEditing/chatEditingActions.js'; import { ctxHasEditorModification, ctxHasRequestInProgress, ctxIsGlobalEditingSession } from '../chatEditing/chatEditingEditorContextKeys.js'; @@ -48,6 +49,7 @@ export interface IVoiceChatExecuteActionContext { export interface IChatExecuteActionContext { widget?: IChatWidget; inputValue?: string; + acceptInputOptions?: IChatAcceptInputOptions; voice?: IVoiceChatExecuteActionContext; } @@ -157,7 +159,7 @@ abstract class SubmitAction extends Action2 { } else if (widget?.viewModel?.model.checkpoint) { widget.viewModel.model.setCheckpoint(undefined); } - widget?.acceptInput(context?.inputValue); + widget?.acceptInput(context?.inputValue, context?.acceptInputOptions); } private async handleDelegation(accessor: ServicesAccessor, widget: IChatWidget, delegationTarget: Exclude<AgentSessionTarget, AgentSessionProviders.Local>): Promise<void> { @@ -346,7 +348,7 @@ class ToggleChatModeAction extends Action2 { isClaudeAgent }); - widget.input.setChatMode(switchToMode.id); + widget.input.setChatMode(switchToMode.id, true, true); if (chatModeCheck.needToClearSession) { await commandService.executeCommand(ACTION_ID_NEW_CHAT); @@ -664,6 +666,41 @@ export class OpenWorkspacePickerAction extends Action2 { } } +/** + * Workspace picker chip for the automations dialog. Sits between the mode + * picker (order 1) and the model picker (order 3) in the primary chat input + * toolbar. Visible only when the hosting `ChatInputPart` was constructed with + * a `workspacePickerInput` and the dialog has set + * {@link ChatContextKeys.inAutomationsDialog} on its scoped context-key + * service. + */ +export class OpenAutomationsWorkspacePickerAction extends Action2 { + static readonly ID = 'workbench.action.chat.openAutomationsWorkspacePicker'; + + constructor() { + super({ + id: OpenAutomationsWorkspacePickerAction.ID, + title: localize2('interactive.openAutomationsWorkspacePicker.label', "Open Automations Workspace Picker"), + tooltip: localize('selectAutomationsWorkspace', "Select Workspace Folder"), + category: CHAT_CATEGORY, + f1: false, + precondition: ChatContextKeys.enabled, + menu: [ + { + id: MenuId.ChatInput, + order: 2, + group: 'navigation', + when: ChatContextKeys.inAutomationsDialog, + }, + ] + }); + } + + override async run(accessor: ServicesAccessor, ...args: unknown[]): Promise<void> { + // The picker is opened via the action view item's trigger. + } +} + export class ChatSessionPrimaryPickerAction extends Action2 { static readonly ID = 'workbench.action.chat.chatSessionPrimaryPicker'; constructor() { @@ -906,6 +943,7 @@ class SendToNewChatAction extends Action2 { const chatService = accessor.get(IChatService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); const widget = context?.widget ?? widgetService.lastFocusedWidget; if (!widget) { return; @@ -927,7 +965,7 @@ class SendToNewChatAction extends Action2 { // Clear the input from the current session before creating a new one widget.setInput(''); - await clearChatSessionPreservingType(widget, viewsService, undefined, configurationService, chatSessionsService); + await clearChatSessionPreservingType(widget, viewsService, undefined, configurationService, chatSessionsService, storageService); widget.acceptInput(inputBeforeClear, { storeToHistory: true }); } @@ -1229,6 +1267,7 @@ export function registerChatExecuteActions(): DisposableStore { store.add(registerAction2(OpenSessionTargetPickerAction)); store.add(registerAction2(OpenDelegationPickerAction)); store.add(registerAction2(OpenWorkspacePickerAction)); + store.add(registerAction2(OpenAutomationsWorkspacePickerAction)); store.add(registerAction2(ChatSessionPrimaryPickerAction)); store.add(registerAction2(ChangeChatModelAction)); store.add(registerAction2(CancelEdit)); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts index 90386345d09790..8b96ee2b676fae 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatNewActions.ts @@ -13,6 +13,7 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatContextKeyExprs, ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatEditingSession } from '../../common/editing/chatEditingService.js'; @@ -317,6 +318,7 @@ async function runNewChatAction( const viewsService = accessor.get(IViewsService); const configurationService = accessor.get(IConfigurationService); const chatSessionsService = accessor.get(IChatSessionsService); + const storageService = accessor.get(IStorageService); const { editingSession, chatWidget: widget } = context ?? {}; if (!widget) { @@ -333,7 +335,7 @@ async function runNewChatAction( await editingSession?.stop(); // Create a new session, preserving the session type (or using the specified one) - await clearChatSessionPreservingType(widget, viewsService, sessionType, configurationService, chatSessionsService); + await clearChatSessionPreservingType(widget, viewsService, sessionType, configurationService, chatSessionsService, storageService); widget.attachmentModel.clear(true); widget.focusInput(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts index 8487c949628de4..4766e72e8b5ae2 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatPluginActions.ts @@ -81,8 +81,8 @@ class InstallFromSourceAction extends Action2 { const store = new DisposableStore(); const inputBox = store.add(quickInputService.createInputBox()); - inputBox.placeholder = localize('pluginSourcePlaceholder', "owner/repo or git clone URL"); - inputBox.prompt = localize('pluginSourcePrompt', "Enter a GitHub repository or git URL to install a plugin from"); + inputBox.placeholder = localize('pluginSourcePlaceholder', "owner/repo, git URL, or local folder path"); + inputBox.prompt = localize('pluginSourcePrompt', "Enter a GitHub repository, git URL, or local folder path to install a plugin from"); inputBox.ignoreFocusOut = true; inputBox.show(); @@ -122,7 +122,7 @@ class InstallFromSourceAction extends Action2 { // Hide the input box so it doesn't conflict with trust/progress dialogs. inputBox.hide(); - const result = await pluginInstallService.installPluginFromValidatedSource(source); + const result = await pluginInstallService.installPluginFromSource(source); if (!result.success) { if (result.message) { // Re-open with the error so the user can correct their input. diff --git a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts index af44d3964e6684..a18f564ad9c737 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/exportAgentHostDebugLogsAction.ts @@ -12,7 +12,8 @@ import { localize, localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { agentHostAuthority, toAgentHostUri } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { AgentHostEnabledSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { IRemoteAgentHostConnectionInfo, IRemoteAgentHostService, remoteAgentHostLogOutputChannelId, AGENT_HOST_LOG_OUTPUT_CHANNEL_ID } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IsWebContext } from '../../../../../platform/contextkey/common/contextkeys.js'; @@ -373,7 +374,7 @@ export class ExportAgentHostDebugLogsAction extends Action2 { precondition: ContextKeyExpr.and( ChatContextKeys.enabled, IsWebContext.negate(), - ContextKeyExpr.equals(`config.${AgentHostEnabledSettingId}`, true), + AGENT_HOST_ENABLED_CONTEXT_KEY, ), }); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts index dcb81fd7bc3dfd..6f21d2b131f300 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationItemProvider.ts @@ -21,6 +21,7 @@ import { AgentCustomizationContentExpander } from './agentCustomizationContentEx import { IAgentHostCustomizationService } from './agentHostCustomizationService.js'; import { IAgentSource, ICustomAgent, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; +import { localize } from '../../../../../../nls.js'; const REMOTE_HOST_GROUP = 'remote-host'; @@ -101,10 +102,10 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto }; } - private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, groupKey: string | undefined): ICustomizationItem[] { + private toDirectoryItems(customization: DirectoryCustomization, source: AICustomizationSource, isRemote: boolean): ICustomizationItem[] { const items: ICustomizationItem[] = []; for (const child of customization.children ?? []) { - const item = this.toDirectoryChildItem(child, source, groupKey); + const item = this.toDirectoryChildItem(child, source, isRemote); if (item) { items.push(item); } @@ -112,7 +113,7 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return items; } - private toDirectoryChildItem(child: ChildCustomization, source: AICustomizationSource, groupKey: string | undefined): ICustomizationItem | undefined { + private toDirectoryChildItem(child: ChildCustomization, source: AICustomizationSource, isRemote: boolean): ICustomizationItem | undefined { const type = toPromptsType(child.type); if (!type) { return undefined; @@ -121,6 +122,25 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto if (child.type === CustomizationType.Agent) { userInvocable = readAgentCustomizationMeta(child).userInvocable !== false; } + let groupKey = isRemote ? REMOTE_CLIENT_GROUP : undefined; + let badge: string | undefined = undefined; + let badgeTooltip: string | undefined = undefined; + if (!groupKey && child.type === CustomizationType.Rule) { + const pattern = child.globs?.[0]; + if (child.globs && child.globs.length > 0) { + groupKey = 'context-instructions'; + badge = pattern === '**' + ? localize('alwaysAdded', 'always added') + : pattern; + badgeTooltip = pattern === '**' + ? localize('alwaysIncluded', 'This instruction is automatically included in every interaction.') + : localize('contextInstructions', 'This instruction is automatically included when files matching \'{0}\' are in context.', pattern); + } else if (child.alwaysApply) { + groupKey = 'agent-instructions'; + } else { + groupKey = 'on-demand-instructions'; + } + } return { itemKey: child.id, @@ -130,6 +150,8 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto description: getChildDescription(child), source, groupKey, + badge, + badgeTooltip, extensionId: undefined, pluginUri: undefined, userInvocable, @@ -137,6 +159,8 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto } async provideSourceFolders(sessionResource: URI, type: PromptsType, _token: CancellationToken): Promise<readonly ICustomizationSourceFolder[]> { + const workingDirectory = this._customAgentsService.getWorkingDirectory(sessionResource); + const folders: ICustomizationSourceFolder[] = []; for (const customization of this._customAgentsService.getCustomizations(sessionResource)) { if (!isDirectoryCustomization(customization) || !customization.writable) { @@ -145,9 +169,11 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto if (toPromptsType(customization.contents) !== type) { continue; } + const source = workingDirectory && customization.uri.startsWith(workingDirectory + '/') ? AICustomizationSources.local : AICustomizationSources.user; folders.push({ uri: this.toRemoteUri(customization.uri), label: customization.name, + source, }); } return folders; @@ -255,9 +281,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto const workingDirectory = this._customAgentsService.getWorkingDirectory(sessionResource); for (const sessionCustomization of directoryCustomizations) { - const source = workingDirectory && sessionCustomization.uri.startsWith(workingDirectory + '/') ? AICustomizationSources.local : AICustomizationSources.user; - const groupKey = sessionCustomization.clientId ? REMOTE_CLIENT_GROUP : undefined; - for (const child of this.toDirectoryItems(sessionCustomization, source, groupKey)) { + const source = workingDirectory && isParentOrEqual(workingDirectory, sessionCustomization.uri) ? AICustomizationSources.local : AICustomizationSources.user; + const isRemote = sessionCustomization.clientId !== undefined; + for (const child of this.toDirectoryItems(sessionCustomization, source, isRemote)) { items.set(child.itemKey ?? child.uri.toString(), { ...child, status: toStatusString(sessionCustomization.load), @@ -284,6 +310,9 @@ export class AgentCustomizationItemProvider extends Disposable implements ICusto return children; } } +function isParentOrEqual(folderURI: string, childURI: string): boolean { + return childURI === folderURI || childURI.startsWith(folderURI + '/'); +} function toStatusString(load: CustomizationLoadState | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { return load?.kind; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts index 6f3743356daa47..83f0005b2fe899 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostActiveClientService.ts @@ -19,6 +19,7 @@ import { IAgentPluginService } from '../../../common/plugins/agentPluginService. import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { ILanguageModelToolsService, IToolData, IToolSet } from '../../../common/tools/languageModelToolsService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; import { resolveCustomizationRefs } from './agentHostLocalCustomizations.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; @@ -82,6 +83,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo @IInstantiationService private readonly _instantiationService: IInstantiationService, @IFileService private readonly _fileService: IFileService, @IMcpService private readonly _mcpService: IMcpService, + @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @IAgentHostToolSetEnablementService private readonly _toolSetEnablementService: IAgentHostToolSetEnablementService, ) { super(); @@ -101,7 +103,7 @@ export class AgentHostActiveClientService extends Disposable implements IAgentHo const updateCustomizations = async () => { const seq = ++updateSeq; try { - const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, bundler, sessionType); + const refs = await resolveCustomizationRefs(this._fileService, this._promptsService, syncProvider, this._agentPluginService, this._mcpService, this._configurationResolverService, bundler, sessionType); if (seq !== updateSeq) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index b922286a3cb3ad..19152ad3f85f54 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -3,11 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { fetchAuthorizationServerMetadata } from '../../../../../../base/common/oauth.js'; import { URI } from '../../../../../../base/common/uri.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +import { IAuthenticationMcpAccessService } from '../../../../../services/authentication/browser/authenticationMcpAccessService.js'; +import { IAuthenticationMcpService } from '../../../../../services/authentication/browser/authenticationMcpService.js'; +import { IAuthenticationMcpUsageService } from '../../../../../services/authentication/browser/authenticationMcpUsageService.js'; +import { AuthenticationSession, IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; + +/** + * Stable identity for an agent-host MCP server, used as the key for + * remembered authentication (allowed-server access, account preference and + * usage). Agent-host customization ids are **not** stable across reloads — + * bare/top-level ids embed the agent-host session id, and synced child ids + * embed a per-sync nonce — so keying remembered auth on them orphans the + * grant on every reload. Instead we key on the session's host `authority` + * plus the server `name` and its resource `url`, all of which are stable + * for a given server across sessions and reloads. + */ +export function agentHostMcpServerId(authority: string, serverName: string, resourceUrl: string): string { + return `agent-host-mcp:${authority}/${encodeURIComponent(serverName)}/${encodeURIComponent(resourceUrl)}`; +} /** * Tracks the last bearer token pushed to a given agent host connection @@ -135,6 +155,24 @@ export interface IAgentHostAuthenticationOptions { readonly authenticate: (request: IAgentHostAuthenticateRequest) => Promise<unknown>; } +export interface IAgentHostMcpAuthenticationOptionsBase { + readonly allowInteraction: boolean; + readonly authTokenCache?: AgentHostAuthTokenCache; + readonly logPrefix: string; + readonly mcpServerId: string; + readonly mcpServerName: string; + readonly mcpServerUrl: string; + /** + * Identifies the agent host backing this MCP server so remembered-auth + * entries can be surfaced in their own section of the "Manage Trusted MCP + * Servers" picker. When set, the resolved host label (via + * {@link ILabelService.getHostLabel}) is recorded on the allowed-server + * entry. Omit for non-agent-host callers. + */ + readonly agentHost?: { readonly scheme: string; readonly authority: string }; + readonly authenticate: (request: IAgentHostAuthenticateRequest) => Promise<unknown>; +} + /** * Resolves and forwards bearer tokens for the protected resources declared by * the agents currently published from an agent host. @@ -223,3 +261,118 @@ export async function resolveAuthenticationInteractively( return false; } + +export async function resolveMcpServerAuthentication( + accessor: ServicesAccessor, + protectedResource: ProtectedResourceMetadata, + options: IAgentHostMcpAuthenticationOptionsBase, +): Promise<boolean> { + const authenticationService = accessor.get(IAuthenticationService); + const authenticationMcpAccessService = accessor.get(IAuthenticationMcpAccessService); + const authenticationMcpService = accessor.get(IAuthenticationMcpService); + const authenticationMcpUsageService = accessor.get(IAuthenticationMcpUsageService); + const logService = accessor.get(ILogService); + const agentHostMeta = options.agentHost + ? { authority: options.agentHost.authority, label: accessor.get(ILabelService).getHostLabel(options.agentHost.scheme, options.agentHost.authority) } + : undefined; + const scopes = protectedResource.scopes_supported ?? []; + for (const authorizationServer of protectedResource.authorization_servers ?? []) { + const authorizationServerUri = URI.parse(authorizationServer); + const providerId = await getOrCreateProviderForMcpResource(authorizationServerUri, protectedResource, authenticationService, logService, options.logPrefix); + if (!providerId) { + continue; + } + + const sessions = await authenticationService.getSessions(providerId, [...scopes], { authorizationServer: authorizationServerUri, resource: protectedResource.resource }, true); + const allowedSession = getAllowedMcpSession(providerId, sessions, authenticationMcpAccessService, authenticationMcpService, options); + if (allowedSession) { + await authenticateMcpSession(providerId, allowedSession, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, false, agentHostMeta); + return true; + } + + if (!options.allowInteraction) { + continue; + } + + const provider = authenticationService.getProvider(providerId); + const session = sessions.length + ? provider.supportsMultipleAccounts + ? await authenticationMcpService.selectSession(providerId, options.mcpServerId, options.mcpServerName, [...scopes], sessions) + : sessions[0] + : await authenticationService.createSession(providerId, [...scopes], { + activateImmediate: true, + authorizationServer: authorizationServerUri, + resource: protectedResource.resource, + }); + await authenticateMcpSession(providerId, session, scopes, authenticationMcpAccessService, authenticationMcpService, authenticationMcpUsageService, logService, options, true, agentHostMeta); + return true; + } + return false; +} + +async function getOrCreateProviderForMcpResource( + authorizationServer: URI, + protectedResource: ProtectedResourceMetadata, + authenticationService: IAuthenticationService, + logService: ILogService, + logPrefix: string, +): Promise<string | undefined> { + const resourceUri = URI.parse(protectedResource.resource); + const existing = await authenticationService.getOrActivateProviderIdForServer(authorizationServer, resourceUri); + if (existing) { + return existing; + } + + try { + const { metadata } = await fetchAuthorizationServerMetadata(authorizationServer.toString(true)); + const provider = await authenticationService.createDynamicAuthenticationProvider(authorizationServer, metadata, protectedResource); + return provider?.id; + } catch (err) { + logService.warn(`${logPrefix} Failed to create MCP auth provider for ${authorizationServer.toString(true)}`, err); + return undefined; + } +} + +function getAllowedMcpSession( + providerId: string, + sessions: readonly AuthenticationSession[], + authenticationMcpAccessService: IAuthenticationMcpAccessService, + authenticationMcpService: IAuthenticationMcpService, + options: IAgentHostMcpAuthenticationOptionsBase, +): AuthenticationSession | undefined { + const accountNamePreference = authenticationMcpService.getAccountPreference(options.mcpServerId, providerId); + if (accountNamePreference) { + const preferred = sessions.find(session => session.account.label === accountNamePreference); + if (preferred && authenticationMcpAccessService.isAccessAllowedForUrl(providerId, preferred.account.label, options.mcpServerId, options.mcpServerUrl)) { + return preferred; + } + } + + if (sessions.length === 1 && authenticationMcpAccessService.isAccessAllowedForUrl(providerId, sessions[0].account.label, options.mcpServerId, options.mcpServerUrl)) { + return sessions[0]; + } + + return undefined; +} + +async function authenticateMcpSession( + providerId: string, + session: AuthenticationSession, + scopes: readonly string[], + authenticationMcpAccessService: IAuthenticationMcpAccessService, + authenticationMcpService: IAuthenticationMcpService, + authenticationMcpUsageService: IAuthenticationMcpUsageService, + logService: ILogService, + options: IAgentHostMcpAuthenticationOptionsBase, + updateAccess: boolean, + agentHost: { readonly authority: string; readonly label: string } | undefined, +): Promise<void> { + await options.authenticate({ resource: options.mcpServerUrl, scopes, token: session.accessToken }); + options.authTokenCache?.updateAndIsChanged(options.mcpServerUrl, scopes, session.accessToken); + if (updateAccess) { + authenticationMcpAccessService.updateAllowedMcpServers(providerId, session.account.label, [{ id: options.mcpServerId, name: options.mcpServerName, allowed: true, url: options.mcpServerUrl, agentHost }]); + authenticationMcpService.updateAccountPreference(options.mcpServerId, providerId, session.account); + } + authenticationMcpUsageService.addAccountUsage(providerId, session.account.label, scopes, options.mcpServerId, options.mcpServerName); + logService.info(`${options.logPrefix} MCP authentication succeeded for ${options.mcpServerName}`); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts new file mode 100644 index 00000000000000..15f85a3057d4fb --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostByokLmHandler.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { + IAgentHostByokLmHandler, + IByokLmChatMessage, + IByokLmChatRequest, + IByokLmChatResult, + IByokLmModelInfo, + IByokLmToolCall, +} from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { + ChatMessageRole, + IChatMessage, + IChatMessagePart, + ILanguageModelChatRequestOptions, + ILanguageModelsService, +} from '../../../common/languageModels.js'; + +/** + * Renderer-side {@link IAgentHostByokLmHandler}. Services BYOK chat requests + * forwarded by the node agent host's OpenAI proxy by calling the VS Code LM + * API for the matching extension-registered model. + * + * The bridge DTOs are plain/serializable; this class is the single place that + * translates them to and from the `workbench/contrib/chat` LM types. + */ +export class AgentHostByokLmHandler extends Disposable implements IAgentHostByokLmHandler { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeModels = this._register(new Emitter<void>()); + /** Fires when the renderer's BYOK models change, so the node agent host re-enumerates. */ + readonly onDidChangeModels = this._onDidChangeModels.event; + + constructor( + @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + // Re-emit (debounced) whenever the renderer's language models change, so the + // agent host can refresh its BYOK model list — extension-provided BYOK models + // often register shortly after the bridge connects. + this._register(Event.debounce(this._languageModelsService.onDidChangeLanguageModels, () => undefined, 500)(() => { + this._onDidChangeModels.fire(); + })); + } + + async chat(request: IByokLmChatRequest, token: CancellationToken): Promise<IByokLmChatResult> { + const modelIdentifier = this._resolveModelIdentifier(request.vendor, request.modelId); + if (!modelIdentifier) { + return { content: '', error: `No BYOK model found for ${request.vendor}/${request.modelId}` }; + } + + const messages = request.messages.map(message => this._toChatMessage(message)); + const tools = request.tools?.length + ? request.tools.map(tool => ({ + name: tool.name, + description: tool.description ?? '', + inputSchema: tool.parametersSchema, + })) + : undefined; + const options: ILanguageModelChatRequestOptions = { + modelOptions: request.modelOptions, + ...(tools ? { tools } : {}), + }; + + try { + const response = await this._languageModelsService.sendChatRequest(modelIdentifier, undefined, messages, options, token); + + let content = ''; + const toolCalls: IByokLmToolCall[] = []; + const streaming = (async () => { + for await (const part of response.stream) { + const parts = Array.isArray(part) ? part : [part]; + for (const p of parts) { + if (p.type === 'text') { + content += p.value; + } else if (p.type === 'tool_use') { + toolCalls.push({ + id: p.toolCallId, + name: p.name, + argumentsJson: JSON.stringify(p.parameters ?? {}), + }); + } + } + } + })(); + + await Promise.all([response.result, streaming]); + return { content, toolCalls: toolCalls.length ? toolCalls : undefined }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this._logService.warn(`[AgentHostByokLmHandler] chat request failed for ${request.vendor}/${request.modelId}: ${message}`); + return { content: '', error: message }; + } + } + + async listModels(_token: CancellationToken): Promise<IByokLmModelInfo[]> { + const models: IByokLmModelInfo[] = []; + for (const identifier of this._languageModelsService.getLanguageModelIds()) { + const metadata = this._languageModelsService.lookupLanguageModel(identifier); + // Only genuine renderer BYOK models — exclude agent-host copies, which + // carry a `targetChatSessionType` and would otherwise re-enter the bridge. + if (metadata?.isBYOK && !metadata.targetChatSessionType) { + models.push({ + vendor: metadata.vendor, + id: metadata.id, + name: metadata.name, + maxContextWindowTokens: metadata.maxInputTokens + metadata.maxOutputTokens, + supportsVision: !!metadata.capabilities?.vision, + }); + } + } + return models; + } + + /** + * Find the LM API identifier for a BYOK model addressed by its vendor and + * provider-local id (the `provider/id` selection id the picker surfaced). + */ + private _resolveModelIdentifier(vendor: string, modelId: string): string | undefined { + for (const identifier of this._languageModelsService.getLanguageModelIds()) { + const metadata = this._languageModelsService.lookupLanguageModel(identifier); + if (metadata?.isBYOK && metadata.vendor === vendor && metadata.id === modelId) { + return identifier; + } + } + return undefined; + } + + private _toChatMessage(message: IByokLmChatMessage): IChatMessage { + // A tool-result message carries its payload solely in the `tool_result` + // part — the renderer/extension turns that into a wire `role: 'tool'` + // message on its own. Emit it and return early so the shared text branch + // below doesn't also inject a duplicate `role: 'user'` copy of the output. + // Tool messages that lack a `toolCallId` fall through to the plain text branch. + if (message.role === 'tool' && message.toolCallId) { + return { + role: ChatMessageRole.User, + content: [{ type: 'tool_result', toolCallId: message.toolCallId, value: [{ type: 'text', value: message.content }] }], + }; + } + + const content: IChatMessagePart[] = []; + if (message.content) { + content.push({ type: 'text', value: message.content }); + } + + if (message.role === 'assistant' && message.toolCalls?.length) { + for (const call of message.toolCalls) { + content.push({ + type: 'tool_use', + name: call.name, + toolCallId: call.id, + parameters: this._safeParseJson(call.argumentsJson), + }); + } + } + + return { role: this._toChatRole(message.role), content }; + } + + private _toChatRole(role: IByokLmChatMessage['role']): ChatMessageRole { + switch (role) { + case 'system': return ChatMessageRole.System; + case 'assistant': return ChatMessageRole.Assistant; + case 'user': + case 'tool': + default: return ChatMessageRole.User; + } + } + + private _safeParseJson(json: string): unknown { + try { + return JSON.parse(json); + } catch { + return {}; + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index fd7b78f5617332..c70cb2577dea83 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -8,8 +8,10 @@ import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { type ProtectedResourceMetadata } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { NotificationType } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -25,11 +27,12 @@ import { ICustomizationHarnessService } from '../../../common/customizationHarne import { ILanguageModelsService } from '../../../common/languageModels.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; +import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js'; import { authenticateProtectedResources, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { IAgentHostActiveClientService } from './agentHostActiveClientService.js'; -import { AICustomizationManagementSection, AICustomizationSources } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection } from '../../../common/aiCustomizationWorkspaceService.js'; const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; @@ -39,8 +42,8 @@ Registry.as<IAsyncChatSessionActivationRegistry>(ChatSessionsExtensions.AsyncAct }); async function waitForLocalAgentHostActivation(accessor: ServicesAccessor, sessionType: string): Promise<boolean> { - const configurationService = accessor.get(IConfigurationService); - if (!configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { + const agentHostEnablementService = accessor.get(IAgentHostEnablementService); + if (!agentHostEnablementService.enabled) { return false; } @@ -50,6 +53,7 @@ async function waitForLocalAgentHostActivation(accessor: ServicesAccessor, sessi } const agentHostService = accessor.get(IAgentHostService); + const configurationService = accessor.get(IConfigurationService); const environmentService = accessor.get(IWorkbenchEnvironmentService); while (true) { const rootState = agentHostService.rootState.value; @@ -113,12 +117,13 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr @ICustomizationHarnessService private readonly _customizationHarnessService: ICustomizationHarnessService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { super(); this._isSessionsWindow = environmentService.isSessionsWindow; this._enableSmokeTestDriver = !!environmentService.enableSmokeTestDriver; - if (!this._configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { + if (!agentHostEnablementService.enabled) { return; } @@ -135,6 +140,21 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._authTokenCache.clear(); })); + // Surface the agent host's lazy, first-use SDK download as a progress + // notification. The Agents window renders this via its own sessions + // provider (`BaseAgentHostSessionsProvider`), so only wire it up here + // for regular editor windows to avoid duplicate notifications (this + // contribution runs in both windows). The matching `createSession` + // opt-in (`progressToken`) lives in the editor-window session handlers. + if (!this._isSessionsWindow) { + const downloadProgress = this._register(this._instantiationService.createInstance(AgentHostDownloadProgress)); + this._register(this._agentHostService.onDidNotification(n => { + if (n.type === NotificationType.Progress) { + downloadProgress.handleProgress(n); + } + })); + } + // Process initial root state if already available const initialRootState = this._agentHostService.rootState.value; if (initialRootState && !(initialRootState instanceof Error)) { @@ -249,9 +269,8 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr label: localize('agentHostHarnessLabel.local', "{0} [Agent Host]", agent.displayName), icon: ThemeIcon.fromId(Codicon.server.id), // The Tools section is surfaced for the Copilot CLI agent host only. - hiddenSections: agent.provider === 'copilotcli' ? [] : [AICustomizationManagementSection.Tools], + hiddenSections: agent.provider === 'copilotcli' ? [AICustomizationManagementSection.Prompts] : [AICustomizationManagementSection.Tools, AICustomizationManagementSection.Prompts], hideGenerateButton: true, - getStorageSourceFilter: () => ({ sources: AICustomizationSources.all }), syncProvider, itemProvider, })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts index 5736df0a8ebdd0..f6f2da18f4197a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatInputPicker.ts @@ -212,6 +212,11 @@ export function isWellKnownAutoApproveSchema(schema: SessionConfigPropertySchema * * `Permissions` has no chip — it is surfaced through other UI — but is * included so the generic lane does not invent a chip for it. + * + * `WorktreeBranchPrefix` likewise has no chip: it is a carrier value seeded by + * the client (from `git.branchPrefix`) and consumed by the agent for worktree + * isolation, never edited by the user. Including it here keeps the generic lane + * from surfacing it as a chip in the chat input. */ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet<string> = new Set<string>([ SessionConfigKey.Mode, @@ -219,6 +224,7 @@ export const WELL_KNOWN_PICKER_PROPERTIES: ReadonlySet<string> = new Set<string> SessionConfigKey.Isolation, SessionConfigKey.Branch, SessionConfigKey.Permissions, + SessionConfigKey.WorktreeBranchPrefix, ClaudeSessionConfigKey.PermissionMode, ]); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts new file mode 100644 index 00000000000000..d13286d8852d65 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { isObject } from '../../../../../../base/common/types.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, CopilotCliConfigKey, type CopilotCliModelCapabilityOverrides } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; +import { AgentHostRootConfigForwarder, type IForwardedRootConfigKey } from './agentHostRootConfigForwarder.js'; + +/** + * Forwards the Copilot-CLI experimentation settings (Opus 4.8 prompt opt-in, + * reasoning-effort override, per-model family-alias overrides) into the + * **local** agent host's root config so `CopilotSessionLauncher` can read them + * at session launch. Gated on `chat.agentHost.enabled`. The schema-gate / + * hydration-retry / loop-guard machinery lives in the shared + * {@link AgentHostRootConfigForwarder}; this contribution only declares the keys. + */ +export class AgentHostCopilotCliSettingsContribution extends Disposable implements IWorkbenchContribution { + static readonly ID = 'workbench.contrib.agentHostCopilotCliSettings'; + + private readonly _forwarder: AgentHostRootConfigForwarder; + + constructor( + @IAgentHostService agentHostService: IAgentHostService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, + ) { + super(); + + const keys: readonly IForwardedRootConfigKey[] = [ + { + key: CopilotCliConfigKey.Opus48Prompt, + computeValue: () => this._configurationService.getValue<boolean>(AgentHostOpus48PromptEnabledSettingId) === true, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostOpus48PromptEnabledSettingId), + }, + { + key: CopilotCliConfigKey.ReasoningEffortOverride, + computeValue: () => { + const value = this._configurationService.getValue<string>(AgentHostReasoningEffortOverrideSettingId); + // '' is the schema's unset marker, so clearing the setting clears the override. + return typeof value === 'string' ? value : ''; + }, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostReasoningEffortOverrideSettingId), + }, + { + key: CopilotCliConfigKey.ModelCapabilityOverrides, + computeValue: () => { + const value = this._configurationService.getValue<CopilotCliModelCapabilityOverrides>(AgentHostModelCapabilityOverridesSettingId); + return isObject(value) ? value : {}; + }, + registerTriggers: (store, push) => this._pushOnSettingChange(store, push, AgentHostModelCapabilityOverridesSettingId), + }, + ]; + this._forwarder = this._register(new AgentHostRootConfigForwarder(keys, agentHostService)); + + if (this._agentHostEnablementService.enabled) { + this._forwarder.start(); + } + } + + private _pushOnSettingChange(store: DisposableStore, push: () => void, settingId: string): void { + store.add(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(settingId)) { + push(); + } + })); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotPromptContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotPromptContribution.ts deleted file mode 100644 index a434f7e0bd97a5..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCopilotPromptContribution.ts +++ /dev/null @@ -1,112 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { AgentHostEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; -import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; - -/** - * Forwards the `chat.agentHost.opus48Prompt.enabled` VS Code setting into the - * **local** agent host's root config (`opus48Prompt`) so - * {@link CopilotSessionLauncher} can read it at session launch via - * `getRootValue`. Gated on `chat.agentHost.enabled`. - * - * `AgentHostTerminalContribution` has a more elaborate, multi-key version of - * this forwarding for its terminal keys; this contribution intentionally stays - * single-key and self-contained. It still respects the two correctness - * constraints that forwarding into the shared root config requires: - * - schema gate + hydration retry: only dispatch once the host advertises the - * key (its `rootState` may hydrate after this contribution starts); and - * - cross-window loop guard: only push on the setting changing, the schema - * first appearing, or an agent-host (re)start — never on value-only - * root-state changes, which another window's write would otherwise bounce - * back and forth forever (#314385). - */ -export class AgentHostCopilotPromptContribution extends Disposable implements IWorkbenchContribution { - static readonly ID = 'workbench.contrib.agentHostCopilotPrompt'; - - private readonly _conditionalListeners = this._register(new MutableDisposable<DisposableStore>()); - - /** Whether the host has advertised the `opus48Prompt` key in its schema yet. */ - private _schemaSeen = false; - - constructor( - @IAgentHostService private readonly _agentHostService: IAgentHostService, - @IConfigurationService private readonly _configurationService: IConfigurationService, - ) { - super(); - - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AgentHostEnabledSettingId)) { - this._updateEnabled(); - } - })); - this._updateEnabled(); - } - - private _updateEnabled(): void { - if (this._configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { - if (!this._conditionalListeners.value) { - const store = new DisposableStore(); - store.add(this._agentHostService.onAgentHostStart(() => this._push())); - store.add(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AgentHostOpus48PromptEnabledSettingId)) { - this._push(); - } - })); - store.add(this._agentHostService.rootState.onDidChange(() => this._onRootStateChanged())); - this._schemaSeen = this._schemaHasKey(); - this._conditionalListeners.value = store; - this._push(); - } - } else { - this._schemaSeen = false; - this._conditionalListeners.value = undefined; - } - } - - private _onRootStateChanged(): void { - if (this._schemaHasKey()) { - // Push only on the schema's first appearance (hydration), not on - // value-only changes — see the class doc for the cross-window rationale. - if (!this._schemaSeen) { - this._schemaSeen = true; - this._push(); - } - } else { - this._schemaSeen = false; - } - } - - private _schemaHasKey(): boolean { - const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error) { - return false; - } - return !!rootState.config?.schema.properties[AgentHostConfigKey.Opus48Prompt]; - } - - private _push(): void { - if (!this._schemaHasKey()) { - return; - } - const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error || !rootState.config) { - return; - } - const value = this._configurationService.getValue<boolean>(AgentHostOpus48PromptEnabledSettingId) === true; - if (rootState.config.values[AgentHostConfigKey.Opus48Prompt] === value) { - return; - } - this._agentHostService.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { [AgentHostConfigKey.Opus48Prompt]: value }, - }); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts index b501ed23378ade..384efead32de61 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.ts @@ -14,15 +14,17 @@ import { IAgentHostConnectionsService, IAgentHostSessionResolution, LOCAL_AGENT_ import { getEffectiveAgents } from '../../../../../../platform/agentHost/common/customAgents.js'; import { type IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { CustomizationType, McpServerCustomization, type Customization, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { CustomizationType, McpServerCustomization, McpServerStatus, type Customization, type RootConfigState, type SessionState } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { AgentCustomization, ROOT_STATE_URI, StateComponents } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { InstantiationType, registerSingleton } from '../../../../../../platform/instantiation/common/extensions.js'; -import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { createDecorator, IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMcpServerConfiguration } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; import { IAgentHostMcpServer } from '../../../../../../sessions/common/agentHostSessionsProvider.js'; +import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js'; export const IAgentHostCustomizationService = createDecorator<IAgentHostCustomizationService>('agentHostCustomizationService'); @@ -41,8 +43,9 @@ export interface IAgentHostCustomizationService { * Returns the MCP servers exposed by an agent-host session. Each entry * carries the current status, a {@link IAgentHostMcpServer.setEnabled} * method that dispatches the protocol-level toggle on behalf of the - * caller, and the {@link IAgentHostMcpServer.logOutputChannelId} of the - * host backing the session. Returns an empty array for sessions not + * caller, lifecycle actions, and the + * {@link IAgentHostMcpServer.logOutputChannelId} of the host backing the + * session. Returns an empty array for sessions not * backed by an agent host, or that don't expose any MCP servers. */ getMcpServers(sessionResource: URI): readonly IAgentHostMcpServer[]; @@ -54,6 +57,13 @@ export interface IAgentHostCustomizationService { * sessions not backed by an agent host. */ addMcpServer(sessionResource: URI, name: string, config: IMcpServerConfiguration): void; + + /** + * Runs interactive authentication for an auth-required MCP server in an + * agent-host session. Returns false when the session/server cannot be + * resolved or authentication did not complete. + */ + authenticateMcpServer(sessionResource: URI, serverId: string): Promise<boolean>; } export class NullAgentHostCustomizationService implements IAgentHostCustomizationService { @@ -75,28 +85,167 @@ export class NullAgentHostCustomizationService implements IAgentHostCustomizatio addMcpServer(_sessionResource: URI, _name: string, _config: IMcpServerConfiguration): void { // no-op } + authenticateMcpServer(_sessionResource: URI, _serverId: string): Promise<boolean> { + return Promise.resolve(false); + } } -class WorkbenchAgentHostCustomizationService extends Disposable implements IAgentHostCustomizationService { +export interface IAgentHostCustomizationTarget { + readonly customizations: readonly Customization[]; + readonly workingDirectory?: string; + readonly logOutputChannelId?: string; + readonly rootConfig?: RootConfigState; + authenticate(request: { resource: string; scopes?: readonly string[]; token: string }): Promise<unknown>; + setCustomizationEnabled(rawId: string, enabled: boolean): void; + startMcpServer(rawId: string): Promise<void>; + stopMcpServer(rawId: string): Promise<void>; + setRootConfigValue(property: string, value: unknown): void; +} + +export abstract class AbstractAgentHostCustomizationService extends Disposable implements IAgentHostCustomizationService { declare readonly _serviceBrand: undefined; private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>()); private readonly _onDidChangeCustomizations = this._register(new Emitter<void>()); readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event; readonly onDidChangeCustomizations: Event<void> = this._onDidChangeCustomizations.event; + + protected constructor( + protected readonly _instantiationService: IInstantiationService, + protected readonly _logService: ILogService, + ) { + super(); + } + + protected abstract _resolveTarget(sessionResource: URI): IAgentHostCustomizationTarget | undefined; + + getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { + return getEffectiveAgents(this._resolveTarget(sessionResource)?.customizations); + } + + getCustomizations(sessionResource: URI): readonly Customization[] { + return this._resolveTarget(sessionResource)?.customizations ?? []; + } + + getWorkingDirectory(sessionResource: URI): string | undefined { + return this._resolveTarget(sessionResource)?.workingDirectory; + } + + getMcpServers(sessionResource: URI): readonly IAgentHostMcpServer[] { + const target = this._resolveTarget(sessionResource); + if (!target) { + return []; + } + return this._flattenMcpServers(target.customizations) + .map((c): IAgentHostMcpServer => ({ + id: this._scopedMcpServerId(sessionResource, c.id), + name: c.name, + enabled: c.enabled, + status: c.state.kind, + state: c.state, + logOutputChannelId: target.logOutputChannelId, + setEnabled: (enabled: boolean) => target.setCustomizationEnabled(c.id, enabled), + start: () => target.startMcpServer(c.id), + stop: () => target.stopMcpServer(c.id), + })); + } + + addMcpServer(sessionResource: URI, name: string, config: IMcpServerConfiguration): void { + const target = this._resolveTarget(sessionResource); + const existingServers = target?.rootConfig?.values?.[AgentHostMcpServersConfigKey]; + if (!target || !target.rootConfig) { + return; + } + const servers: AgentHostMcpServers = existingServers && typeof existingServers === 'object' && !Array.isArray(existingServers) + ? existingServers as AgentHostMcpServers + : {}; + target.setRootConfigValue(AgentHostMcpServersConfigKey, { + ...servers, + [name]: config, + }); + } + + async authenticateMcpServer(sessionResource: URI, serverId: string): Promise<boolean> { + const target = this._resolveTarget(sessionResource); + if (!target) { + return false; + } + const server = this._findMcpServer(target.customizations, serverId); + if (!server || server.state.kind !== McpServerStatus.AuthRequired) { + return false; + } + const scopedServerId = agentHostMcpServerId(sessionResource.authority, server.name, server.state.resource.resource); + const resource = { + ...server.state.resource, + scopes_supported: server.state.requiredScopes ?? server.state.resource.scopes_supported, + }; + try { + return await this._instantiationService.invokeFunction(resolveMcpServerAuthentication, resource, { + allowInteraction: true, + logPrefix: '[AgentHost]', + mcpServerId: scopedServerId, + mcpServerName: server.name, + mcpServerUrl: server.state.resource.resource, + agentHost: { scheme: sessionResource.scheme, authority: sessionResource.authority }, + authenticate: request => target.authenticate(request), + }); + } catch (err) { + this._logService.error(`[AgentHost] Failed to authenticate MCP server '${server.name}'`, err); + return false; + } + } + + protected _fireCustomAgentsChanged(): void { + this._onDidChangeCustomAgents.fire(); + } + + protected _fireCustomizationsChanged(): void { + this._onDidChangeCustomizations.fire(); + } + + private _flattenMcpServers(customizations: readonly Customization[]): McpServerCustomization[] { + return customizations.flatMap(c => c.type === CustomizationType.McpServer + ? [c] + : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []); + } + + private _findMcpServer(customizations: readonly Customization[], serverId: string): McpServerCustomization | undefined { + for (const server of this._flattenMcpServers(customizations)) { + if (server.id === serverId || this._isScopedMcpServerIdForRawId(serverId, server.id)) { + return server; + } + } + return undefined; + } + + protected _scopedMcpServerId(sessionResource: URI, rawId: string): string { + return `${sessionResource.authority}/${rawId}`; + } + + private _isScopedMcpServerIdForRawId(serverId: string, rawId: string): boolean { + const separator = serverId.indexOf('/'); + return separator >= 0 && serverId.slice(separator + 1) === rawId; + } +} + +class WorkbenchAgentHostCustomizationService extends AbstractAgentHostCustomizationService { + private readonly _sessionStateSubscriptions = this._register(new DisposableMap<string, IDisposable & { readonly connection: IAgentConnection; readonly backendSession: URI; readonly sub: IAgentSubscription<SessionState> }>()); constructor( @IAgentHostConnectionsService private readonly _connectionsService: IAgentHostConnectionsService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalSessionService: IAgentHostUntitledProvisionalSessionService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService logService: ILogService, @IChatService chatService: IChatService, ) { - super(); + super(instantiationService, logService); this._register(this._connectionsService.ambientConnection.onDidAction(envelope => { switch (envelope.action.type) { case ActionType.SessionCustomizationsChanged: case ActionType.SessionCustomizationUpdated: + case ActionType.SessionMcpServerStateChanged: this._fireCustomizationsChanged(); this._fireCustomAgentsChanged(); break; @@ -116,77 +265,48 @@ class WorkbenchAgentHostCustomizationService extends Disposable implements IAgen })); } - getCustomAgents(sessionResource: URI): readonly AgentCustomization[] { - const sessionState = this._readSessionState(sessionResource); - const agents = getEffectiveAgents(sessionState?.customizations); - if (agents.length > 0) { - return agents; - } - - return []; - } - - getCustomizations(sessionResource: URI): readonly Customization[] { - const sessionState = this._readSessionState(sessionResource); - return sessionState?.customizations ?? []; - } - - getWorkingDirectory(sessionResource: URI): string | undefined { - const sessionState = this._readSessionState(sessionResource); - return sessionState?.workingDirectory; - } - - getMcpServers(sessionResource: URI): readonly IAgentHostMcpServer[] { - const target = this._resolveSessionTarget(sessionResource); - if (!target) { - return []; - } - const customizations = this._readSessionState(sessionResource)?.customizations ?? []; - const channel = target.backendSession.toString(); - const logOutputChannelId = this._resolveLogOutputChannelId(sessionResource, target.backendSession); - return customizations - .filter((c): c is McpServerCustomization => c.type === CustomizationType.McpServer) - .map((c): IAgentHostMcpServer => ({ - id: c.id, - name: c.name, - enabled: c.enabled, - status: c.state.kind, - logOutputChannelId, - setEnabled: (enabled: boolean) => { - target.connection.dispatch(channel, { - type: ActionType.SessionCustomizationToggled, - id: c.id, - enabled, - }); - }, - })); - } - - addMcpServer(sessionResource: URI, name: string, config: IMcpServerConfiguration): void { + protected override _resolveTarget(sessionResource: URI): IAgentHostCustomizationTarget | undefined { const target = this._resolveSessionTarget(sessionResource); if (!target) { - return; + return undefined; } - + const sessionState = this._readSessionState(sessionResource); const rootState = target.connection.rootState.value; - if (!rootState || rootState instanceof Error) { - return; - } - - const existingServers = rootState.config?.values?.[AgentHostMcpServersConfigKey]; - const servers: AgentHostMcpServers = existingServers && typeof existingServers === 'object' && !Array.isArray(existingServers) - ? existingServers as AgentHostMcpServers - : {}; - - target.connection.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { - [AgentHostMcpServersConfigKey]: { - ...servers, - [name]: config, - }, + const channel = target.backendSession.toString(); + return { + customizations: sessionState?.customizations ?? [], + workingDirectory: sessionState?.workingDirectory, + logOutputChannelId: this._resolveLogOutputChannelId(sessionResource, target.backendSession), + rootConfig: rootState && !(rootState instanceof Error) ? rootState.config : undefined, + authenticate: request => target.connection.authenticate(request), + setCustomizationEnabled: (rawId, enabled) => { + target.connection.dispatch(channel, { + type: ActionType.SessionCustomizationToggled, + id: rawId, + enabled, + }); }, - }); + startMcpServer: rawId => { + target.connection.dispatch(channel, { + type: ActionType.SessionMcpServerStartRequested, + id: rawId, + }); + return Promise.resolve(); + }, + stopMcpServer: rawId => { + target.connection.dispatch(channel, { + type: ActionType.SessionMcpServerStopRequested, + id: rawId, + }); + return Promise.resolve(); + }, + setRootConfigValue: (property, value) => { + target.connection.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [property]: value }, + }); + } + }; } private _resolveLogOutputChannelId(sessionResource: URI, backendSession: URI): string | undefined { @@ -239,14 +359,6 @@ class WorkbenchAgentHostCustomizationService extends Disposable implements IAgen return entry; } - private _fireCustomAgentsChanged(): void { - this._onDidChangeCustomAgents.fire(); - } - - private _fireCustomizationsChanged(): void { - this._onDidChangeCustomizations.fire(); - } - /** * Resolves a chat session resource to the backend agent-session URI plus * the {@link IAgentConnection} (local or remote) that owns it. Returns diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.ts new file mode 100644 index 00000000000000..016c1ebf75bf39 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostDownloadProgress.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 { DeferredPromise } from '../../../../../../base/common/async.js'; +import { Disposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../../nls.js'; +import { type ProgressParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IProgressService, IProgressStep, ProgressLocation } from '../../../../../../platform/progress/common/progress.js'; +import { ChatConfiguration } from '../../../common/constants.js'; + +/** + * One in-flight download tracked by {@link AgentHostDownloadProgress}. Owns the + * lifecycle of a single notification progress: `report` pushes a step, + * `complete` resolves the backing deferred so the notification is dismissed. + */ +interface IActiveDownload { + /** Last reported determinate percentage, used to compute progress increments. */ + lastPercent: number; + report(step: IProgressStep): void; + complete(): void; +} + +/** + * Renders agent-host `progress` notifications as notification progress bars. + * + * Shared by the Agents window (via `BaseAgentHostSessionsProvider`) and the + * editor window (via `AgentHostContribution`) so both surfaces render the + * agent host's lazy, first-use SDK download identically. + * + * Progress is correlated by {@link ProgressParams.progressToken}; today's only + * producer is the SDK download, which the host surfaces as a single stream per + * provider keyed by the download's own stable identity — so one indicator per + * download regardless of how many sessions await it. Determinate when the host + * knows the `total` (`Content-Length`), or a byte-count spinner otherwise. The + * operation is complete — and the notification dismissed — once + * `progress >= total`. The human-readable brand noun rides on + * {@link ProgressParams.message}. + */ +export class AgentHostDownloadProgress extends Disposable { + + /** + * Active progress indicators keyed by `progressToken`. The host emits a + * single stream per download keyed by the download's own stable identity + * (so distinct sessions of a provider share one indicator). Each entry owns + * one long-running notification progress (opened on the first frame), driven + * via {@link IActiveDownload.report} and dismissed via + * {@link IActiveDownload.complete} once `progress >= total`. + */ + private readonly _activeDownloads = new Map<string, IActiveDownload>(); + + constructor( + @IProgressService private readonly _progressService: IProgressService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { + super(); + this._register(toDisposable(() => { + for (const download of this._activeDownloads.values()) { + download.complete(); + } + this._activeDownloads.clear(); + })); + } + + handleProgress(progress: ProgressParams): void { + // New AI UI must stay hidden when the user has turned AI features off. + if (this._configurationService.getValue<boolean>(ChatConfiguration.AIDisabled)) { + return; + } + + // Complete when we reach the (possibly server-synthesized) total. The + // host emits a terminal frame with `progress === total` for success, + // indeterminate completion, and failure alike; real errors surface via + // the session-failure path, not here. + const isComplete = progress.total !== undefined && progress.progress >= progress.total; + if (isComplete) { + this._activeDownloads.get(progress.progressToken)?.complete(); + this._activeDownloads.delete(progress.progressToken); + return; + } + + let entry = this._activeDownloads.get(progress.progressToken); + if (!entry) { + // First frame for this download: open one long-running notification + // progress and drive it via `report` until a terminal frame resolves + // `deferred`. `message` is the host-supplied, already-localized title + // (e.g. "Downloading Claude agent"); render it verbatim so this stays + // a generic indicator that makes no assumption about what's downloading. + const deferred = new DeferredPromise<void>(); + let report: ((step: IProgressStep) => void) | undefined; + const title = progress.message ?? localize('agentHost.download.titleFallback', "Downloading"); + this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title, + }, + p => { + report = step => p.report(step); + return deferred.p; + }, + ); + entry = { + lastPercent: 0, + report: step => report?.(step), + complete: () => deferred.complete(), + }; + this._activeDownloads.set(progress.progressToken, entry); + } + + if (progress.total && progress.total > 0) { + const percent = Math.max(0, Math.min(100, Math.round((progress.progress / progress.total) * 100))); + const increment = percent - entry.lastPercent; + entry.lastPercent = percent; + entry.report({ + message: localize('agentHost.download.percent', "{0}%", percent), + increment: increment > 0 ? increment : 0, + total: 100, + }); + } else { + // No total: indeterminate. Show megabytes received so the user + // still sees the download making progress. + const megabytes = (progress.progress / (1024 * 1024)).toFixed(1); + entry.report({ message: localize('agentHost.download.megabytes', "{0} MB", megabytes) }); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostImportConversationStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostImportConversationStore.ts new file mode 100644 index 00000000000000..c7ca9319b4c43c --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostImportConversationStore.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. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; +import type { Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import type { ModelSelection } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; + +export const IAgentHostImportConversationStore = createDecorator<IAgentHostImportConversationStore>('agentHostImportConversationStore'); + +/** + * A conversation being imported ("Continue in…") into a new agent-host session: + * the translated {@link Turn}s plus the source session's selected model, so the + * new session resumes on the same model instead of the host's default. + */ +export interface IAgentHostImportConversation { + readonly turns: readonly Turn[]; + readonly model?: ModelSelection; +} + +/** + * Short-lived hand-off for a conversation being imported ("Continue in…") into a + * new agent-host session. The trigger stashes the translated {@link Turn}s keyed + * by the (untitled) session resource; the session handler consumes them once, + * when it creates the backend session, seeding them as real editable history via + * `IAgentCreateSessionConfig.importConversation`. + * + * Entries are consumed exactly once (at session creation) and are not persisted: + * once the turns become real backend events the store has no further role. + */ +export interface IAgentHostImportConversationStore { + readonly _serviceBrand: undefined; + + /** Stash the imported conversation for a session resource (no-op when it has no turns). */ + set(resource: URI, conversation: IAgentHostImportConversation): void; + + /** Read and remove the imported conversation for a session resource, if any. */ + take(resource: URI): IAgentHostImportConversation | undefined; + + /** + * Move a stashed entry from one resource to another. Used when a session + * graduates from its provisional (`untitled-…`) resource to the real + * backend resource before it has been consumed. + */ + rename(oldResource: URI, newResource: URI): void; +} + +export class AgentHostImportConversationStore implements IAgentHostImportConversationStore { + + declare readonly _serviceBrand: undefined; + + private readonly _pending = new Map<string, IAgentHostImportConversation>(); + + set(resource: URI, conversation: IAgentHostImportConversation): void { + if (conversation.turns.length > 0) { + this._pending.set(resource.toString(), conversation); + } + } + + take(resource: URI): IAgentHostImportConversation | undefined { + const key = resource.toString(); + const conversation = this._pending.get(key); + this._pending.delete(key); + return conversation; + } + + rename(oldResource: URI, newResource: URI): void { + const conversation = this.take(oldResource); + if (conversation) { + this._pending.set(newResource.toString(), conversation); + } + } +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts index 554744369b3919..7454ef50610201 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.ts @@ -10,7 +10,7 @@ import { localize } from '../../../../../../nls.js'; import { ConfigSchema, SessionModelInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { readAgentModelPricingMeta } from '../../../../../../platform/agentHost/common/agentModelPricing.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; -import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelChatProvider, ILanguageModelConfigurationSchema } from '../../../common/languageModels.js'; /** * Returns whether an agent host provider exposes a synthetic "Auto" model to @@ -69,12 +69,14 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu const discountPercent = pricing.discountPercent; // Guard against a non-finite or out-of-range value from the open `_meta` bag so we never render // nonsense like "Infinity% discount"; the documented range is a whole number in (0, 100]. - const detail = isAuto && typeof discountPercent === 'number' && discountPercent > 0 && discountPercent <= 100 + const hasDiscount = typeof discountPercent === 'number' && discountPercent > 0 && discountPercent <= 100; + const detail = isAuto && hasDiscount ? localize('agentHost.auto.discount', "{0}% discount", discountPercent) : undefined; const tooltip = isAuto - ? localize('agentHost.auto.tooltip', "Auto selects the best model based on your request complexity and model performance.") + ? ILanguageModelChatMetadata.getAutoModelDescription(hasDiscount ? discountPercent : undefined) : undefined; + const modelGroup = AgentHostLanguageModelProvider._modelGroupFor(m); return { identifier: `${this._vendor}:${m.id}`, metadata: { @@ -102,6 +104,11 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu longContextOutputCost: pricing.longContextOutputCost, priceCategory: pricing.priceCategory, targetChatSessionType: this._sessionType, + // Group agent-host models in the picker by their upstream provider + // (Copilot CLI, OpenAI, a 3p BYOK provider, …). All of a host's + // models share one vendor, so without this they'd render as a single + // undifferentiated bucket. Presentation-only; routing stays by vendor. + ...(modelGroup ? { modelGroup } : {}), capabilities: { vision: m.supportsVision ?? false, toolCalling: true, @@ -143,6 +150,20 @@ export class AgentHostLanguageModelProvider extends Disposable implements ILangu } } + /** + * Derives the picker group id for a model — the vendor its models are bucketed + * under. BYOK models are surfaced by the agent host under the `vendor/id` selection + * id (see `resolveByokSessionConfig`), so their upstream vendor is the id prefix; + * native harness models have no prefix and group under their `provider` (the harness, + * e.g. `copilotcli`). The picker resolves the display name from the vendor registry — + * no name mapping lives here. + */ + private static _modelGroupFor(model: SessionModelInfo): { id: string } | undefined { + const slash = model.id.indexOf('/'); + const groupVendorId = slash > 0 ? model.id.slice(0, slash) : model.provider; + return groupVendorId ? { id: groupVendorId } : undefined; + } + async sendChatRequest(): Promise<never> { throw new Error('Agent-host models do not support direct chat requests'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 817f98ee448679..aebc13acc41b77 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Iterable } from '../../../../../../base/common/iterator.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; @@ -16,7 +17,10 @@ import { type ICustomizationSyncProvider } from '../../../common/customizationHa import { IAgentPlugin, IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { isContributionEnabled } from '../../../common/enablement.js'; import { MCP_PLUGIN_COLLECTION_ID_PREFIX } from '../../../../mcp/common/discovery/pluginMcpDiscovery.js'; -import { IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; import type { ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { isDefined } from '../../../../../../base/common/types.js'; @@ -155,6 +159,53 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf } } +/** + * Attempts to resolve every configuration variable (`${workspaceFolder}`, + * `${env:…}`, …) in an MCP server config without any user interaction, using + * {@link IConfigurationResolverService.resolveAsync}. Returns the resolved + * config, or `undefined` when it cannot be fully resolved without prompting the + * user. + * + * The synced `.mcp.json` is launched by the agent host verbatim, so any + * variable the agent host can't itself expand must be resolved here up front. + * Variables requiring interaction (`${input:…}`, `${command:…}`) or context we + * don't have (e.g. `${workspaceFolder}` outside a folder) cause the server to + * be skipped. + */ +async function resolveConfigurationForSync( + configurationResolverService: IConfigurationResolverService, + folder: IWorkspaceFolderData | undefined, + configuration: IMcpServerConfiguration, +): Promise<IMcpServerConfiguration | undefined> { + const expr = ConfigurationResolverExpression.parse(configuration); + + // Interactive variables (`${input:…}`, `${command:…}`) can only be resolved + // by prompting the user, so a server referencing them is skipped. This is + // checked up front because `resolveAsync` "resolves" them to their own + // literal text when no value mapping is supplied, which would otherwise + // leave them out of `unresolved()` below. + for (const replacement of expr.unresolved()) { + if (replacement.name === 'input' || replacement.name === 'command') { + return undefined; + } + } + + try { + // Resolves everything that can be resolved without interaction; throws + // when a variable requires context we don't have (e.g. no folder). + await configurationResolverService.resolveAsync(folder, expr); + } catch { + return undefined; + } + + // Any replacement left unresolved would require user interaction. + if (!Iterable.isEmpty(expr.unresolved())) { + return undefined; + } + + return expr.toObject(); +} + /** * Enumerates MCP servers configured directly in VS Code — i.e. those that * are not contributed by an agent plugin — so they can be bundled into the @@ -162,8 +213,15 @@ function launchToMcpServerConfiguration(launch: McpServerLaunch): IMcpServerConf * are already synced via their owning plugin's customization ref. Disabled * servers and servers whose launch cannot be expressed declaratively are * skipped. + * + * Workspace-discovered servers are also excluded by default: the agent host + * discovers workspace `.mcp.json` itself, so syncing them would duplicate. The + * exception is `.vscode/mcp.json`, which the agent host does not discover + * (despite what the SDK's `enableConfigDiscovery` docs imply) — those are + * synced, but only when their config can be resolved without requiring user + * interaction. */ -export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMcpServer[] { +export async function collectNonPluginMcpServers(mcpService: IMcpService, configurationResolverService: IConfigurationResolverService): Promise<ISyncableMcpServer[]> { const result: ISyncableMcpServer[] = []; for (const server of mcpService.servers.get()) { if (server.collection.id.startsWith(MCP_PLUGIN_COLLECTION_ID_PREFIX)) { @@ -172,14 +230,27 @@ export function collectNonPluginMcpServers(mcpService: IMcpService): ISyncableMc if (!isContributionEnabled(server.enablement.get())) { continue; } - const launch = server.readDefinitions().get().server?.launch; + const definitions = server.readDefinitions().get(); + const definition = definitions.server; + const launch = definition?.launch; if (!launch) { continue; } - const configuration = launchToMcpServerConfiguration(launch); + let configuration = launchToMcpServerConfiguration(launch); if (!configuration) { continue; } + const collection = definitions.collection; + if (collection && McpCollectionDefinition.isWorkspaceDiscovered(collection)) { + if (!McpCollectionDefinition.isVscodeMcpJson(collection)) { + continue; + } + const resolved = await resolveConfigurationForSync(configurationResolverService, definition.variableReplacement?.folder, configuration); + if (!resolved) { + continue; + } + configuration = resolved; + } result.push({ name: server.definition.label, configuration }); } return result; @@ -200,6 +271,7 @@ export async function resolveCustomizationRefs( syncProvider: ICustomizationSyncProvider, agentPluginService: IAgentPluginService, mcpService: IMcpService, + configurationResolverService: IConfigurationResolverService, bundler: SyncedCustomizationBundler, sessionType: string, ): Promise<ClientPluginCustomization[]> { @@ -272,7 +344,7 @@ export async function resolveCustomizationRefs( } const refs: Promise<ClientPluginCustomization | undefined>[] = [...pluginRefs.values()]; - const mcpServers = collectNonPluginMcpServers(mcpService); + const mcpServers = await collectNonPluginMcpServers(mcpService, configurationResolverService); if (looseFiles.length > 0 || mcpServers.length > 0) { refs.push(bundler.bundle(looseFiles, mcpServers).then(r => r?.ref)); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts index f102fadf86a2ee..c6efc7c1f68630 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostModeSynchronizer.ts @@ -4,21 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { agentHostAgentPickerStorageKey } from '../../../../../../platform/agentHost/common/customAgents.js'; +import { isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../common/contributions.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { IChatWidget, IChatWidgetService } from '../../chat.js'; import { ChatMode, IChatMode, IChatModes } from '../../../common/chatModes.js'; import { ChatModeKind } from '../../../common/constants.js'; +import type { IChatModeChangeEvent } from '../../widget/input/chatInputPart.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; const AGENT_HOST_SESSION_SCHEME_PREFIX = 'agent-host-'; -class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribution { +export class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.agentHostModeSynchronizer'; private readonly _widgetListeners = this._register(new DisposableMap<IChatWidget>()); @@ -59,13 +62,21 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu } const store = new DisposableStore(); - store.add(widget.input.onDidChangeCurrentChatMode(() => this._onWidgetModeChanged(widget))); + store.add(widget.input.onDidChangeCurrentChatMode(e => this._onWidgetModeChanged(widget, e))); store.add(widget.onDidChangeViewModel(() => this._syncWidgetFromBackend(widget))); + store.add(autorun(reader => { + const modes = widget.input.currentChatModesObs.read(reader); + reader.store.add(modes.onDidChange(() => this._syncWidgetFromBackend(widget))); + })); this._widgetListeners.set(widget, store); this._syncWidgetFromBackend(widget); } - private _onWidgetModeChanged(widget: IChatWidget): void { + private _onWidgetModeChanged(widget: IChatWidget, e: IChatModeChangeEvent): void { + if (!e.isUserInitiated) { + return; + } + if (this._updatingWidgets.has(widget)) { return; } @@ -91,11 +102,23 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu return; } + // The per-scheme stored agent is only a SEED for NEW (untitled) sessions — the same + // semantics as the Agents Window new-session picker. An established or restored session's + // agent is its own persisted mode, so applying the shared value here would override it + // (e.g. flipping the user's `Agent` to a stale custom agent the moment the backend session + // resolves on the first send). + if (!isUntitledChatSession(sessionResource)) { + return; + } + const agentUri = this._readSelectedAgent(sessionResource); + if (agentUri === undefined) { + return; + } void this._applyMode(widget, sessionResource, agentUri); } - private async _applyMode(widget: IChatWidget, sessionResource: URI, agentUri: string | undefined): Promise<void> { + private async _applyMode(widget: IChatWidget, sessionResource: URI, agentUri: string): Promise<void> { const modes = widget.input.currentChatModesObs.get(); await modes.waitForPendingUpdates(); @@ -103,8 +126,7 @@ class AgentHostModeSynchronizer extends Disposable implements IWorkbenchContribu return; } - const modeId = agentUri ?? ChatMode.Agent.id; - const mode = this._findMode(modes, modeId); + const mode = this._findMode(modes, agentUri); if (!mode || widget.input.currentModeObs.get().id === mode.id) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts index 1da734f7c3f20b..eddde2c231272c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostResponseFileChanges.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable } from '../../../../../../base/common/lifecycle.js'; -import { constObservable, derived, IObservable, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { constObservable, derived, derivedOpts, IObservable, observableFromEvent } from '../../../../../../base/common/observable.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; @@ -64,7 +65,7 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements // the summary stays empty (and self-hidden) for them. const sessionStateObs = this._subscribe<SessionState>(StateComponents.Session, constObservable(backendSession)); - const turnChangesetUriObs = derived(reader => { + const turnChangesetUriObs = derivedOpts<URI | undefined>({ equalsFn: isEqual }, reader => { const sessionState = sessionStateObs.read(reader).read(reader); if (!sessionState || sessionState instanceof Error) { return undefined; @@ -123,9 +124,19 @@ export class AgentHostResponseFileChangesProvider extends Disposable implements ? toAgentHostUri(normalized.beforeContentUri, this._connectionAuthority) : modifiedURI; + // The frozen after-turn snapshot, when the changeset provides one. Lets + // consumers show this turn's diff (before-snapshot -> after-snapshot) + // rather than before-snapshot -> live file (which includes later turns). + // Distinct from the checkpoint-ref readability fix (#323932): that made + // these blobs readable; this line decides *which* snapshot to diff against. + const modifiedSnapshotURI = normalized.afterContentUri + ? toAgentHostUri(normalized.afterContentUri, this._connectionAuthority) + : undefined; + return { originalURI, modifiedURI, + modifiedSnapshotURI, added: file.edit.diff?.added ?? 0, removed: file.edit.diff?.removed ?? 0, quitEarly: false, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRootConfigForwarder.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRootConfigForwarder.ts new file mode 100644 index 00000000000000..51724a941c09b4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostRootConfigForwarder.ts @@ -0,0 +1,184 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { structuralEquals } from '../../../../../../base/common/equals.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; +import { CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; +import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; +import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js'; + +/** + * A single root-config key managed by an {@link AgentHostRootConfigForwarder}: + * says only *what* its value is and *when* to recompute it. The forwarder owns + * the schema gate, value-equality guard, dispatch, and hydration retry. + */ +export interface IForwardedRootConfigKey { + /** The root-config key this descriptor owns. */ + readonly key: AgentHostConfigKey | CopilotCliConfigKey; + + /** Compute the desired value; return `undefined` to skip the push. May be async. */ + computeValue(): unknown | Promise<unknown>; + + /** Wire up the triggers that should re-push this key; add disposables to `store`, call `push`. */ + registerTriggers(store: DisposableStore, push: () => void): void; +} + +/** + * Shared engine that forwards VS Code-derived values into the **local** agent + * host's root config so the host (and the CLI session launcher) can read them + * via `getRootValue`. Not a contribution itself: a workbench contribution + * constructs one with its own {@link IForwardedRootConfigKey} table and drives + * {@link start} / {@link stop} from its own enablement gate. Shared by + * `AgentHostTerminalContribution` and `AgentHostCopilotCliSettingsContribution` + * so the three correctness constraints below live in exactly one place. + * + * The three constraints forwarding into the shared root config requires: + * 1. **Schema gate.** A key is dispatched only once the host advertises it in + * its root-config schema - protects older / third-party agent hosts (and an + * older host that advertises only a subset of the keys) from receiving keys + * they don't understand. + * 2. **Hydration retry.** The host's `rootState` may hydrate *after* the + * forwarder starts, so a key whose schema isn't present yet is retried when + * the schema first appears (see {@link _onRootStateChanged}). + * 3. **Cross-window loop guard.** The local agent host's root config is shared + * across windows, so a key is dispatched only when its value actually + * changes (compared structurally - see {@link _push}). Reacting to a + * value-only change pushed by another window would otherwise start an + * infinite update war, each window forcing its own value back over the + * other's (#314385). + * + * Local agent host only. Remote agent hosts (via + * `IRemoteAgentHostService.connections`) are intentionally not fanned out to: + * e.g. a resolved shell path is local-machine-shaped and not necessarily valid + * on the remote. Remote operators should configure such values server-side via + * the remote's `agent-host-config.json`. See + * https://github.com/microsoft/vscode/issues/313160 follow-ups. + */ +export class AgentHostRootConfigForwarder extends Disposable { + + private readonly _listeners = this._register(new MutableDisposable<DisposableStore>()); + + /** + * Managed keys whose schema the host has already advertised, so a key is + * re-pushed only when its schema *first* appears (see {@link _onRootStateChanged}). + */ + private readonly _schemaSeen = new Set<AgentHostConfigKey | CopilotCliConfigKey>(); + + constructor( + private readonly _keys: readonly IForwardedRootConfigKey[], + private readonly _agentHostService: IAgentHostService, + ) { + super(); + } + + /** + * Begin listening for triggers / agent-host (re)starts / schema hydration and + * do the initial push. Idempotent. + */ + start(): void { + if (this._listeners.value) { + return; + } + const store = new DisposableStore(); + store.add(this._agentHostService.onAgentHostStart(() => this.reconcile())); + for (const entry of this._keys) { + entry.registerTriggers(store, () => this._push(entry)); + } + store.add(this._agentHostService.rootState.onDidChange(() => this._onRootStateChanged())); + // Seed schema-seen so the immediate reconcile() counts as the initial push + // for already-advertised keys (rather than being re-fired by _onRootStateChanged). + this._schemaSeen.clear(); + for (const entry of this._keys) { + if (this._schemaHasKey(entry.key)) { + this._schemaSeen.add(entry.key); + } + } + this._listeners.value = store; + this.reconcile(); + } + + /** Stop listening and forget advertised-schema state. Idempotent. */ + stop(): void { + this._schemaSeen.clear(); + this._listeners.value = undefined; + } + + /** Push every managed key (e.g. on start and after an agent-host restart). */ + reconcile(): void { + for (const entry of this._keys) { + this._push(entry); + } + } + + /** + * Push managed values only for keys whose schema has just transitioned from + * absent to present (host root-config hydration). Value-only changes — e.g. + * another window writing a different value — are ignored so windows don't + * fight in an infinite loop. + */ + private _onRootStateChanged(): void { + for (const entry of this._keys) { + if (this._schemaHasKey(entry.key)) { + if (!this._schemaSeen.has(entry.key)) { + this._schemaSeen.add(entry.key); + this._push(entry); + } + } else { + this._schemaSeen.delete(entry.key); + } + } + } + + private _schemaHasKey(key: AgentHostConfigKey | CopilotCliConfigKey): boolean { + const rootState = this._agentHostService.rootState.value; + if (!rootState || rootState instanceof Error) { + return false; + } + return !!rootState.config?.schema.properties[key]; + } + + /** + * Push pipeline for a managed key: no-op if the schema doesn't advertise it + * (retried on hydration); compute the value (`undefined` skips); dispatch only + * if the host doesn't already hold a structurally-equal value — which breaks + * cross-window loops (#314385) and, being structural not `===`, never + * re-dispatches an unchanged object value. + */ + private async _push(entry: IForwardedRootConfigKey): Promise<void> { + if (!this._schemaHasKey(entry.key)) { + return; + } + + let value: unknown; + try { + value = await entry.computeValue(); + } catch { + return; + } + if (value === undefined) { + return; + } + + // Re-check after the await: a host restart / schema refresh may have landed + // while we resolved, so never dispatch a key the current schema dropped. + if (!this._schemaHasKey(entry.key)) { + return; + } + const rootState = this._agentHostService.rootState.value; + if (!rootState || rootState instanceof Error || !rootState.config) { + return; + } + if (structuralEquals(rootState.config.values[entry.key], value)) { + return; + } + + this._agentHostService.dispatch(ROOT_STATE_URI, { + type: ActionType.RootConfigChanged, + config: { [entry.key]: value }, + }); + } +} 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 9aa8cf0e3800bc..9096e45dd89ee8 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Delayer } from '../../../../../../base/common/async.js'; +import { Delayer, disposableTimeout } from '../../../../../../base/common/async.js'; import { encodeBase64, VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; import { isCancellationError } from '../../../../../../base/common/errors.js'; @@ -12,31 +12,42 @@ import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { getChatErrorDetailsFromMeta, getCopilotPlanFromEntitlement, IChatErrorContext } from '../../../common/chatErrorMessages.js'; import { Disposable, DisposableResourceMap, DisposableStore, IReference, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; +import { Schemas } from '../../../../../../base/common/network.js'; import { equals } from '../../../../../../base/common/objects.js'; -import { autorun, autorunPerKeyedItem, derived, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; +import { autorun, autorunPerKeyedItem, constObservable, derived, derivedOpts, IObservable, ISettableObservable, observableValue, transaction } from '../../../../../../base/common/observable.js'; import { extUriBiasedIgnorePathCase, isEqual } from '../../../../../../base/common/resources.js'; +import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { Mutable } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IPosition } from '../../../../../../editor/common/core/position.js'; +import type { IRange } from '../../../../../../editor/common/core/range.js'; import { isLocation, type Location } from '../../../../../../editor/common/languages.js'; +import type { ITextModel } from '../../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; import { localize } from '../../../../../../nls.js'; import { AgentProvider, AgentSession, type IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; +import { agentHostAuthority } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; +import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; import { readCompletionAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; +import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import type { ChatInputRequestWithPlanReview, IAgentHostPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { IAgentSubscription, observableFromSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ChatTruncatedAction } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionItem as AhpCompletionItem } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; -import { ConfirmationOptionKind, JsonPrimitive, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { ConfirmationOptionKind, CustomizationType, JsonPrimitive, McpServerAuthRequiredState, McpServerStatus, TerminalClaimKind, ToolCallContributorKind, ToolResultContentType, type ConfirmationOption, type ProtectedResourceMetadata, type SessionActiveClient } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { ActionType, ChatTurnStartedAction, isChatAction, type ChatAction, type ClientChatAction, type ClientSessionAction, type ChatInputCompletedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import { AHP_AUTH_REQUIRED, ProtocolError } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, getToolSubagentContent, MessageAttachmentKind, MessageKind, PendingMessageKind, ResponsePartKind, ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, StateComponents, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, TurnState, parseChatUri, mergeSessionWithDefaultChat, type ChatState, type ISessionWithDefaultChat, type ClientPluginCustomization, type ICompletedToolCall, type MarkdownResponsePart, type Message, type MessageAttachment, type MessageAnnotationsAttachment, type MessageResourceAttachment, type MessageEmbeddedResourceAttachment, type ModelSelection, type ReasoningResponsePart, type RootState, type ChatInputAnswer, type ChatInputRequest, type SessionState, type ToolCallResponsePart, type ToolCallState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; @@ -45,17 +56,19 @@ import { AgentHostCompletionReferenceKind, getAgentHostCompletionReferenceKind, isAgentFeedbackVariableEntry, + isBrowserViewVariableEntry, isImageVariableEntry, type IAgentFeedbackVariableEntry, type IChatRequestVariableEntry, type IImageVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { coerceImageBuffer } from '../../../common/chatImageExtraction.js'; -import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; +import { ChatRequestQueueKind, ConfirmedReason, ElicitationState, IChatProgress, IChatQuestion, IChatQuestionAnswers, IChatService, IChatToolInvocation, ToolConfirmKind, type IChatMcpAuthenticationRequired, type IChatMcpAuthenticationRequiredServer, type IChatMcpStartingServer, type IChatMultiSelectAnswer, type IChatPlanReviewResult, type IChatQuestionAnswerValue, type IChatResponseErrorDetails, type IChatSingleSelectAnswer, type IChatTerminalToolInvocationData } from '../../../common/chatService/chatService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionRequestHistoryItem, SessionType, type IChatInputCompletionItem, type IChatInputCompletionsParams, type IChatInputCompletionsResult, type IChatSessionServerRequest } from '../../../common/chatSessionsService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ChatMode } from '../../../common/chatModes.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; import { type IChatModel, type IChatModelInputState, type IChatRequestVariableData, type ISerializableChatModelInputState } from '../../../common/model/chatModel.js'; @@ -74,11 +87,21 @@ import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderSe import { AgentHostSnapshotController } from './agentHostSnapshotController.js'; import { AgentHostResponseFileChangesProvider } from './agentHostResponseFileChanges.js'; import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceAttachmentMeta, toSessionReferenceModelRepresentation } from './agentHostSessionReferenceAttachment.js'; +import { buildHostLocalEventsPath } from '../../copilotCliEventsUri.js'; import { toolDataToDefinition } from './agentHostToolUtils.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; -import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, stringOrMarkdownToString, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; +import { activeTurnToProgress, completedToolCallToEditParts, completedToolCallToSerialized, finalizeToolInvocation, formatTurnResponseDetails, getTerminalContentUri, isSubagentTool, makeAhpTerminalToolSessionId, messageAttachmentsToVariableData, messageToVariableData, parseAhpTerminalToolSessionId, rawMarkdownToString, rewriteAgentHostLinkTarget, stringOrMarkdownToString, systemNotificationToChatPart, toolCallStateToInvocation, turnsToHistory, updateRunningToolSpecificData, usageInfoToChatUsage, usageInfoToQuotas, type IToolCallFileEdit, type TurnModelLookup } from './stateToProgressAdapter.js'; +import { resolveMcpServerAuthentication, agentHostMcpServerId } from './agentHostAuth.js'; export { toolDataToDefinition }; +/** + * Upper bound on the live editor text we inline for an unsaved document, matching the 1 MB per-file cap chat uses + * elsewhere (`chatRepoInfo`). Larger buffers are not inlined; a dirty saved file then falls back to its on-disk path. + */ +const MAX_INLINED_UNSAVED_EDITOR_BYTES = 1024 * 1024; + // ============================================================================= // AgentHostSessionHandler - renderer-side handler for a single agent host // chat session type. Bridges the protocol state layer with the chat UI: @@ -119,6 +142,7 @@ interface IObserveTurnOptions { readonly cancellationToken: CancellationToken; readonly adoptInvocations?: ReadonlyMap<string, ChatToolInvocation>; readonly seedEmittedLengths?: ReadonlyMap<string, number>; + readonly initialResponsePartCount?: number; readonly onTurnEnded?: (lastTurn: Turn | undefined) => void; readonly onFileEdits?: (tc: ToolCallState, fileEdits: IToolCallFileEdit[]) => void; /** @@ -132,8 +156,7 @@ interface IObserveTurnOptions { * Tool calls emitted into {@link sink} are tagged with this id so the * renderer groups them under the parent subagent widget. Markdown, * reasoning, and input requests are not forwarded (the subagent's own - * session view renders those); nested subagent observation is also - * suppressed to preserve legacy behavior. + * session view renders those); nested subagents are observed recursively. */ readonly subAgentInvocationId?: string; /** @@ -182,6 +205,15 @@ function lastTurnModelSelection(state: ISessionWithDefaultChat | undefined): Mod return lastTurnMessage(state)?.model; } +/** + * Whether a progress emission counts as the turn's first visible progress + * for time-to-first-progress telemetry. Mirrors the agent host's own + * definition (text delta, response part, tool call start, or reasoning). + */ +function isFirstVisibleProgressPart(part: IChatProgress): boolean { + return part.kind === 'markdownContent' || part.kind === 'thinking' || part.kind === 'toolInvocation'; +} + function lastTurnMessage(state: ISessionWithDefaultChat | undefined): Message | undefined { return state?.activeTurn?.message ?? (state && state.turns.length ? state.turns[state.turns.length - 1].message : undefined); } @@ -622,6 +654,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IAgentHostUntitledProvisionalSessionService private readonly _provisionalService: IAgentHostUntitledProvisionalSessionService, + @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @@ -629,7 +662,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC @IAgentHostActiveClientService private readonly _activeClientService: IAgentHostActiveClientService, @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IModelService private readonly _modelService: IModelService, + @IWorkingCopyService private readonly _workingCopyService: IWorkingCopyService, + @IConfigurationService private readonly _configurationService: IConfigurationService, @IChatResponseFileChangesService private readonly _chatResponseFileChangesService: IChatResponseFileChangesService, + @IPathService private readonly _pathService: IPathService, + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, ) { super(); this._config = config; @@ -826,6 +864,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const isNewSession = this._isNewSessionResource(sessionResource); const history: IChatSessionHistoryItem[] = []; let initialProgress: IChatProgress[] | undefined; + let initialResponsePartCount = 0; let activeTurnId: string | undefined; let sessionTitle: string | undefined; let draftInputState: ISerializableChatModelInputState | undefined; @@ -901,6 +940,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC details: lookup.toResponseDetails(activeRawModelId, sessionState.activeTurn.usage), }); initialProgress = activeTurnToProgress(resolvedSession, sessionState.activeTurn, this._config.connectionAuthority); + initialResponsePartCount = sessionState.activeTurn.responseParts.length; // Enrich usage entries with the actual model so the // context-usage widget resolves the right context window // on reconnection (same enrichment as _observeTurn). @@ -1011,7 +1051,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // If reconnecting to an active turn, wire up an ongoing state listener // to stream new progress into the session's progressObs. if (activeTurnId && initialProgress !== undefined) { - this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress); + this._reconnectToActiveTurn(resolvedSession, activeTurnId, session, initialProgress, initialResponsePartCount); } // For existing sessions, start watching for server-initiated turns @@ -1097,7 +1137,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // folder-pick time, or this session was created via a legacy/ // test path). Fall back to the original create-then-subscribe // flow. - await this._createAndSubscribe(request.sessionResource, this._createModelSelection(request.userSelectedModelId, request.modelConfiguration), undefined, request.agentHostSessionConfig); + // + // If a conversation was imported ("Continue in…") into this + // session, seed it as real editable history at creation time. + const imported = this._importConversationStore.take(request.sessionResource); + const model = imported?.model ?? this._createModelSelection(request.userSelectedModelId, request.modelConfiguration); + await this._createAndSubscribe(request.sessionResource, model, undefined, request.agentHostSessionConfig, imported ? { turns: imported.turns, model: imported.model } : undefined); } else { // Eager-created session: take a refcounted subscription so the // handler observes state changes for the duration of the chat @@ -1124,11 +1169,23 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } - const completedTurn = await this._handleTurn(resolvedSession, request, progress, cancellationToken); + // Measure turn timings so the core `interactiveSessionProviderInvoked` + // telemetry event is populated for agent-host providers. + const stopWatch = StopWatch.create(false); + let firstProgress: number | undefined; + const measuredProgress = (parts: IChatProgress[]) => { + if (firstProgress === undefined && parts.some(isFirstVisibleProgressPart)) { + firstProgress = stopWatch.elapsed(); + } + progress(parts); + }; + + const completedTurn = await this._handleTurn(resolvedSession, request, measuredProgress, cancellationToken); const details = this._getTurnResponseDetails(request.sessionResource, resolvedSession, completedTurn); const errorDetails = this._getTurnErrorDetails(completedTurn); return { + timings: { firstProgress, totalElapsed: stopWatch.elapsed() }, ...(details ? { details } : {}), ...(errorDetails ? { errorDetails } : {}), }; @@ -1734,6 +1791,36 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const responseParts$ = derived(reader => turn$.read(reader)?.responseParts ?? []); const inputRequests$ = derived(reader => mergedState$.read(reader)?.inputRequests ?? []); const usage$ = derived(reader => turn$.read(reader)?.usage); + const mcpAuthRequired$ = derivedOpts({ equalsFn: equals }, reader => { + const state = mergedState$.read(reader); + const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer + ? [c] + : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []) ?? []; + const authRequiredServers = servers.filter(server => server.enabled && server.state.kind === McpServerStatus.AuthRequired); + return authRequiredServers.map((server): IChatMcpAuthenticationRequiredServer => { + const state = server.state as McpServerAuthRequiredState; + return { + id: opts.sessionResource.authority + '/' + server.id, + name: server.name, + resource: state.resource.resource, + authorizationServers: state.resource.authorization_servers, + requiredScopes: state.requiredScopes, + reason: state.reason, + }; + }); + }); + const mcpStarting$ = derivedOpts({ equalsFn: equals }, reader => { + const state = mergedState$.read(reader); + const servers = state?.customizations?.flatMap(c => c.type === CustomizationType.McpServer + ? [c] + : c.children?.filter(c => c.type === CustomizationType.McpServer) ?? []) ?? []; + return servers + .filter(server => server.enabled && server.state.kind === McpServerStatus.Starting) + .map((server): IChatMcpStartingServer => ({ + id: opts.sessionResource.authority + '/' + server.id, + name: server.name, + })); + }); // Subagent observation context: dedups subagent tool calls so each is // observed once. @@ -1776,6 +1863,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC case ResponsePartKind.ToolCall: this._setupToolCallPart(part$ as IObservable<ToolCallResponsePart>, partStore, opts, subagentContext); break; + case ResponsePartKind.SystemNotification: + // System notifications don't have an id, so we have to identify it by index + if (responseParts$.get().indexOf(initial) >= (opts.initialResponsePartCount ?? 0) && opts.subAgentInvocationId === undefined) { + const progress = systemNotificationToChatPart(initial.content, this._config.connectionAuthority); + if (progress) { + opts.sink([progress]); + } + } + break; } }, )); @@ -1785,6 +1881,73 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC // own view, not the parent. if (opts.subAgentInvocationId === undefined) { let lastUsage: ReturnType<typeof usageInfoToChatUsage>; + let mcpAuthPart: IChatMcpAuthenticationRequired & { servers: ISettableObservable<IChatMcpAuthenticationRequiredServer[]> } | undefined; + let mcpAuthRunId = 0; + + store.add(autorun(reader => { + const pendingAuth = mcpAuthRequired$.read(reader); + const runId = ++mcpAuthRunId; + this._filterAutoGrantedMcpAuthentication(opts.sessionResource, pendingAuth).then(servers => { + // Ignore stale completions: a newer run has superseded this one + // (guards against out-of-order resolution of the async filter). + if (runId !== mcpAuthRunId) { + return; + } + // Don't emit an empty prompt: only surface the part once there is + // something to authenticate, or to update/hide a live prompt. + if (!servers.length && (!mcpAuthPart || mcpAuthPart.isUsed)) { + return; + } + if (!mcpAuthPart || mcpAuthPart.isUsed) { + mcpAuthPart = { + kind: 'mcpAuthenticationRequired', + sessionResource: opts.sessionResource.toJSON(), + isUsed: false, + servers: observableValue('mcpAuthNeededServers', []), + }; + opts.sink([mcpAuthPart]); + } + mcpAuthPart.servers.set(servers.slice(), undefined); + }); + })); + + // Surface a "Starting MCP servers …" progress hint when servers + // remain in the `Starting` state past a short grace period after the + // turn begins without any content arriving from the host. The part + // updates as servers finish and hides once every server has started, + // content starts being received, or the turn ends — whichever comes + // first. It carries no interactive affordance (no "Skip"). + { + const MCP_STARTING_GRACE_MS = 5000; + + let didAppend = false; + const hasContent$ = responseParts$.map(r => r.length > 0); + const hasServersStarting$ = mcpStarting$.map(s => s.length > 0); + const serversStartingInput = observableValue('mcpStartingServersInput', constObservable<IChatMcpStartingServer[]>([])); + + store.add(autorun(reader => { + if (hasContent$.read(reader) || !hasServersStarting$.read(reader)) { + serversStartingInput.set(constObservable([]), undefined); + return; + } + + reader.store.add(disposableTimeout(() => { + serversStartingInput.set(mcpStarting$, undefined); + if (!didAppend) { + didAppend = true; + opts.sink([{ + kind: 'mcpServersStartingSlow', + sessionResource: opts.sessionResource, + servers: serversStartingInput.map((o, r) => o.read(r)), + }]); + } + + }, MCP_STARTING_GRACE_MS)); + })); + + store.add(toDisposable(() => serversStartingInput.set(constObservable([]), undefined))); + } + store.add(autorun(reader => { const rawUsage = usage$.read(reader); // The parent turn's usage already aggregates the parent agent's @@ -1973,6 +2136,35 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return store; } + private async _filterAutoGrantedMcpAuthentication(sessionResource: URI, servers: readonly IChatMcpAuthenticationRequiredServer[]): Promise<readonly IChatMcpAuthenticationRequiredServer[]> { + const remaining: IChatMcpAuthenticationRequiredServer[] = []; + for (const server of servers) { + try { + const authenticated = await this._instantiationService.invokeFunction(resolveMcpServerAuthentication, { + resource: server.resource, + resource_name: server.name, + authorization_servers: server.authorizationServers ? [...server.authorizationServers] : undefined, + scopes_supported: server.requiredScopes ? [...server.requiredScopes] : undefined, + }, { + allowInteraction: false, + logPrefix: '[AgentHost]', + mcpServerId: agentHostMcpServerId(sessionResource.authority, server.name, server.resource), + mcpServerName: server.name, + mcpServerUrl: server.resource, + agentHost: { scheme: sessionResource.scheme, authority: sessionResource.authority }, + authenticate: request => this._config.connection.authenticate(request), + }); + if (!authenticated) { + remaining.push(server); + } + } catch (err) { + this._logService.error(`[AgentHost] Failed to auto-authenticate MCP server '${server.name}'`, err); + remaining.push(server); + } + } + return remaining; + } + private _setupMarkdownPart( part$: IObservable<MarkdownResponsePart>, store: DisposableStore, @@ -1989,7 +2181,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const delta = content.substring(lastEmitted); lastEmitted = content.length; - opts.sink([{ kind: 'markdownContent', content: rawMarkdownToString(delta, this._config.connectionAuthority) }]); + opts.sink([{ kind: 'markdownContent', content: new MarkdownString(delta) }]); })); } @@ -2049,23 +2241,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } const tryObserveSubagent = (tc: ToolCallState) => { - // Don't recurse into nested subagents \u2014 legacy behavior was to - // only observe the immediate child session, not children of - // children. - if (subAgentInvocationId !== undefined) { - return; - } if (subagentContext.observedToolIds.has(toolCallId)) { return; } - const subagentContent = (tc.status === ToolCallStatus.Running || tc.status === ToolCallStatus.Completed) ? getToolSubagentContent(tc) : undefined; - if (!subagentContent && !isSubagentTool(tc)) { + // Only observe a tool once it has started running (or finished). + if (tc.status !== ToolCallStatus.Running && tc.status !== ToolCallStatus.Completed) { return; } - if (!subagentContent) { + // Observe as a subagent if the tool is a known subagent-spawning + // tool (from `_meta.toolKind`/name) or already carries a Subagent + // content block (older restored snapshots). We deliberately do NOT + // *require* the content block: the child chat URI is derived from + // the tool call id alone (see `_observeSubagentSession`), so the + // block is not needed to subscribe. This keeps observation robust + // even when the discovery content block never reaches this chat — + // e.g. an agent host that does not route it to the immediate + // parent chat of a nested subagent, or a restored snapshot that + // predates it. Gating on the block would otherwise leave such a + // nested subagent — and any client tools it calls — unobserved, + // hanging the session. + if (!isSubagentTool(tc) && !getToolSubagentContent(tc)) { return; } subagentContext.observedToolIds.add(toolCallId); + if (invocation.toolSpecificData?.kind === 'subagent') { + invocation.toolSpecificData.isActive = true; + invocation.notifyToolSpecificDataChanged(); + } // Track this subagent's own running credit (AIC) total so it can be // surfaced on the subagent tool's hover and persisted via its @@ -2092,7 +2294,11 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } })); - this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, opts.sink, store, subagentContext, perInvocationCredits, perInvocationModel); + // Group descendant tool calls under the root subagent so the + // renderer nests the whole tree under one container; for the + // top-level subagent the root is this tool call itself. + const rootInvocationId = subAgentInvocationId ?? toolCallId; + this._observeSubagentSession(opts.sessionResource, opts.backendSession, toolCallId, rootInvocationId, invocation, opts.sink, store, subagentContext, perInvocationCredits, perInvocationModel); }; // Initial confirmation hookup. The autorun below only handles @@ -2137,6 +2343,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC updateRunningToolSpecificData(invocation, tc, opts.backendSession, this._config.connectionAuthority); } + tryObserveSubagent(tc); + if ((status === ToolCallStatus.Completed || status === ToolCallStatus.Cancelled) && !IChatToolInvocation.isComplete(invocation)) { // Revive terminal before finalizing — handles the case where // Running was skipped (e.g. throttling) and terminal content @@ -2147,8 +2355,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC opts.onFileEdits?.(tc, fileEdits); } } - - tryObserveSubagent(tc); })); // If the turn ends with the tool still mid-flight (e.g. external @@ -2204,6 +2410,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const invocation = this._toolsService.beginToolCall({ toolCallId, toolId: toolData.id, + subagentInvocationId: opts.subAgentInvocationId, sessionResource: opts.sessionResource, force: true, }) as ChatToolInvocation | undefined; @@ -2278,25 +2485,29 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (cts.token.isCancellationRequested) { return; } - if (!approvedDispatched) { - if (err !== undefined && !isCancellationError(err)) { - this._logService.warn(`[AgentHost] Client tool rejected pre-execution: ${toolName}`, err); - } - return; - } + if (err !== undefined) { if (!isCancellationError(err)) { - this._logService.warn(`[AgentHost] Client tool invocation failed: ${toolName}`, err); + if (!approvedDispatched) { + this._logService.warn(`[AgentHost] Client tool rejected pre-execution: ${toolName}`, err); + } else { + this._logService.warn(`[AgentHost] Client tool invocation failed: ${toolName}`, err); + } } - const message = err instanceof Error ? err.message : String(err); - result = { content: [], toolResultError: message }; + + result = { content: [], toolResultError: err instanceof Error ? err.message : String(err) }; + } + + const protocolToolCall = part$.get().toolCall; + const isProtocolToolCallComplete = protocolToolCall.status === ToolCallStatus.Completed || protocolToolCall.status === ToolCallStatus.Cancelled; + if (!isProtocolToolCallComplete) { + this._dispatchAction(opts.backendSession, { + type: ActionType.ChatToolCallComplete, + turnId: opts.turnId, + toolCallId, + result: toolResultToProtocol(result ?? { content: [] }, toolName), + }, opts.chatURI); } - this._dispatchAction(opts.backendSession, { - type: ActionType.ChatToolCallComplete, - turnId: opts.turnId, - toolCallId, - result: toolResultToProtocol(result ?? { content: [] }, toolName), - }, opts.chatURI); }; // React to part$ updates: route external cancellation, and try to @@ -2308,12 +2519,21 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && shouldAutoApproveClientToolCall(tc)) { state.confirm({ type: ToolConfirmKind.Setting, id: SessionConfigKey.AutoApprove }); } - if (tc.status === ToolCallStatus.Cancelled) { + if (tc.status === ToolCallStatus.Cancelled || tc.status === ToolCallStatus.Completed) { + // The protocol tool call reached a terminal state. If this was + // driven by the server (e.g. the client-tool bridge abandoned the + // call because the client was considered disconnected, the turn was + // superseded, or a reconnect occurred) while our local `invokeTool` + // is still running, cancel it so the tool cleans up (e.g. dismisses a + // pending question carousel) instead of blocking forever on an answer + // nobody will consume. In the normal path we complete the call + // ourselves first, so `invokeTool` has already settled and this + // cancellation is a harmless no-op. if (cts.token.isCancellationRequested) { return; } cts.cancel(); - if (!invoked) { + if (!invoked && tc.status === ToolCallStatus.Cancelled) { // No `invokeTool` is listening to the CTS — transition // the invocation to `Cancelled` ourselves. invocation.cancelFromStreaming(ToolConfirmKind.Skipped); @@ -2364,6 +2584,13 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC parameters, context: { sessionResource: opts.sessionResource }, chatStreamToolCallId: toolCallId, + // If the agent host already resolved auto-approval for this call, + // pass it through so the invocation transitions straight to + // executing instead of briefly flashing a confirmation prompt + // (which would flicker "needs input" in the sessions list). + preApproved: shouldAutoApproveClientToolCall(tc) + ? { type: ToolConfirmKind.Setting, id: SessionConfigKey.AutoApprove } + : undefined, }; const noOpCountTokens = async () => 0; this._logService.info(`[AgentHost] Invoking client tool: ${toolName} (callId=${toolCallId})`); @@ -2940,6 +3167,8 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC sessionResource: URI, parentSession: URI, parentToolCallId: string, + rootInvocationId: string, + parentInvocation: ChatToolInvocation, emitProgress: (parts: IChatProgress[]) => void, disposables: DisposableStore, subagentContext: ISubagentContext, @@ -2951,6 +3180,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const cts = new CancellationTokenSource(); disposables.add(toDisposable(() => cts.dispose(true))); + disposables.add(toDisposable(() => { + if (parentInvocation.toolSpecificData?.kind === 'subagent' && parentInvocation.toolSpecificData.isActive) { + parentInvocation.toolSpecificData.isActive = false; + parentInvocation.notifyToolSpecificDataChanged(); + } + })); try { const childSub = this._ensureSessionSubscription(parentSessionUri); @@ -2966,6 +3201,17 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } return mergeSessionWithDefaultChat(session, childChatState$.read(reader)); }); + disposables.add(autorun(reader => { + const state = childState$.read(reader); + if (!state || (!state.activeTurn && state.turns.length === 0)) { + return; + } + const isActive = !!state.activeTurn; + if (parentInvocation.toolSpecificData?.kind === 'subagent' && parentInvocation.toolSpecificData.isActive !== isActive) { + parentInvocation.toolSpecificData.isActive = isActive; + parentInvocation.notifyToolSpecificDataChanged(); + } + })); const childTurnIds$ = derived(reader => { const state = childState$.read(reader); @@ -2991,7 +3237,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId, sink: emitProgress, cancellationToken: cts.token, - subAgentInvocationId: parentToolCallId, + subAgentInvocationId: rootInvocationId, subAgentCreditsAccumulator: perInvocationCreditsAccumulator, subAgentModelObservable: perInvocationModel, })); @@ -3017,6 +3263,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC turnId: string, chatSession: AgentHostChatSession, initialProgress: IChatProgress[], + initialResponsePartCount: number, ): void { const sessionKey = backendSession.toString(); const chatURI = this._getChatURI(chatSession.sessionResource); @@ -3055,6 +3302,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC cancellationToken: cts.token, adoptInvocations, seedEmittedLengths, + initialResponsePartCount, onTurnEnded: () => { chatSession.complete(); reconnectStore.dispose(); @@ -3222,7 +3470,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } /** Creates a new backend session and subscribes to its state. */ - private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, config?: Record<string, unknown>): Promise<URI> { + private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; turnIndex: number; turnId: string }, config?: Record<string, unknown>, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }): Promise<URI> { const workingDirectory = this._resolveRequestedWorkingDirectory(sessionResource); const requestedSession = fork ? undefined : this._resolveSessionUri(sessionResource); @@ -3243,6 +3491,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC const activeClient = this._getCurrentActiveClient(); + // Opt in to bring-up progress (chiefly the lazy first-use SDK download) + // so the editor window surfaces the same download notification the + // Agents window does. The host echoes the download's own identity on + // each frame; this token only records interest. + const progressToken = generateUuid(); + let session: URI; try { session = await this._config.connection.createSession({ @@ -3252,7 +3506,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC workingDirectory, fork, config, + importConversation, activeClient, + progressToken, }); } catch (err) { // If authentication is required (e.g. token expired), try interactive auth and retry once @@ -3267,7 +3523,9 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC workingDirectory, fork, config, + importConversation, activeClient, + progressToken, }); } else { throw new Error(localize('agentHost.authRequired', "Authentication is required to start a session. Please sign in and try again.")); @@ -3592,7 +3850,156 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } private _convertVariablesToAttachments(request: IChatAgentRequest): MessageAttachment[] { - return this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message); + const attachments = this._variableEntriesToAttachments(request.variables.variables, request.sessionResource, request.message); + const explicitCount = attachments.length; + this._appendActiveEditorAttachments(attachments, request); + if (attachments.length !== explicitCount) { + this._logService.trace(`[AgentHost] Forwarded ${attachments.length - explicitCount} active editor attachment(s); ${attachments.length} total`); + } + return attachments; + } + + /** + * Forward the active editor (which the suggested-context flow omits in agent mode) as ambient context, deduped + * against files the user attached explicitly. Gated on + * {@link ChatConfiguration.ImplicitContextActiveEditor} (on by default, off in the Agents window). + * Unsaved handling lives in {@link _convertVariableToAttachment}. + */ + private _appendActiveEditorAttachments(attachments: MessageAttachment[], request: IChatAgentRequest): void { + if (!this._configurationService.getValue<boolean>(ChatConfiguration.ImplicitContextActiveEditor)) { + return; + } + const implicitContext = this._chatWidgetService.getWidgetBySessionResource(request.sessionResource)?.input.implicitContext; + if (!implicitContext) { + return; + } + // Key on source entries (not produced attachments) so inlined unsaved buffers (no URI) still dedupe. + const existingKeys = new Set<string>(); + for (const v of request.variables.variables) { + const key = this._fileEntryDedupeKey(v, request.sessionResource); + if (key) { + existingKeys.add(key); + } + } + // Non-Copilot-CLI backends can't read an untitled buffer, so don't forward it as a broken path. + const skipUntitled = this._config.provider !== SessionType.CopilotCLI; + for (const entry of implicitContext.values) { + if (entry.value === undefined) { + continue; + } + if (skipUntitled && entry.uri?.scheme === Schemas.untitled) { + continue; + } + const key = this._fileEntryDedupeKey(entry, request.sessionResource); + if (key) { + if (existingKeys.has(key)) { + continue; + } + existingKeys.add(key); + } + const attachment = this._convertVariableToAttachment(entry, request.sessionResource, request.message); + if (!Array.isArray(attachment) && attachment) { + attachments.push(attachment); + } + } + } + + /** Dedupe identity for a file/implicit entry: rebased URI, suffixed with the range for a selection. */ + private _fileEntryDedupeKey(entry: IChatRequestVariableEntry, sessionResource: URI): string | undefined { + if (entry.kind !== 'file' && entry.kind !== 'implicit') { + return undefined; + } + const value = entry.value; + const uri = isLocation(value) ? value.uri : (value instanceof URI ? value : undefined); + if (!uri) { + return undefined; + } + const selection = this._entrySelection(entry); + return this._attachmentDedupeKey(this._rebaseAttachmentUri(uri, sessionResource).toString(), selection); + } + + /** The selection range carried by a file/implicit entry, or `undefined` for whole-document references. */ + private _entrySelection(entry: IChatRequestVariableEntry): MessageEmbeddedResourceAttachment['selection'] { + const location = this._entrySelectionLocation(entry); + return location ? { range: this._toTextRange(location.range) } : undefined; + } + + /** Dedupe identity: the bare URI for a whole document, suffixed with the range for a selection. */ + private _attachmentDedupeKey(uri: string, selection?: MessageResourceAttachment['selection']): string { + if (!selection) { + return uri; + } + const { start, end } = selection.range; + return `${uri}#${start.line}:${start.character}-${end.line}:${end.character}`; + } + + /** A resource is unsaved when it's untitled or a saved file with in-memory (dirty) changes. */ + private _isUnsavedResource(uri: URI): boolean { + return uri.scheme === Schemas.untitled || this._workingCopyService.isDirty(uri); + } + + /** + * Inline the live (in-memory) text of an unsaved editor as an embedded resource so a path-reading backend still + * gets current content, preserving the entry's selection, range and `_meta`. Selection entries inline only the + * selected text; whole-document entries inline the full buffer. Returns `undefined` when no loaded text model is + * available or the inlined text exceeds {@link MAX_INLINED_UNSAVED_EDITOR_BYTES}. + */ + private _buildUnsavedEditorAttachment(uri: URI, v: IChatRequestVariableEntry, range: MessageAttachment['range']): MessageAttachment | undefined { + const model = this._modelService.getModel(uri); + if (!model) { + return undefined; + } + const text = this._getUnsavedEditorAttachmentText(model, this._entryModelSelectionRange(v)); + const buffer = text === undefined ? undefined : VSBuffer.fromString(text); + if (!buffer || buffer.byteLength > MAX_INLINED_UNSAVED_EDITOR_BYTES) { + this._logService.trace(`[AgentHost] Skipping inline of unsaved editor ${uri.toString()}: exceeds ${MAX_INLINED_UNSAVED_EDITOR_BYTES} byte cap`); + return undefined; + } + const selection = this._entrySelection(v); + const attachment: MessageEmbeddedResourceAttachment = { + type: MessageAttachmentKind.EmbeddedResource, + label: v.name, + displayKind: selection ? 'selection' : 'document', + data: encodeBase64(buffer), + contentType: 'text/plain', + }; + if (selection) { + attachment.selection = selection; + } + if (range) { + attachment.range = range; + } + if (v._meta) { + attachment._meta = v._meta; + } + return attachment; + } + + /** + * The inline text to send for an unsaved editor: the selected text for a selection, else the whole buffer. Uses the + * model length APIs so an over-cap buffer is skipped (returns `undefined`) without ever being materialized. + */ + private _getUnsavedEditorAttachmentText(model: ITextModel, range: IRange | undefined): string | undefined { + if (range) { + const selection = model.validateRange(range); + const selectionLength = model.getValueLengthInRange(selection); + if (selectionLength > 0) { + return selectionLength > MAX_INLINED_UNSAVED_EDITOR_BYTES ? undefined : model.getValueInRange(selection); + } + } + return model.getValueLength() > MAX_INLINED_UNSAVED_EDITOR_BYTES ? undefined : model.getValue(); + } + + /** The editor range of a file/implicit selection entry, used to slice the live model; `undefined` otherwise. */ + private _entryModelSelectionRange(entry: IChatRequestVariableEntry): IRange | undefined { + return this._entrySelectionLocation(entry)?.range; + } + + /** The {@link Location} of a file/implicit entry that represents a selection, or `undefined` for whole documents. */ + private _entrySelectionLocation(entry: IChatRequestVariableEntry): Location | undefined { + const value = entry.value; + const isSelectionEntry = (entry.kind === 'file' || (entry.kind === 'implicit' && entry.isSelection)) && isLocation(value); + return isSelectionEntry ? value as Location : undefined; } private _variableEntriesToAttachments(variables: readonly IChatRequestVariableEntry[], sessionResource: URI, messageText?: string): MessageAttachment[] { @@ -3606,20 +4013,33 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } if (attachments.length > 0) { - this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${variables.length} variables`); + this._logService.trace(`[AgentHost] Converted ${attachments.length} attachments from ${variables.length} explicit variables`); } return attachments; } private _convertVariableToAttachment(v: IChatRequestVariableEntry, sessionResource: URI, messageText?: string): MessageAttachment | MessageAttachment[] | undefined { const referenceRange = this._toAttachmentReferenceRange(messageText, v.range); - // File / implicit attachments: a Location → selection, a URI → resource. - // Only the selection variant of an implicit attachment becomes a - // `selection`; the bare visible-document case stays a plain file - // reference (or, when there's no value at all, gets dropped). + // Copilot CLI can't read unsaved content from disk, so inline the live buffer; drop unreadable schemes. + if ((v.kind === 'file' || v.kind === 'implicit') && this._config.provider === SessionType.CopilotCLI) { + const uri = isLocation(v.value) ? v.value.uri : (v.value instanceof URI ? v.value : undefined); + if (uri && this._isUnsavedResource(uri)) { + const embedded = this._buildUnsavedEditorAttachment(uri, v, referenceRange); + if (embedded) { + return embedded; + } + if (uri.scheme !== Schemas.file) { + return undefined; + } + } + } + // File/implicit: a selection Location → 'selection'; a whole document/URI → 'document' (range dropped). if ((v.kind === 'file' || (v.kind === 'implicit' && v.isSelection)) && isLocation(v.value)) { return this._toSelectionAttachment(v.value, v.name, 'selection', sessionResource, v._meta, referenceRange); } + if (v.kind === 'implicit' && isLocation(v.value)) { + return this._toResourceAttachment(v.value.uri, v.name, 'document', sessionResource, v._meta, referenceRange); + } if ((v.kind === 'file' || v.kind === 'implicit') && v.value instanceof URI) { return this._toResourceAttachment(v.value, v.name, 'document', sessionResource, v._meta, referenceRange); } @@ -3642,6 +4062,28 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC if (isAgentFeedbackVariableEntry(v)) { return this._toAgentFeedbackAttachment(v); } + if (v.kind === 'sessionReference' && v.value instanceof URI) { + const trajectoryPath = this._toSessionReferenceTrajectoryPath(v.value); + if (!trajectoryPath) { + return undefined; + } + return this._toSessionReferenceAttachments(v, v.value, trajectoryPath, referenceRange); + } + // Browser views are live pages rather than filesystem resources. Preserve + // the page ID as model-readable context so the agent can address the page + // with browser tools without trying to read the vscode-browser URI. + if (isBrowserViewVariableEntry(v)) { + return this._toSimpleAttachment( + v.name, + v.modelDescription ?? `Browser page: ${v.name}. The pageId is "${v.browserId}".`, + { + ...v._meta, + [BrowserViewAttachmentMetadataKey]: { browserId: v.browserId, browserUri: v.value.toString() }, + }, + BrowserViewAttachmentDisplayKind, + referenceRange, + ); + } // Pasted code, prompt text, workspace context, and free-form string entries: surface their // textual representation as an opaque attachment. if (v.kind === 'paste') { @@ -3651,7 +4093,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); } if (v.kind === 'workspace') { - return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); + return this._toSimpleAttachment(v.name, v.value, v._meta, 'workspace', referenceRange); } if (v.kind === 'string' && typeof v.value === 'string') { return this._toSimpleAttachment(v.name, v.value, v._meta, undefined, referenceRange); @@ -3666,6 +4108,43 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } + private _toSessionReferenceAttachment(v: IChatRequestVariableEntry, sessionResource: URI, trajectoryPath: string, range?: MessageAttachment['range']): MessageAttachment { + return this._toSimpleAttachment( + v.name, + toSessionReferenceModelRepresentation(v.name, sessionResource, trajectoryPath), + { ...(v._meta ?? {}), ...toSessionReferenceAttachmentMeta(sessionResource) }, + AgentHostSessionReferenceAttachmentDisplayKind, + range + ); + } + + private _toSessionReferenceAttachments(v: IChatRequestVariableEntry, sessionResource: URI, trajectoryPath: string, range?: MessageAttachment['range']): MessageAttachment[] { + return [ + this._toSessionReferenceAttachment(v, sessionResource, trajectoryPath, range), + this._toSessionReferenceTrajectoryAttachment(v, sessionResource, trajectoryPath), + ]; + } + + private _toSessionReferenceTrajectoryAttachment(v: IChatRequestVariableEntry, sessionResource: URI, trajectoryPath: string): MessageAttachment { + return { + type: MessageAttachmentKind.Resource, + uri: URI.file(trajectoryPath).toString(), + label: `${v.name} trajectory`, + displayKind: AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, + _meta: { ...(v._meta ?? {}), ...toSessionReferenceAttachmentMeta(sessionResource) }, + }; + } + + private _toSessionReferenceTrajectoryPath(sessionResource: URI): string | undefined { + // TODO: Support non-Copilot-CLI session references through IChatModel or a first-class AHP attachment path. + // TODO: Support full EH-to-AH session porting for continue/resume flows. + return buildHostLocalEventsPath( + sessionResource, + this._pathService.userHome({ preferLocal: true }), + authority => this._remoteAgentHostService.connections.find(connection => agentHostAuthority(connection.address) === authority), + ); + } + private _toResourceAttachment(uri: URI, label: string, displayKind: string, sessionResource: URI, _meta: Record<string, unknown> | undefined, range?: MessageAttachment['range']): MessageAttachment | undefined { const attachmentUri = this._rebaseAttachmentUri(uri, sessionResource); const attachment: MessageAttachment = { type: MessageAttachmentKind.Resource, uri: attachmentUri.toString(), label, displayKind }; @@ -4068,6 +4547,10 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC : this._ensureAdditionalChatSubscription(chatUri); } + resolveChatResponseUri(_sessionResource: URI, href: string, _kind: 'link' | 'image'): string { + return rewriteAgentHostLinkTarget(href, this._config.connectionAuthority); + } + /** * Read the current root state. */ diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts index eba35d5f14b0a0..51bcb5bb8136cc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListContribution.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { Disposable, DisposableMap, DisposableStore } from '../../../../../../base/common/lifecycle.js'; -import { AgentHostEnabledSettingId, claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { claudePreferAgentHostSettingId, IAgentHostService, shouldSurfaceLocalAgentHostProvider, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { type AgentInfo, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -30,12 +31,13 @@ export class AgentHostSessionListContribution extends Disposable implements IWor @IInstantiationService private readonly _instantiationService: IInstantiationService, @IWorkbenchEnvironmentService environmentService: IWorkbenchEnvironmentService, @IAgentHostSessionWorkingDirectoryResolver private readonly _workingDirectoryResolver: IAgentHostSessionWorkingDirectoryResolver, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, ) { super(); this._isSessionsWindow = environmentService.isSessionsWindow; - if (this._isSessionsWindow || !this._configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { + if (this._isSessionsWindow || !agentHostEnablementService.enabled) { return; } 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 bae1c619eae9ac..3a2b790ea17858 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -15,6 +15,7 @@ import { IWorkspaceContextService } from '../../../../../../platform/workspace/c import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; +import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; import { AgentHostSessionListStore, type IAgentHostSessionListDelta } from './agentHostSessionListStore.js'; @@ -50,6 +51,7 @@ export class AgentHostSessionListController extends Disposable implements IChatS @IAgentHostUntitledProvisionalSessionService private readonly _provisional: IAgentHostUntitledProvisionalSessionService, @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, + @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, ) { super(); void _connectionAuthority; @@ -110,6 +112,10 @@ export class AgentHostSessionListController extends Disposable implements IChatS if (workingDirectory) { this._newSessionFolderService.setFolder(item.resource, workingDirectory); } + // Carry any imported ("Continue in…") conversation snapshot from the + // untitled chat-input resource to the freshly-minted real resource so + // the provisional `getOrCreate` for the real resource seeds it. + this._importConversationStore.rename(request.untitledResource, item.resource); await this._provisional.tryRebind(request.untitledResource, item.resource, this._provider, workingDirectory); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.ts new file mode 100644 index 00000000000000..a7c89454b4d6ed --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.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. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../../base/common/uri.js'; +import { type SimpleMessageAttachment } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { type IChatRequestSessionReferenceVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { chatSessionResourceToId } from '../../../common/model/chatUri.js'; + +export const AgentHostSessionReferenceAttachmentDisplayKind = 'sessionReference'; +export const AgentHostSessionReferenceTrajectoryAttachmentDisplayKind = 'sessionReferenceTrajectory'; +export const AgentHostSessionReferenceAttachmentMetadataKey = 'vscode.agentHost.sessionReference'; + +interface IAgentHostSessionReferenceAttachmentMetadata { + readonly sessionResource: string; + readonly sessionID: string; +} + +export function toSessionReferenceModelRepresentation(label: string, sessionResource: URI, trajectoryPath?: string): string { + const sessionID = chatSessionResourceToId(sessionResource); + const lines = [ + `Attached chat session: ${label}`, + `Session ID: ${sessionID}`, + `Session resource: ${sessionResource.toString()}`, + ]; + if (trajectoryPath) { + lines.push(`Session events file attached: ${trajectoryPath}`); + } + return lines.join('\n'); +} + +export function toSessionReferenceAttachmentMeta(sessionResource: URI): NonNullable<SimpleMessageAttachment['_meta']> { + return { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionResource.toString(), + sessionID: chatSessionResourceToId(sessionResource), + } satisfies IAgentHostSessionReferenceAttachmentMetadata, + }; +} + +export function restoreSessionReferenceVariableEntryFromAttachment(attachment: SimpleMessageAttachment): IChatRequestSessionReferenceVariableEntry | undefined { + if (attachment.displayKind !== AgentHostSessionReferenceAttachmentDisplayKind) { + return undefined; + } + + const metadata = getSessionReferenceAttachmentMetadata(attachment); + if (!metadata) { + return undefined; + } + + try { + const sessionResource = URI.parse(metadata.sessionResource); + return { + kind: 'sessionReference', + id: sessionResource.toString(), + name: attachment.label, + value: sessionResource, + _meta: attachment._meta, + }; + } catch { + return undefined; + } +} + +function getSessionReferenceAttachmentMetadata(attachment: SimpleMessageAttachment): IAgentHostSessionReferenceAttachmentMetadata | undefined { + const meta = attachment._meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return undefined; + } + const metadata = meta[AgentHostSessionReferenceAttachmentMetadataKey]; + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) { + return undefined; + } + const typedMetadata = metadata as Partial<IAgentHostSessionReferenceAttachmentMetadata>; + const sessionResource = typedMetadata.sessionResource; + if (typeof sessionResource !== 'string') { + return undefined; + } + const sessionID = typedMetadata.sessionID; + if (typeof sessionID !== 'string') { + return undefined; + } + return { + sessionResource, + sessionID, + }; +} + +export function isSessionReferenceTrajectoryAttachment(attachment: { readonly displayKind?: string }): boolean { + return attachment.displayKind === AgentHostSessionReferenceTrajectoryAttachmentDisplayKind; +} 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 19da2678097c6b..a830bd1fd876f3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostTerminalContribution.ts @@ -6,15 +6,17 @@ import { OS } from '../../../../../../base/common/platform.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../../nls.js'; -import { AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; -import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; -import { ROOT_STATE_URI } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentHostCustomTerminalToolEnabledSettingId, CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { TerminalSettingId } from '../../../../../../platform/terminal/common/terminal.js'; import { IWorkbenchContribution } from '../../../../../../workbench/common/contributions.js'; import { ITerminalProfileResolverService, ITerminalProfileService } from '../../../../../../workbench/contrib/terminal/common/terminal.js'; import { IAgentHostTerminalService } from '../../../../../../workbench/contrib/terminal/browser/agentHostTerminalService.js'; +import { AgentHostRootConfigForwarder, type IForwardedRootConfigKey } from './agentHostRootConfigForwarder.js'; /** Terminal settings whose change should re-resolve the agent host shell. */ const AGENT_HOST_SHELL_DEPENDENT_SETTINGS = [ @@ -29,34 +31,13 @@ const AGENT_HOST_SHELL_DEPENDENT_SETTINGS = [ TerminalSettingId.ProfilesWindows, ]; -/** - * A single agent-host root-config key managed by this contribution. - * - * The shared machinery in {@link AgentHostTerminalContribution} handles the - * schema gate, the value-equality guard, the dispatch, and the - * hydration-retry. A descriptor only has to say *what* its value is and *when* - * to recompute it - adding a new managed key is one entry, no copy/paste. - */ -interface IManagedRootConfigKey { - /** The root-config key this descriptor owns. */ - readonly key: AgentHostConfigKey; - - /** - * Compute the desired value for {@link key}. Return `undefined` to skip the - * push (e.g. the value can't be resolved yet). May be async. - */ - computeValue(): unknown | Promise<unknown>; - - /** - * Wire up the events / settings whose change should re-push this key. - * Disposables go in `store`; call `push` to trigger a re-push. - */ - registerTriggers(store: DisposableStore, push: () => void): void; -} - /** * Registers local agent host terminal entries with - * {@link IAgentHostTerminalService} so they appear in the terminal dropdown. + * {@link IAgentHostTerminalService} so they appear in the terminal dropdown, + * and forwards the terminal-related agent-host root-config keys (the resolved + * default shell and the custom-terminal-tool toggle) via the shared + * {@link AgentHostRootConfigForwarder} (also used by + * `AgentHostCopilotCliSettingsContribution`). * * Gated on the `chat.agentHost.enabled` setting. */ @@ -65,16 +46,7 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe private readonly _localEntry = this._register(new MutableDisposable()); private readonly _conditionalListeners = this._register(new MutableDisposable<DisposableStore>()); - - /** Declarative table of the root-config keys we manage. */ - private readonly _managedKeys: readonly IManagedRootConfigKey[]; - - /** - * Managed keys whose schema the host has already advertised. Used to re-push - * only when a key's schema *first* appears (hydration) rather than on every - * root-state change - see {@link _onRootStateChanged}. - */ - private readonly _schemaSeen = new Set<AgentHostConfigKey>(); + private readonly _forwarder: AgentHostRootConfigForwarder; constructor( @IAgentHostService private readonly _agentHostService: IAgentHostService, @@ -82,10 +54,12 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe @IConfigurationService private readonly _configurationService: IConfigurationService, @ITerminalProfileService private readonly _terminalProfileService: ITerminalProfileService, @ITerminalProfileResolverService private readonly _terminalProfileResolverService: ITerminalProfileResolverService, + @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, + @IAgentHostEnablementService private readonly _agentHostEnablementService: IAgentHostEnablementService, ) { super(); - this._managedKeys = [ + const keys: readonly IForwardedRootConfigKey[] = [ { key: AgentHostConfigKey.DefaultShell, computeValue: () => this._resolveDefaultShell(), @@ -99,7 +73,7 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe }, }, { - key: AgentHostConfigKey.EnableCustomTerminalTool, + key: CopilotCliConfigKey.EnableCustomTerminalTool, computeValue: () => this._configurationService.getValue<boolean>(AgentHostCustomTerminalToolEnabledSettingId) === true, registerTriggers: (store, push) => { store.add(this._configurationService.onDidChangeConfiguration(e => { @@ -109,56 +83,49 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe })); }, }, + { + // Mirror the connected GitHub Enterprise host to the agent host so its + // GitHub resources / CAPI calls target the enterprise instance. Sourced + // from the account service — the authoritative "am I signed in to GHE" + // state — rather than reading the setting directly. Push `''` (not + // `undefined`) for github.com: the push pipeline skips `undefined`, + // which would strand a stale host on the agent host. + key: AgentHostConfigKey.GithubEnterpriseUri, + computeValue: () => { + const provider = this._defaultAccountService.getDefaultAccountAuthenticationProvider(); + // `resolveGitHubUrl('')` yields the GitHub Enterprise base (e.g. + // `https://acme.ghe.com/`) when signed in via a GHE provider. + return provider.enterprise ? this._defaultAccountService.resolveGitHubUrl('').replace(/\/+$/, '') : ''; + }, + registerTriggers: (store, push) => { + store.add(this._defaultAccountService.onDidChangeDefaultAccount(() => push())); + }, + }, ]; - - this._register(this._configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AgentHostEnabledSettingId)) { - this._updateEnabled(); - } - })); + this._forwarder = this._register(new AgentHostRootConfigForwarder(keys, this._agentHostService)); this._updateEnabled(); } private _updateEnabled(): void { - if (this._configurationService.getValue<boolean>(AgentHostEnabledSettingId)) { + if (this._agentHostEnablementService.enabled) { if (!this._conditionalListeners.value) { const store = new DisposableStore(); - store.add(this._agentHostService.onAgentHostStart(() => this._reconcile())); - for (const entry of this._managedKeys) { - entry.registerTriggers(store, () => this._push(entry)); - } - // Re-push only when the host's root-config *schema* first - // advertises a key we manage. The initial push from - // `_reconcile()` may race an undefined `rootState.value` (schema - // not yet hydrated); this retries once the schema arrives. - // - // We deliberately do NOT re-push on every root-state change: the - // local agent host's root config is shared across windows, so - // reacting to *value* changes pushed by another window would - // start an infinite update war - each window forcing its own - // value back over the other's. See #314385 follow-up. - store.add(this._agentHostService.rootState.onDidChange(() => this._onRootStateChanged())); - // Seed the schema-seen set from the current state so the - // immediate `_reconcile()` below counts as the initial push for - // whatever keys are already advertised. - this._schemaSeen.clear(); - for (const entry of this._managedKeys) { - if (this._schemaHasKey(entry.key)) { - this._schemaSeen.add(entry.key); - } - } + // The forwarder registers its own agent-host-start listener to re-push + // keys; this one keeps the terminal dropdown entry alive across restarts. + store.add(this._agentHostService.onAgentHostStart(() => this._registerLocalEntry())); this._conditionalListeners.value = store; - this._reconcile(); + this._registerLocalEntry(); + this._forwarder.start(); } } else { - this._schemaSeen.clear(); this._conditionalListeners.value = undefined; this._localEntry.value = undefined; + this._forwarder.stop(); } } - private _reconcile(): void { + private _registerLocalEntry(): void { if (!this._localEntry.value) { this._localEntry.value = this._agentHostTerminalService.registerEntry({ name: localize('agentHostTerminal.local', "Local"), @@ -166,91 +133,6 @@ export class AgentHostTerminalContribution extends Disposable implements IWorkbe getConnection: () => this._agentHostService, }); } - for (const entry of this._managedKeys) { - this._push(entry); - } - } - - /** - * Push managed values only for keys whose schema has just transitioned from - * absent to present (host root-config hydration). Value-only changes - e.g. - * another window writing a different value into the shared root config - are - * intentionally ignored so multiple windows don't fight in an infinite loop. - */ - private _onRootStateChanged(): void { - for (const entry of this._managedKeys) { - if (this._schemaHasKey(entry.key)) { - if (!this._schemaSeen.has(entry.key)) { - this._schemaSeen.add(entry.key); - this._push(entry); - } - } else { - this._schemaSeen.delete(entry.key); - } - } - } - - private _schemaHasKey(key: AgentHostConfigKey): boolean { - const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error) { - return false; - } - return !!rootState.config?.schema.properties[key]; - } - - /** - * Shared push pipeline for a managed root-config key: - * - * 1. No-op if the host's root-config schema doesn't advertise the key - - * protects older / third-party agent hosts from receiving keys they - * don't understand. The push is retried automatically when `rootState` - * hydrates (see {@link _onRootStateChanged}). - * 2. Compute the desired value (may be async); `undefined` skips the push. - * 3. Skip if the host already holds that value (avoids redundant dispatches - * and, critically, breaks cross-window update loops - see #314385). - * - * Local agent host only. Remote agent hosts (via - * `IRemoteAgentHostService.connections`) are intentionally not fanned out - * to: e.g. the resolved shell path is local-machine-shaped (a Windows path) - * and not necessarily valid on the remote machine. Remote operators should - * configure such values server-side via the remote's - * `agent-host-config.json`. See - * https://github.com/microsoft/vscode/issues/313160 follow-ups. - */ - private async _push(entry: IManagedRootConfigKey): Promise<void> { - if (!this._schemaHasKey(entry.key)) { - return; - } - - let value: unknown; - try { - value = await entry.computeValue(); - } catch { - return; - } - if (value === undefined) { - return; - } - - // Re-check after the await: a host restart / schema refresh may have - // landed while we resolved. Re-run the schema gate (not just a config - // existence check) so we never dispatch a key the *current* schema no - // longer advertises - protects older / 3rd-party hosts. - if (!this._schemaHasKey(entry.key)) { - return; - } - const rootState = this._agentHostService.rootState.value; - if (!rootState || rootState instanceof Error || !rootState.config) { - return; - } - if (rootState.config.values[entry.key] === value) { - return; - } - - this._agentHostService.dispatch(ROOT_STATE_URI, { - type: ActionType.RootConfigChanged, - config: { [entry.key]: value }, - }); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index c95d5d0bbeea16..a787d6f20fa4df 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -54,6 +54,7 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { equals } from '../../../../../../base/common/objects.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; import { KNOWN_AUTO_APPROVE_VALUES, KNOWN_MODE_VALUES, SessionConfigKey } from '../../../../../../platform/agentHost/common/sessionConfigKeys.js'; import { migrateLegacyAutopilotConfig } from '../../../../../../platform/agentHost/common/agentHostSchema.js'; @@ -68,6 +69,7 @@ import { IWorkbenchEnvironmentService } from '../../../../../services/environmen import { ChatConfiguration, type IChatDefaultConfiguration } from '../../../common/constants.js'; import { IChatService } from '../../../common/chatService/chatService.js'; import { IAgentHostNewSessionFolderService } from './agentHostNewSessionFolderService.js'; +import { IAgentHostImportConversationStore } from './agentHostImportConversationStore.js'; export const IAgentHostUntitledProvisionalSessionService = createDecorator<IAgentHostUntitledProvisionalSessionService>('agentHostUntitledProvisionalSessionService'); @@ -220,6 +222,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, + @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, ) { super(); @@ -306,6 +309,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple session: backendSession, workingDirectory, config: initialConfig, + progressToken: generateUuid(), }); this._entries.set(sessionResource, { backendSession: created, config: { ...(initialConfig ?? {}) }, workingDirectory }); this._onDidChange.fire(sessionResource); @@ -369,6 +373,14 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple const config = oldEntry.config; const newBackendSession = this._toBackendUri(newSessionResource, provider); + // If a conversation was imported ("Continue in…") into this session, seed + // it as real editable history on the rebound (real) session. The stash was + // moved from the untitled resource to `newSessionResource` at graduation. + // Carry the source session's model so the imported session resumes on the + // same model instead of the host default (the normal per-turn model path + // is skipped for imports, which materialize eagerly at create time). + const imported = this._importConversationStore.take(newSessionResource); + let created: URI; try { created = await this._agentHostService.createSession({ @@ -376,6 +388,8 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple session: newBackendSession, workingDirectory, config, + ...(imported ? { model: imported.model, importConversation: { turns: imported.turns, model: imported.model } } : {}), + progressToken: generateUuid(), }); } catch (err) { this._logService.warn(`[AgentHostProvisional] Failed to create rebound provisional: ${err instanceof Error ? err.message : String(err)}`); @@ -438,6 +452,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple session: entry.backendSession, workingDirectory: newWorkingDirectory, config, + progressToken: generateUuid(), }); } catch (err) { this._logService.warn(`[AgentHostProvisional] Failed to recreate provisional at new cwd: ${err instanceof Error ? err.message : String(err)}`); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts new file mode 100644 index 00000000000000..7e146e2d9310cd --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/importLocalConversationToAgentSession.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { basename } from '../../../../../../base/common/resources.js'; +import { localize } from '../../../../../../nls.js'; +import { MessageKind, ResponsePartKind, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, type ErrorInfo, type ResponsePart, type ToolCallCompletedState, type ToolResultContent, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatToolInvocation, type IChatContentInlineReference, type IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import type { IChatModel, IChatRequestModel } from '../../../common/model/chatModel.js'; +import { isToolResultInputOutputDetails } from '../../../common/tools/languageModelToolsService.js'; + +/** Renders a chat message (plain string or markdown) as plain text. */ +function stringifyChatMessage(message: string | IMarkdownString | undefined): string { + if (message === undefined) { + return ''; + } + return typeof message === 'string' ? message : message.value; +} + +/** Serializes a tool's raw input to a JSON string, tolerating non-serializable values. */ +function stringifyToolInput(rawInput: unknown): string { + if (typeof rawInput === 'string') { + return rawInput; + } + try { + return JSON.stringify(rawInput) ?? ''; + } catch { + return ''; + } +} + +/** + * Renders an inline reference (a file/symbol chip interleaved with the response + * text) as a markdown link so it survives migration instead of leaving a gap + * where the chip used to be. Falls back to plain label text when no URI is + * available. + */ +function inlineReferenceToMarkdown(reference: IChatContentInlineReference['inlineReference'], name: string | undefined): string { + let uri: URI | undefined; + let label = name; + if (URI.isUri(reference)) { + uri = reference; + } else { + // `Location` carries the URI directly; `IWorkspaceSymbol` nests it under + // `location` and supplies its own display name. + const location = reference as { uri?: URI; location?: { uri?: URI } }; + if (URI.isUri(location.uri)) { + uri = location.uri; + } else if (URI.isUri(location.location?.uri)) { + uri = location.location.uri; + label = label ?? (reference as { name?: string }).name; + } + } + if (!uri) { + return label ?? ''; + } + return `[${label ?? basename(uri)}](${uri.toString()})`; +} + +/** + * Maps a local chat tool invocation (live or serialized) to a completed + * agent-host tool call, carrying its name, invocation messages, raw input and + * textual result output. Failure is inferred from the result's `isError` flag. + * + * Sub-agent invocations (`toolSpecificData.kind === 'subagent'`) keep their + * summary inline: the delegated prompt seeds the tool input and the sub-agent's + * result text seeds the output when the generic result details carry neither. + * The sub-agent's own turn-by-turn transcript lives in a separate worker chat + * that has no backend counterpart after import, so only the summary is carried. + */ +function toolCallResponsePart(part: IChatToolInvocation | IChatToolInvocationSerialized): ResponsePart { + const invocationMessage = stringifyChatMessage(part.invocationMessage); + const resultDetails = IChatToolInvocation.resultDetails(part); + const subagentData = part.toolSpecificData?.kind === 'subagent' ? part.toolSpecificData : undefined; + + let outputText = ''; + let isError = false; + let resultInput: string | undefined; + if (resultDetails && isToolResultInputOutputDetails(resultDetails)) { + if (Array.isArray(resultDetails.output)) { + for (const item of resultDetails.output) { + if (item.type === 'embed' && item.isText) { + outputText += item.value; + } + } + } + isError = !!resultDetails.isError; + resultInput = resultDetails.input; + } + // Fall back to the sub-agent summary when the generic result details are empty. + if (!outputText && subagentData?.result) { + outputText = subagentData.result; + } + + const toolInput = part.toolSpecificData?.kind === 'input' + ? stringifyToolInput(part.toolSpecificData.rawInput) + : (resultInput ?? subagentData?.prompt ?? ''); + const toolCallId = part.toolCallId || generateUuid(); + const content: ToolResultContent[] = []; + if (outputText) { + content.push({ type: ToolResultContentType.Text, text: outputText }); + } + if (subagentData) { + // Preserve the sub-agent identity as structured content so it renders as a + // sub-agent tool call (matching native sessions) and survives the events + // round-trip — `buildSessionEventsFromTurns` emits a matching + // `subagent.started` so a reload reconstructs the same name/description. + content.push({ + type: ToolResultContentType.Subagent, + resource: subagentData.chatResource ?? `agent-host-subagent:/${toolCallId}`, + title: subagentData.agentName ?? localize('chat.importConversation.subagent', "Subagent"), + ...(subagentData.agentName ? { agentName: subagentData.agentName } : {}), + ...(subagentData.description ? { description: subagentData.description } : {}), + }); + } + const displayName = subagentData?.agentName || part.toolId; + + return { + kind: ResponsePartKind.ToolCall, + toolCall: { + status: ToolCallStatus.Completed, + toolCallId, + toolName: part.toolId, + displayName, + invocationMessage: invocationMessage || stringifyChatMessage(subagentData?.description), + toolInput, + success: !isError, + pastTenseMessage: stringifyChatMessage(part.pastTenseMessage) || invocationMessage, + confirmed: ToolCallConfirmationReason.NotNeeded, + ...(content.length ? { content } : {}), + ...(isError ? { error: { message: outputText || localize('chat.importConversation.toolFailed', "Tool failed.") } } : {}), + } satisfies ToolCallCompletedState, + }; +} + +/** + * Collects a request's response stream into agent-host {@link ResponsePart}s: + * markdown parts become {@link ResponsePartKind.Markdown}, reasoning parts + * become {@link ResponsePartKind.Reasoning}, and tool invocations become + * completed {@link ResponsePartKind.ToolCall} parts, all in stream order. Other + * progress kinds are skipped. + */ +function responsePartsFromRequest(request: IChatRequestModel): ResponsePart[] { + const responseParts: ResponsePart[] = []; + const response = request.response; + if (response) { + for (const part of response.entireResponse.value) { + if (part.kind === 'markdownContent') { + const content = part.content.value; + if (content) { + responseParts.push({ kind: ResponsePartKind.Markdown, id: generateUuid(), content }); + } + } else if (part.kind === 'thinking') { + const content = Array.isArray(part.value) ? part.value.join('') : (part.value ?? ''); + if (content) { + responseParts.push({ kind: ResponsePartKind.Reasoning, id: generateUuid(), content }); + } + } else if (part.kind === 'inlineReference') { + // A file/symbol chip interleaved with the response text; render it + // as a markdown link so it survives instead of leaving a gap. + const content = inlineReferenceToMarkdown(part.inlineReference, part.name); + if (content) { + responseParts.push({ kind: ResponsePartKind.Markdown, id: generateUuid(), content }); + } + } else if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { + responseParts.push(toolCallResponsePart(part)); + } + // Other progress kinds are not yet imported. + } + } + return responseParts; +} + +/** + * Derives how a turn ended from its source response: a cancelled response maps + * to {@link TurnState.Cancelled}, a response carrying error details maps to + * {@link TurnState.Error} (with the message/code surfaced as {@link ErrorInfo}), + * and anything else is {@link TurnState.Complete}. + */ +function turnOutcomeFromRequest(request: IChatRequestModel): { state: TurnState; error?: ErrorInfo } { + const response = request.response; + if (!response) { + return { state: TurnState.Complete }; + } + if (response.isCanceled) { + return { state: TurnState.Cancelled }; + } + const errorDetails = response.result?.errorDetails; + if (errorDetails) { + return { + state: TurnState.Error, + error: { errorType: errorDetails.code ?? 'error', message: errorDetails.message }, + }; + } + return { state: TurnState.Complete }; +} + +/** + * Translates a local {@link IChatModel} into agent-host {@link Turn}s suitable + * for {@link IAgentHostService.createSession}'s `importConversation` option. + * + * Each user request becomes a user turn whose response stream is mapped by + * {@link responsePartsFromRequest} and whose end state is mapped by + * {@link turnOutcomeFromRequest}. System-initiated requests (e.g. background + * terminal-completion notifications that were auto-sent back to the agent) are + * not real user turns, so their synthetic message is dropped and their response + * (and end state) is folded into the preceding turn as a continuation. + */ +export function importedTurnsFromChatModel(model: IChatModel): Turn[] { + const turns: Turn[] = []; + for (const request of model.getRequests()) { + const responseParts = responsePartsFromRequest(request); + const outcome = turnOutcomeFromRequest(request); + if (request.isSystemInitiated) { + // Not a genuine user message; append its output to the previous + // turn so the agent's continued work is preserved without surfacing + // the injected notification as an editable user turn. The + // continuation is what actually ended the exchange, so its outcome + // supersedes the previous turn's. + const previous = turns[turns.length - 1]; + if (previous) { + previous.responseParts.push(...responseParts); + previous.state = outcome.state; + previous.error = outcome.error; + } + continue; + } + turns.push({ + id: generateUuid(), + message: { text: request.message.text, origin: { kind: MessageKind.User } }, + responseParts, + usage: undefined, + state: outcome.state, + ...(outcome.error ? { error: outcome.error } : {}), + } satisfies Turn); + } + return turns; +} + diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css index 677b136aa27125..a77cc3d3283150 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/media/agentHostChatInputPicker.css @@ -35,9 +35,8 @@ .agent-host-chat-input-picker-slot span.action-label { display: inline-flex; align-items: center; - gap: 4px; height: 16px; - padding: 3px 6px; + padding: 3px 8px; border-radius: 4px; color: var(--vscode-icon-foreground); } @@ -67,7 +66,7 @@ .chat-secondary-generic-chips { display: inline-flex; align-items: center; - gap: 12px; + gap: 2px; &:empty { display: none; @@ -89,9 +88,6 @@ } } - > .agent-host-generic-chip-slot + .agent-host-generic-chip-slot { - box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); - } } /* Collapse agent host picker labels to icon-only when the secondary toolbar gets narrow. */ @@ -99,7 +95,7 @@ container-type: inline-size; } -@container (max-width: 400px) { +@container (max-width: 350px) { .agent-host-chat-input-picker-label { display: none; } @@ -140,4 +136,5 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + margin-left: 6px; } 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 bc150f3d417e07..ec4b8f310ee7cc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -5,19 +5,24 @@ import { decodeBase64 } from '../../../../../../base/common/buffer.js'; import { escapeMarkdownLinkLabel, IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { escapeIcons } from '../../../../../../base/common/iconLabels.js'; import { marked, type Token, type Tokens, type TokensList } from '../../../../../../base/common/marked/marked.js'; +import { Schemas } from '../../../../../../base/common/network.js'; +import { posix, win32 } from '../../../../../../base/common/path.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { MessageKind, ToolCallContributorKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type Message, type ToolCallState, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { buildSubagentChatUri, MessageKind, ToolCallContributorKind, ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, getToolSubagentContent, readUsageInfoMeta, type ActiveTurn, type ICompletedToolCall, type Message, type ToolCallState, type ToolResultSubagentContent, type Turn, FileEditKind, ToolResultContentType, type ToolResultContent, type UsageInfo, type UsageInfoMeta } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { readToolCallMeta } from '../../../../../../platform/agentHost/common/meta/agentToolCallMeta.js'; import { getChatErrorDetailsFromMeta, IChatErrorContext } from '../../../common/chatErrorMessages.js'; import { AGENT_HOST_SCHEME, toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { getAgentFeedbackAttachmentMetadata, isAgentFeedbackAnnotationsAttachment, isAgentFeedbackAttachment } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; -import { isViewUnreviewedCommentsTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; +import { getBrowserViewAttachmentMetadata, isBrowserViewAttachment } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; +import { isViewUnreviewedCommentsTool, isAddCommentTool } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAnnotations.js'; import { MessageAttachmentKind, type FileEdit, type MessageAttachment, type StringOrMarkdown, type TextRange } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { normalizeFileEdit } from '../../../../../../platform/agentHost/common/fileEditDiff.js'; -import { formatCopilotCredits, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import product from '../../../../../../platform/product/common/product.js'; +import { formatCopilotCredits, type ChatExternalEditKind, type ChatMcpAppData, type IChatAgentFeedbackReviewConfirmationData, type IChatExternalEdit, type IChatModifiedFilesConfirmationData, type IChatProgress, type IChatResponseErrorDetails, type IChatSearchToolInvocationData, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, type IChatUsage, type IChatUsagePromptTokenDetail, ToolConfirmKind, AgentFeedbackReviewCommandId } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { type IQuotaSnapshot } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; @@ -29,6 +34,7 @@ import { basename } from '../../../../../../base/common/resources.js'; import { hasKey, type Mutable } from '../../../../../../base/common/types.js'; import { localize } from '../../../../../../nls.js'; import type { IRange } from '../../../../../../editor/common/core/range.js'; +import { isSessionReferenceTrajectoryAttachment, restoreSessionReferenceVariableEntryFromAttachment } from './agentHostSessionReferenceAttachment.js'; /** * Constructs a terminal tool session ID from a terminal URI and backend session. @@ -70,6 +76,21 @@ function getSubagentAgentName(tc: ToolCallState): string | undefined { return v && v.length > 0 ? v : undefined; } +/** + * The subagent chat resource for a subagent-spawning tool call: the discovery + * content block's resource when present, else the deterministic child chat URI + * derived from the session + tool call id (matching the host's + * {@link buildSubagentChatUri}, and how {@link _observeSubagentSession} + * subscribes). Deriving it from the tool call id alone keeps the inline subagent + * pill linkable even when the discovery content block never reaches this chat — + * e.g. a background subagent whose `subagent_started` arrives after its spawning + * tool call has already completed, so the running-only content update is dropped + * by the reducer. + */ +function getSubagentChatResource(tc: ToolCallState, subagentContent: ToolResultSubagentContent | undefined, sessionResource: URI): string { + return subagentContent?.resource ?? buildSubagentChatUri(sessionResource.toString(), tc.toolCallId); +} + /** * Returns MCP App render data for a tool call when it is an MCP call * with an `_meta.ui.resourceUri` and a known AHP `mcp://` `channel`. @@ -119,12 +140,12 @@ export function isSubagentToolName(toolName: string): boolean { return SUBAGENT_TOOL_NAMES.has(toolName); } -function systemNotificationToProgress(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined { +export function systemNotificationToChatPart(content: StringOrMarkdown | undefined, connectionAuthority: string): IChatProgress | undefined { if (!content) { return undefined; } const value = stringOrMarkdownToString(content, connectionAuthority); - return { kind: 'progressMessage', content: typeof value === 'string' ? new MarkdownString(value) : value }; + return { kind: 'systemNotification', content: typeof value === 'string' ? new MarkdownString(value) : value }; } /** @@ -218,6 +239,7 @@ export function usageInfoToChatUsage(usage: UsageInfo | undefined): IChatUsage | promptTokens: usage?.inputTokens ?? 0, completionTokens: usage?.outputTokens ?? 0, copilotCredits, + promptTokenDetails: contextAttributionToPromptTokenDetails(usage), }; } @@ -233,6 +255,133 @@ function getCopilotCredits(usage: UsageInfo | undefined): number | undefined { : undefined; } +/** + * Maps SDK `kind` values to display categories used by the context-usage + * widget. Categories follow the local agent's established grouping + * ("System" for infrastructure, "User Context" for conversation content). + */ +function kindToCategory(kind: string): string { + switch (kind) { + case 'system': + case 'toolDefinition': + return localize('contextAttribution.category.system', "System"); + case 'tool': + case 'skill': + case 'subagent': + case 'mcpServer': + case 'plugin': + return localize('contextAttribution.category.userContext', "User Context"); + default: + return localize('contextAttribution.category.userContext', "User Context"); + } +} + +/** + * Human-readable labels for aggregated `kind` groups. Entries of kind + * `system` are shown individually (they are already aggregated rollups); + * other kinds are summed into a single row per kind. + */ +function kindToAggregateLabel(kind: string): string { + switch (kind) { + case 'tool': return localize('contextAttribution.label.toolResults', "Tool Results"); + case 'toolDefinition': return localize('contextAttribution.label.toolDefinitions', "Tool Definitions"); + case 'skill': return localize('contextAttribution.label.skills', "Skills"); + case 'subagent': return localize('contextAttribution.label.subAgents', "Sub-agents"); + case 'mcpServer': return localize('contextAttribution.label.mcpTools', "MCP Tools"); + case 'plugin': return localize('contextAttribution.label.plugins', "Plugins"); + default: return kind; + } +} + +/** + * Converts the SDK's flat `contextAttribution.entries[]` into the + * `promptTokenDetails` array consumed by the context-usage widget. + * + * Entries of `kind: "system"` are emitted individually (they are already + * high-level rollups like "System prompt") unless they are a parent of + * `toolDefinition` entries — in that case the rollup is skipped and the + * individual `toolDefinition` entries are aggregated into their own row. + * All other kinds are **aggregated into one row per kind** (e.g. all + * `mcpServer` entries become a single "MCP Tools" line) to match the + * CLI's `/context` summary view. + * Any remaining tokens not covered by entries are reported as "Messages" + * (conversation history: user/assistant messages and tool results). + */ +function contextAttributionToPromptTokenDetails(usage: UsageInfo | undefined): IChatUsagePromptTokenDetail[] | undefined { + const meta = readUsageInfoMeta(usage); + const attribution = meta?.contextAttribution; + if (!attribution || attribution.totalTokens <= 0 || attribution.entries.length === 0) { + return undefined; + } + const details: IChatUsagePromptTokenDetail[] = []; + + // Identify system entries that are parents of other entries. + // These rollups are skipped because their children are aggregated + // directly into their own rows to avoid double-counting. + const parentIds = new Set<string>(); + for (const entry of attribution.entries) { + if (entry.parentId) { + parentIds.add(entry.parentId); + } + } + + // Accumulate tokens per aggregated kind + const kindTokens = new Map<string, number>(); + // Track tokens accounted for by top-level entries (system rollups + aggregated kinds) + let accountedTokens = 0; + + for (const entry of attribution.entries) { + if (entry.kind === 'system') { + if (parentIds.has(entry.id)) { + // This system entry is a rollup parent whose children are + // aggregated separately — skip to avoid double-counting. + continue; + } + // System entries are shown individually (already high-level rollups) + accountedTokens += entry.tokens; + const percentageOfPrompt = Math.round((entry.tokens / attribution.totalTokens) * 100); + if (percentageOfPrompt > 0) { + details.push({ + category: kindToCategory('system'), + label: entry.label, + percentageOfPrompt, + }); + } + } else { + // Aggregate all other kinds (including toolDefinition) into one row per kind + kindTokens.set(entry.kind, (kindTokens.get(entry.kind) ?? 0) + entry.tokens); + } + } + + // Emit aggregated rows + for (const [kind, tokens] of kindTokens) { + accountedTokens += tokens; + const percentageOfPrompt = Math.round((tokens / attribution.totalTokens) * 100); + if (percentageOfPrompt <= 0) { + continue; + } + const category = kindToCategory(kind); + const label = kindToAggregateLabel(kind); + details.push({ category, label, percentageOfPrompt }); + } + + // The remainder is conversation messages (user/assistant turns, tool results) + // not attributed to any specific entry by the SDK. + const messageTokens = Math.max(0, attribution.totalTokens - accountedTokens); + if (messageTokens > 0) { + const percentageOfPrompt = Math.round((messageTokens / attribution.totalTokens) * 100); + if (percentageOfPrompt > 0) { + details.push({ + category: localize('contextAttribution.category.userContext', "User Context"), + label: localize('contextAttribution.label.messages', "Messages"), + percentageOfPrompt, + }); + } + } + + return details.length > 0 ? details : undefined; +} + /** * A partial quota update derived from a usage report's `_meta.quotaSnapshots`. Structurally a * subset of the entitlement service's quota state, so callers merge it onto the existing quotas. @@ -362,7 +511,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority) }); + parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); } break; case ResponsePartKind.ToolCall: { @@ -383,7 +532,7 @@ export function turnsToHistory(backendSession: URI, turns: readonly Turn[], part break; case ResponsePartKind.SystemNotification: { - const progress = systemNotificationToProgress(rp.content, connectionAuthority); + const progress = systemNotificationToChatPart(rp.content, connectionAuthority); if (progress) { parts.push(progress); } @@ -508,6 +657,9 @@ function messageAttachmentToVariableEntry(attachment: MessageAttachment, connect } if (attachment.type === MessageAttachmentKind.Resource) { + if (isSessionReferenceTrajectoryAttachment(attachment)) { + return undefined; + } const uri = toAgentHostUri(URI.parse(attachment.uri), connectionAuthority); const name = attachment.label; const id = uri.toString() + (attachment.selection @@ -569,6 +721,35 @@ function messageAttachmentToVariableEntry(attachment: MessageAttachment, connect } const modelRepresentation = attachment.type === MessageAttachmentKind.Simple ? attachment.modelRepresentation : undefined; + if (isBrowserViewAttachment(attachment) && modelRepresentation !== undefined) { + const metadata = getBrowserViewAttachmentMetadata(attachment); + if (metadata) { + return { + kind: 'browserView', + id: metadata.browserUri, + name: attachment.label, + value: URI.parse(metadata.browserUri), + browserId: metadata.browserId, + modelDescription: modelRepresentation, + _meta: attachment._meta, + }; + } + } + if (attachment.displayKind === 'workspace' && modelRepresentation !== undefined) { + return { + kind: 'workspace', + id: attachment.label, + name: attachment.label, + value: modelRepresentation, + _meta: attachment._meta, + }; + } + if (attachment.type === MessageAttachmentKind.Simple) { + const sessionReferenceEntry = restoreSessionReferenceVariableEntryFromAttachment(attachment); + if (sessionReferenceEntry) { + return sessionReferenceEntry; + } + } const pasteEntry = restorePasteVariableEntryFromAttachment({ label: attachment.label, displayKind: attachment.displayKind, @@ -629,7 +810,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur switch (rp.kind) { case ResponsePartKind.Markdown: if (rp.content) { - parts.push({ kind: 'markdownContent', content: rawMarkdownToString(rp.content, connectionAuthority) }); + parts.push({ kind: 'markdownContent', content: new MarkdownString(rp.content) }); } break; case ResponsePartKind.Reasoning: @@ -648,7 +829,7 @@ export function activeTurnToProgress(sessionResource: URI, activeTurn: ActiveTur } case ResponsePartKind.SystemNotification: { - const progress = systemNotificationToProgress(rp.content, connectionAuthority); + const progress = systemNotificationToChatPart(rp.content, connectionAuthority); if (progress) { parts.push(progress); } @@ -674,7 +855,9 @@ function getTerminalInput(tc: ToolCallState): string | undefined { return undefined; } function getTerminalOutput(tc: ToolCallState) { - const text = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running ? tc.content?.find(c => c.type === 'text')?.text : undefined; + // TODO: Revisit whether SDK shell tool output should continue coming from + // ToolResultContentType.Text, or from terminalComplete.preview when available. + const text = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running ? tc.content?.find(isToolResultTextContent)?.text : undefined; if (!text) { return undefined; } @@ -685,6 +868,24 @@ function getTerminalOutput(tc: ToolCallState) { return { text: text.replace(/\r?\n/g, '\r\n') }; } +function isToolResultTextContent(content: ToolResultContent): content is Extract<ToolResultContent, { type: ToolResultContentType.Text }> { + return content.type === ToolResultContentType.Text; +} + +function getTerminalCommandState(tc: ToolCallState, fallbackSuccess?: boolean): IChatTerminalToolInvocationData['terminalCommandState'] | undefined { + const terminalComplete = tc.status === ToolCallStatus.Completed || tc.status === ToolCallStatus.Running + ? tc.content?.find(isToolResultTerminalCompleteContent) + : undefined; + if (terminalComplete?.exitCode !== undefined) { + return { exitCode: terminalComplete.exitCode }; + } + return fallbackSuccess === undefined ? undefined : { exitCode: fallbackSuccess ? 0 : 1 }; +} + +function isToolResultTerminalCompleteContent(content: ToolResultContent): content is Extract<ToolResultContent, { type: ToolResultContentType.TerminalComplete }> { + return content.type === ToolResultContentType.TerminalComplete; +} + function getTerminalLanguage(tc: ToolCallState) { return tc.toolName === 'powershell' ? 'powershell' : 'shellscript'; } @@ -738,8 +939,8 @@ function isTerminalToolCall(tc: ToolCallState, existingKind?: string): boolean { * block arrives — refreshing from `tc` alone would clobber them whenever the * block hasn't landed yet. * - * Completion-only fields (e.g. `terminalCommandState` from `tc.success`) - * are layered on top by the caller; the helper is status-agnostic. + * Completion-only fields (e.g. `terminalCommandState`) are layered on top by + * the caller; the helper is status-agnostic. */ function buildTerminalToolSpecificData( tc: ToolCallState, @@ -761,6 +962,7 @@ function buildTerminalToolSpecificData( ...existing, kind: 'terminal', commandLine, + intention: tc.intention ?? existing?.intention, language: existing?.language ?? getTerminalLanguage(tc), terminalToolSessionId: terminalContentUri ? makeAhpTerminalToolSessionId(terminalContentUri, sessionResource) @@ -898,7 +1100,7 @@ function getToolErrorString(tc: ToolCallState): string | undefined { export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentInvocationId: string | undefined, sessionResource: URI, connectionAuthority: string): IChatToolInvocationSerialized { const isTerminal = isTerminalToolCall(tc); const isSuccess = tc.status === ToolCallStatus.Completed && tc.success; - const invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + let invocationMsg = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? tc.displayName; // Check for subagent content const subagentContent = tc.status === ToolCallStatus.Completed ? getToolSubagentContent(tc) : undefined; @@ -927,6 +1129,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn description: getSubagentTaskDescription(tc) ?? tc.displayName, agentName: subagentContent?.agentName ?? getSubagentAgentName(tc), result: resultText, + chatResource: getSubagentChatResource(tc, subagentContent, sessionResource), }, }; } @@ -935,7 +1138,7 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn if (isTerminal) { toolSpecificData = { ...buildTerminalToolSpecificData(tc, sessionResource), - terminalCommandState: { exitCode: isSuccess ? 0 : 1 }, + terminalCommandState: getTerminalCommandState(tc, isSuccess), }; } else if (getToolKind(tc) === 'search') { toolSpecificData = { kind: 'search' }; @@ -948,9 +1151,18 @@ export function completedToolCallToSerialized(tc: ICompletedToolCall, subAgentIn } } - const pastTenseMsg = isSuccess + let pastTenseMsg = isSuccess ? stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority) ?? invocationMsg : invocationMsg; + // Tools that render a bespoke, client-authored message override both the + // invocation and past-tense text here. Add new per-tool cases alongside. + if (isAddCommentTool(tc.toolName)) { + const ref = addCommentReference(tc); + if (ref) { + invocationMsg = ref; + pastTenseMsg = ref; + } + } const resultDetails = (!toolSpecificData || toolSpecificData.kind === 'input' && toolSpecificData.mcpAppData) && (tc.status !== ToolCallStatus.Completed || getToolFileEdits(tc).length === 0) ? getToolInputOutputDetails(tc, !isSuccess, getToolErrorString(tc), !!(toolSpecificData?.kind === 'input' && toolSpecificData.mcpAppData), connectionAuthority) @@ -1055,6 +1267,9 @@ const EXTERNAL_LINK_SCHEMES: ReadonlySet<string> = new Set([ 'command', 'vscode', 'vscode-insiders', + Schemas.vscodeBrowser, + 'copilot-skill', + product.urlProtocol, AGENT_HOST_SCHEME, ]); @@ -1181,6 +1396,107 @@ export function rawMarkdownToString(content: string, connectionAuthority: string return new MarkdownString(rewritten); } +function parseAbsoluteFileLinkTarget(href: string): URI | undefined { + const fragmentIndex = href.indexOf('#'); + const rawPath = fragmentIndex >= 0 ? href.substring(0, fragmentIndex) : href; + if (rawPath.includes('?')) { + return undefined; + } + + const existingFragment = fragmentIndex >= 0 ? href.substring(fragmentIndex + 1) : ''; + const parsedPath = existingFragment ? { path: rawPath } : parseFileLocation(rawPath); + let decodedPath: string; + try { + decodedPath = decodeURIComponent(parsedPath.path); + } catch { + return undefined; + } + + const absolutePath = decodedPath; + const isWindowsPath = win32.isAbsolute(absolutePath); + if (!posix.isAbsolute(absolutePath) && !isWindowsPath) { + return undefined; + } + + const selectionFragment = formatLocationFragment(parsedPath); + const normalizedPath = isWindowsPath ? absolutePath.replaceAll('\\', '/') : absolutePath; + return URI.file(normalizedPath).with({ fragment: existingFragment || selectionFragment }); +} + +interface IFileLocation { + readonly path: string; + readonly line?: number; + readonly column?: number; +} + +function parseFileLocation(path: string): IFileLocation { + const match = /^(?<path>.+?):(?<line>[1-9]\d*)(?::(?<column>[1-9]\d*))?$/.exec(path); + if (!match?.groups) { + return { path }; + } + const line = Number(match.groups.line); + const column = match.groups.column ? Number(match.groups.column) : undefined; + if ( + !Number.isSafeInteger(line) + || column !== undefined && !Number.isSafeInteger(column) + ) { + return { path }; + } + return { path: match.groups.path, line, column }; +} + +function formatLocationFragment(location: IFileLocation): string { + if (location.line === undefined) { + return ''; + } + return `L${location.line}${location.column !== undefined && location.column !== 1 ? `,${location.column}` : ''}`; +} + +function normalizeFileUriSelection(uri: URI, href: string): URI { + if (uri.scheme.toLowerCase() !== Schemas.file || uri.query || uri.fragment) { + return uri; + } + const parsedPath = parseFileLocation(href); + if (parsedPath.line === undefined) { + return uri; + } + const fragment = formatLocationFragment(parsedPath); + const suffixLength = href.length - parsedPath.path.length; + return uri.with({ path: uri.path.substring(0, uri.path.length - suffixLength), fragment }); +} + +/** Wraps an absolute path or internal URI target for the owning Agent Host connection. */ +export function rewriteAgentHostLinkTarget(href: string, connectionAuthority: string): string { + let parsed = parseAbsoluteFileLinkTarget(href); + if (!parsed) { + try { + parsed = URI.parse(href, true); + } catch { + return href; + } + const scheme = parsed.scheme.toLowerCase(); + if (!scheme || EXTERNAL_LINK_SCHEMES.has(scheme)) { + return href; + } + parsed = normalizeFileUriSelection(parsed.with({ scheme }), href); + if (!parsed.path.startsWith('/')) { + return href; + } + } + + let agentHostUri: URI; + try { + agentHostUri = toAgentHostUri(parsed, connectionAuthority); + } catch { + return href; + } + if (isSkillFileUri(parsed) && !agentHostUri.query.includes('vscodeLinkType=')) { + const existing = agentHostUri.query; + agentHostUri = agentHostUri.with({ query: existing ? `${existing}&vscodeLinkType=skill` : 'vscodeLinkType=skill' }); + } + return agentHostUri.toString(); +} + /** * Converts a protocol `StringOrMarkdown` value to a chat-layer `IMarkdownString`. * @@ -1200,6 +1516,82 @@ export function stringOrMarkdownToString(value: StringOrMarkdown | undefined, co return rawMarkdownToString(value.markdown, connectionAuthority); } +/** + * Number of comment-body characters shown inline in the {@link addCommentReference} + * pill before it is truncated with an ellipsis. + */ +const ADD_COMMENT_PREVIEW_LENGTH = 40; + +/** + * Builds the inline preview of an `addComment` comment body: whitespace is + * collapsed to single spaces and the text is truncated to + * {@link ADD_COMMENT_PREVIEW_LENGTH} characters with a trailing ellipsis. + */ +function addCommentPreview(text: string): string { + const singleLine = text.replace(/\s+/g, ' ').trim(); + return singleLine.length > ADD_COMMENT_PREVIEW_LENGTH + ? `${singleLine.slice(0, ADD_COMMENT_PREVIEW_LENGTH)}…` + : singleLine; +} + +/** Whether {@link value} is a positive 1-based line/column coordinate. */ +function isPositiveInteger(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1; +} + +/** + * Whether {@link value} is a valid 1-based editor range: every coordinate must + * be an integer >= 1, since the range is later used for editor selection and + * reveal. Invalid input is treated as unparseable so the UI falls back to the + * server-authored message. + */ +function isOneBasedRange(value: unknown): value is IRange { + const range = value as IRange | undefined; + return !!range && typeof range === 'object' + && isPositiveInteger(range.startLineNumber) + && isPositiveInteger(range.startColumn) + && isPositiveInteger(range.endLineNumber) + && isPositiveInteger(range.endColumn); +} + +/** + * Builds a rich, clickable reference for the agent host `addComment` feedback + * tool call — the tool name and the first + * {@link ADD_COMMENT_PREVIEW_LENGTH} characters of the comment body in quotes. + * Clicking it runs {@link AgentFeedbackReviewCommandId.RevealAt} to open the + * file and reveal the comment (agent feedback) in the editor. + * + * Only call this for the `addComment` tool (gate call sites with + * {@link isAddCommentTool}). Returns `undefined` when the arguments can't be + * parsed, so the caller falls back to the server-authored message. + */ +function addCommentReference(tc: ToolCallState): IMarkdownString | undefined { + // `toolInput` is absent while parameters are still streaming; every other + // state carries it (see `ToolCallParameterFields`). + if (tc.status === ToolCallStatus.Streaming || !tc.toolInput) { + return undefined; + } + const toolInput = tc.toolInput; + let args: { resourceUri?: unknown; range?: unknown; text?: unknown }; + try { + args = JSON.parse(toolInput); + } catch { + return undefined; + } + if (typeof args.resourceUri !== 'string' || typeof args.text !== 'string' || !isOneBasedRange(args.range)) { + return undefined; + } + const preview = escapeIcons(escapeMarkdownLinkLabel(addCommentPreview(args.text))); + // The command resolves the owning session from the file resource, so the + // link only needs the resource and range (both known here). + const commandArgs = encodeURIComponent(JSON.stringify([args.resourceUri, args.range])); + const link = `command:${AgentFeedbackReviewCommandId.RevealAt}?${commandArgs}`; + return new MarkdownString(`[addComment "${preview}"](${link})`, { + isTrusted: { enabledCommands: [AgentFeedbackReviewCommandId.RevealAt] }, + supportThemeIcons: true, + }); +} + /** * Creates a live {@link ChatToolInvocation} from the protocol's tool-call * state. Used during active turns to represent running tool calls in the UI. @@ -1292,7 +1684,13 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI } const invocation = new ChatToolInvocation(undefined, toolData, tc.toolCallId, subAgentInvocationId, undefined); - invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? localize('ahp.running', "Running {0}...", tc.displayName); + invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? tc.displayName; + + // Tools that render a bespoke, client-authored invocation message override + // the server text here. Add new per-tool cases alongside this branch. + if (isAddCommentTool(tc.toolName)) { + invocation.invocationMessage = addCommentReference(tc) ?? invocation.invocationMessage; + } if (isTerminalToolCall(tc)) { // Set terminal toolSpecificData eagerly so the renderer shows a @@ -1318,6 +1716,7 @@ export function toolCallStateToInvocation(tc: ToolCallState, subAgentInvocationI kind: 'subagent', description: getSubagentTaskDescription(tc), agentName: subagentContent?.agentName ?? getSubagentAgentName(tc), + chatResource: getSubagentChatResource(tc, subagentContent, sessionResource), }; } else if (getToolKind(tc) === 'search') { invocation.toolSpecificData = { kind: 'search' }; @@ -1338,16 +1737,21 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: return; } existing.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? existing.invocationMessage; + if (isAddCommentTool(tc.toolName)) { + existing.invocationMessage = addCommentReference(tc) ?? existing.invocationMessage; + } const subagentContent = getToolSubagentContent(tc); if (subagentContent) { existing.toolSpecificData = { kind: 'subagent', + isActive: existing.toolSpecificData?.kind === 'subagent' ? existing.toolSpecificData.isActive : undefined, description: getSubagentTaskDescription(tc), agentName: subagentContent.agentName, credits: existing.toolSpecificData?.kind === 'subagent' ? existing.toolSpecificData.credits : undefined, modelName: existing.toolSpecificData?.kind === 'subagent' ? existing.toolSpecificData.modelName : undefined, + chatResource: subagentContent.resource, }; // toolSpecificData is a plain property — notify state observers // so ChatSubagentContentPart re-reads the updated metadata. @@ -1361,7 +1765,7 @@ export function updateRunningToolSpecificData(existing: ChatToolInvocation, tc: const description = getSubagentTaskDescription(tc) ?? existing.toolSpecificData.description; const agentName = getSubagentAgentName(tc) ?? existing.toolSpecificData.agentName; if (description !== existing.toolSpecificData.description || agentName !== existing.toolSpecificData.agentName) { - existing.toolSpecificData = { kind: 'subagent', description, agentName, credits: existing.toolSpecificData.credits, modelName: existing.toolSpecificData.modelName }; + existing.toolSpecificData = { kind: 'subagent', isActive: existing.toolSpecificData.isActive, description, agentName, credits: existing.toolSpecificData.credits, modelName: existing.toolSpecificData.modelName, chatResource: existing.toolSpecificData.chatResource }; existing.notifyToolSpecificDataChanged(); } return; @@ -1424,6 +1828,11 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC if ((isCompleted || isCancelled) && hasKey(tc, { invocationMessage: true })) { invocation.invocationMessage = stringOrMarkdownToString(tc.invocationMessage, connectionAuthority) ?? invocation.invocationMessage; } + // Tools that render a bespoke, client-authored message override the + // invocation text here. Add new per-tool cases alongside this branch. + if (isAddCommentTool(tc.toolName)) { + invocation.invocationMessage = addCommentReference(tc) ?? invocation.invocationMessage; + } // Check for subagent content — set toolSpecificData so the UI renders a subagent widget if (isCompleted) { @@ -1432,22 +1841,26 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC const resultText = getToolOutputText(tc); invocation.toolSpecificData = { kind: 'subagent', + isActive: invocation.toolSpecificData?.kind === 'subagent' ? invocation.toolSpecificData.isActive : undefined, description: getSubagentTaskDescription(tc), agentName: subagentContent.agentName, result: resultText, credits: invocation.toolSpecificData?.kind === 'subagent' ? invocation.toolSpecificData.credits : undefined, modelName: invocation.toolSpecificData?.kind === 'subagent' ? invocation.toolSpecificData.modelName : undefined, + chatResource: getSubagentChatResource(tc, subagentContent, backendSession), }; } else if (invocation.toolSpecificData?.kind === 'subagent') { // Subagent-spawning tool that completed without a Subagent content // block. Refresh metadata + carry the tool's output as the result. invocation.toolSpecificData = { kind: 'subagent', + isActive: invocation.toolSpecificData.isActive, description: getSubagentTaskDescription(tc) ?? invocation.toolSpecificData.description, agentName: getSubagentAgentName(tc) ?? invocation.toolSpecificData.agentName, result: getToolOutputText(tc), credits: invocation.toolSpecificData.credits, modelName: invocation.toolSpecificData.modelName, + chatResource: invocation.toolSpecificData.chatResource ?? getSubagentChatResource(tc, undefined, backendSession), }; } } @@ -1457,11 +1870,16 @@ export function finalizeToolInvocation(invocation: ChatToolInvocation, tc: ToolC invocation.presentation = undefined; invocation.toolSpecificData = { ...buildTerminalToolSpecificData(tc, backendSession, existing), - terminalCommandState: { exitCode: isCompleted && tc.success ? 0 : 1 }, + terminalCommandState: getTerminalCommandState(tc, isCompleted && tc.success), }; } else if (isCompleted && tc.pastTenseMessage) { invocation.pastTenseMessage = stringOrMarkdownToString(tc.pastTenseMessage, connectionAuthority); } + // Tools that render a bespoke, client-authored message override the + // past-tense text here. Add new per-tool cases alongside this branch. + if (isCompleted && isAddCommentTool(tc.toolName)) { + invocation.pastTenseMessage = addCommentReference(tc) ?? invocation.pastTenseMessage; + } if (isCompleted) { const mcpAppData = getMcpAppData(tc, backendSession); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts index ff42251ec0cf8f..e3606fc36b9d09 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionApprovalModel.ts @@ -12,13 +12,35 @@ import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService, IChatToolInvocation, ToolConfirmKind } from '../../common/chatService/chatService.js'; import { ILanguageService } from '../../../../../editor/common/languages/language.js'; +/** + * The kind of attention a pending approval needs. Lets consumers tailor UI + * (e.g. a summary message) to what the user is actually being asked to do. + */ +export const enum AgentSessionApprovalKind { + /** A terminal command is waiting to be run. */ + Terminal = 'terminal', + /** The agent is asking the user a question / needs a free-form response. */ + Question = 'question', + /** Some other tool invocation is waiting for confirmation. */ + Other = 'other', +} + export interface IAgentSessionApprovalInfo { + readonly kind: AgentSessionApprovalKind; readonly label: string; readonly languageId: string | undefined; readonly since: Date; confirm(): void; } +/** + * A stable identity for a specific pending approval, distinguishing it from a + * later, distinct approval on the same session (a fresh `since` yields a new id). + */ +export function agentSessionApprovalId(info: IAgentSessionApprovalInfo): string { + return `${info.kind}\u0000${info.label}\u0000${info.since.getTime()}`; +} + /** * Tracks approval state for all live chat sessions. For each session, * exposes an observable that emits {@link IAgentSessionApprovalInfo} @@ -71,7 +93,7 @@ export class AgentSessionApprovalModel extends Disposable { if (current === value) { return; } - if (current !== undefined && value !== undefined && current.label === value.label && current.languageId === value.languageId) { + if (current !== undefined && value !== undefined && current.kind === value.kind && current.label === value.label && current.languageId === value.languageId) { return; } settable.set(value, undefined); @@ -98,19 +120,24 @@ export class AgentSessionApprovalModel extends Disposable { if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { let label: string; let languageId: string | undefined; + let kind: AgentSessionApprovalKind; if (part.toolSpecificData?.kind === 'terminal') { const terminalData = migrateLegacyTerminalToolSpecificData(part.toolSpecificData); label = terminalData.presentationOverrides?.commandLine ?? terminalData.commandLine.forDisplay ?? terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original; languageId = this._languageService.getLanguageIdByLanguageName(terminalData.presentationOverrides?.language ?? terminalData.language) ?? undefined; + kind = AgentSessionApprovalKind.Terminal; } else if (needsInput.detail) { label = needsInput.detail; + kind = AgentSessionApprovalKind.Question; } else { const msg = part.invocationMessage; label = typeof msg === 'string' ? msg : renderAsPlaintext(msg); + kind = AgentSessionApprovalKind.Other; } const confirmState = state; setIfChanged({ + kind, label, languageId, since: new Date(), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 29ffd11e263ceb..8c1bfad3e4b4bc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -96,7 +96,7 @@ export function getAgentSessionProviderIcon(provider: AgentSessionTarget): Theme case AgentSessionProviders.Growth: return Codicon.lightbulb; case AgentSessionProviders.AgentHostCopilot: - return Codicon.copilot; + return Codicon.vm; default: return Codicon.extensions; } @@ -190,7 +190,7 @@ export function getAgentSessionProviderDescription(provider: AgentSessionTarget) case AgentSessionProviders.Growth: return localize('chat.session.providerDescription.growth', "Learn about Copilot features."); case AgentSessionProviders.AgentHostCopilot: - return 'Run a Copilot SDK agent in a dedicated process.'; + return localize('chat.session.providerDescription.agentHostCopilot', "Run a Copilot SDK agent in the local agent host process."); default: return ''; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index d20ac96303c30d..a487397ed4f593 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -529,7 +529,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre return { ...Codicon.circleFilled, color: themeColorFromId('textLink.foreground') }; } - if (!statusOnly && session.providerType === AgentSessionProviders.Local) { + if (!statusOnly && (session.providerType === AgentSessionProviders.Local || session.providerType === AgentSessionProviders.AgentHostCopilot)) { return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 95711f6434ee80..630e0d837ba880 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -7,7 +7,7 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { URI } from '../../../../../base/common/uri.js'; import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IAICustomizationWorkspaceService, IStorageSourceFilter, AICustomizationSources, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; import { type AICustomizationSource, AICustomizationManagementSection, sectionToPromptType } from './aiCustomizationManagement.js'; import { ICustomizationHarnessService, type ICustomizationItem } from '../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; @@ -41,14 +41,12 @@ export async function generateCustomizationDebugReport( ): Promise<string> { const promptType = sectionToPromptType(section); const activeDescriptor = harnessService.getActiveDescriptor(); - const filter = activeDescriptor.getStorageSourceFilter(promptType); const lines: string[] = []; lines.push(`== Customization Debug: ${section} (${promptType}) ==`); lines.push(`Window: ${workspaceService.isSessionsWindow ? 'Sessions' : 'Core VS Code'}`); lines.push(`Active root: ${workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'}`); lines.push(`Sections: [${workspaceService.managementSections.join(', ')}]`); - lines.push(`Filter sources: [${filter.sources.join(', ')}]`); // Active harness descriptor if (activeDescriptor) { @@ -76,7 +74,7 @@ export async function generateCustomizationDebugReport( lines.push('--- Stage 1: No provider available ---'); lines.push(''); await appendRawServiceData(lines, promptsService, promptType); - await appendFilteredData(lines, promptsService, promptType, filter); + await appendUnfilteredData(lines, promptsService, promptType); } @@ -221,26 +219,15 @@ async function appendRawServiceData(lines: string[], promptsService: IPromptsSer lines.push(''); } -async function appendFilteredData(lines: string[], promptsService: IPromptsService, promptType: PromptsType, filter: IStorageSourceFilter): Promise<void> { - lines.push('--- Stage 2b: After applyStorageSourceFilter ---'); +async function appendUnfilteredData(lines: string[], promptsService: IPromptsService, promptType: PromptsType): Promise<void> { + lines.push('--- Stage 2b: All files (no filtering applied) ---'); const { localFiles, userFiles, extensionFiles } = await getPromptFilesByStorage(promptsService, promptType); const all: IPromptPath[] = [...localFiles, ...userFiles, ...extensionFiles]; - const filtered = applyStorageSourceFilter(all, filter); - lines.push(` Input: ${all.length} → Filtered: ${filtered.length}`); - lines.push(` local: ${filtered.filter(f => f.storage === PromptsStorage.local).length}`); - lines.push(` user: ${filtered.filter(f => f.storage === PromptsStorage.user).length}`); - lines.push(` extension: ${filtered.filter(f => f.storage === PromptsStorage.extension).length}`); - - const removedCount = all.length - filtered.length; - if (removedCount > 0) { - const filteredUris = new Set(filtered.map(f => f.uri.toString())); - const removed = all.filter(f => !filteredUris.has(f.uri.toString())); - lines.push(` Removed (${removedCount}):`); - for (const f of removed) { - lines.push(` [${f.storage}] ${f.uri.fsPath}`); - } - } + lines.push(` Count: ${all.length} total`); + lines.push(` local: ${all.filter(f => f.storage === PromptsStorage.local).length}`); + lines.push(` user: ${all.filter(f => f.storage === PromptsStorage.user).length}`); + lines.push(` extension: ${all.filter(f => f.storage === PromptsStorage.extension).length}`); lines.push(''); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index ca9303d4d202e1..1aa26ff98c9092 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -39,6 +39,11 @@ export const promptIcon = registerIcon('ai-customization-prompt', Codicon.bookma */ export const hookIcon = registerIcon('ai-customization-hook', Codicon.zap, localize('aiCustomizationHookIcon', "Icon for hooks.")); +/** + * Icon for automations. + */ +export const automationIcon = registerIcon('ai-customization-automation', Codicon.watch, localize('aiCustomizationAutomationIcon', "Icon for scheduled automations.")); + /** * Icon for adding a new item. */ diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts index 7e70a8ffaaee33..d74847b98a1715 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -282,6 +282,11 @@ export class ItemProviderItemSource extends Disposable implements IAICustomizati })); } + override dispose(): void { + super.dispose(); + this.cachedPromise = undefined; + } + async fetchProviderItems(): Promise<readonly ICustomizationItem[]> { if (!this.cachedPromise) { this.cachedPromise = this.itemProvider.provideChatSessionCustomizations(this.sessionResource, CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts index df56233b723907..18e7a2ed0f7904 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemsModel.ts @@ -249,7 +249,7 @@ export class AICustomizationItemsModel extends Disposable implements IAICustomiz } return new PureItemProviderItemSource(sessionResource, descriptor.itemProvider, this.itemNormalizer); } else { - const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => descriptor); + const itemProvider = descriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); return new ItemProviderItemSource( sessionResource, itemProvider, diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 58d6eb8564433d..07fa2afddcf466 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -616,8 +616,8 @@ export class AICustomizationListWidget extends Disposable { private readonly _onDidRequestCreate = this._register(new Emitter<PromptsType>()); readonly onDidRequestCreate: Event<PromptsType> = this._onDidRequestCreate.event; - private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }>()); - readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'workspace' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; + private readonly _onDidRequestCreateManual = this._register(new Emitter<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }>()); + readonly onDidRequestCreateManual: Event<{ type: PromptsType; target: 'local' | 'user' | 'workspace-root'; rootFileName?: string }> = this._onDidRequestCreateManual.event; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -1098,7 +1098,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); } } else if (!override?.commandId) { @@ -1107,7 +1107,7 @@ export class AICustomizationListWidget extends Disposable { label: `$(${Codicon.add.id}) ${localize('configureHooks', "Configure Hooks")}`, enabled: hasWorkspace, tooltip: hasWorkspace ? undefined : localize('configureHooksDisabled', "Open a workspace folder to configure hooks."), - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); } return actions; @@ -1129,7 +1129,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.add.id}) New ${createTypeLabel} (Workspace)`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); addedTargets.add('workspace'); } else { @@ -1148,7 +1148,7 @@ export class AICustomizationListWidget extends Disposable { actions.push({ label: `$(${Codicon.folder.id}) New ${createTypeLabel} (Workspace)`, enabled: true, - run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'workspace' }); }, + run: () => { this._onDidRequestCreateManual.fire({ type: promptType, target: 'local' }); }, }); } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts index 3b1e3cca8210a4..dff6412b65ef3e 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagementEditor.ts @@ -33,7 +33,7 @@ import { IListVirtualDelegate, IListRenderer } from '../../../../../base/browser import { ThemeIcon } from '../../../../../base/common/themables.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; -import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { basename, dirname, isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { AICustomizationManagementEditorInput } from './aiCustomizationManagementEditorInput.js'; import { AICustomizationListWidget } from './aiCustomizationListWidget.js'; @@ -42,6 +42,7 @@ import { McpListWidget } from './mcpListWidget.js'; import { PluginListWidget } from './pluginListWidget.js'; import { ToolsListWidget } from './toolsListWidget.js'; import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE } from '../agentSessions/agentHost/agentHostToolSetEnablementService.js'; +import { AutomationsListWidget } from './automationsListWidget.js'; import { AI_CUSTOMIZATION_MANAGEMENT_EDITOR_ID, AI_CUSTOMIZATION_MANAGEMENT_SIDEBAR_WIDTH_KEY, @@ -56,7 +57,8 @@ import { SIDEBAR_MAX_WIDTH, CONTENT_MIN_WIDTH, } from './aiCustomizationManagement.js'; -import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon } from './aiCustomizationIcons.js'; +import { agentIcon, instructionsIcon, promptIcon, skillIcon, hookIcon, pluginIcon, toolsIcon, automationIcon } from './aiCustomizationIcons.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../common/automations/automationsEnabled.js'; import { ChatModelsWidget } from '../chatManagement/chatModelsWidget.js'; import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; @@ -65,7 +67,7 @@ import { AGENT_MD_FILENAME } from '../../common/promptSyntax/config/promptFileLo import { getAttributeDefinition, getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; import { INewPromptOptions, NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../promptSyntax/newPromptFileActions.js'; import { showConfigureHooksQuickPick } from '../promptSyntax/hookActions.js'; -import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory } from './customizationCreatorService.js'; +import { resolveWorkspaceTargetDirectory, resolveUserTargetDirectory, CustomizationLocationPicker } from './customizationCreatorService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AICustomizationSources, IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { CodeEditorWidget } from '../../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; @@ -88,12 +90,12 @@ import { IExtension } from '../../../extensions/common/extensions.js'; import { EmbeddedMcpServerDetail } from './embeddedMcpServerDetail.js'; import { EmbeddedAgentPluginDetail } from './embeddedAgentPluginDetail.js'; import { EmbeddedExtensionToolsDetail } from './embeddedExtensionToolsDetail.js'; -import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ChatConfiguration } from '../../common/constants.js'; import { AICustomizationWelcomePage } from './aiCustomizationWelcomePage.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; -import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { showNoFoldersDialog } from '../promptSyntax/pickers/askForPromptSourceFolder.js'; const $ = DOM.$; @@ -267,11 +269,13 @@ export class AICustomizationManagementEditor extends EditorPane { private listWidget!: AICustomizationListWidget; private mcpListWidget: McpListWidget | undefined; private pluginListWidget: PluginListWidget | undefined; + private automationsListWidget: AutomationsListWidget | undefined; private modelsWidget: ChatModelsWidget | undefined; private toolsListWidget: ToolsListWidget | undefined; private promptsContentContainer!: HTMLElement; private mcpContentContainer: HTMLElement | undefined; private pluginContentContainer: HTMLElement | undefined; + private automationsContentContainer: HTMLElement | undefined; private modelsContentContainer: HTMLElement | undefined; private toolsContentContainer: HTMLElement | undefined; private modelsFooterElement: HTMLElement | undefined; @@ -369,6 +373,7 @@ export class AICustomizationManagementEditor extends EditorPane { @INotificationService private readonly notificationService: INotificationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, @IViewsService private readonly viewsService: IViewsService, + @ILabelService private readonly labelService: ILabelService, @IAICustomizationItemsModel private readonly itemsModel: IAICustomizationItemsModel, ) { super(AICustomizationManagementEditor.ID, group, telemetryService, themeService, storageService); @@ -398,6 +403,7 @@ export class AICustomizationManagementEditor extends EditorPane { [AICustomizationManagementSection.Instructions]: { label: localize('instructions', "Instructions"), icon: instructionsIcon, description: localize('instructionsDesc', "Set always-on instructions that guide AI behavior across your workspace or user profile.") }, [AICustomizationManagementSection.Prompts]: { label: localize('prompts', "Prompts"), icon: promptIcon, description: localize('promptsDesc', "Reusable prompt templates that can be invoked as slash commands.") }, [AICustomizationManagementSection.Hooks]: { label: localize('hooks', "Hooks"), icon: hookIcon, description: localize('hooksDesc', "Configure automated actions triggered by events like saving files or running tasks.") }, + [AICustomizationManagementSection.Automations]: { label: localize('automations', "Automations"), icon: automationIcon, description: localize('automationsDesc', "Schedule agent sessions to run on a cadence you choose.") }, [AICustomizationManagementSection.McpServers]: { label: localize('mcpServers', "MCP Servers"), icon: Codicon.server, description: localize('mcpServersDesc', "Connect external tool servers that extend AI capabilities with custom tools and data sources.") }, [AICustomizationManagementSection.Plugins]: { label: localize('plugins', "Plugins"), icon: pluginIcon, description: localize('pluginsDesc', "Install and manage agent plugins that add additional tools, skills, and integrations.") }, [AICustomizationManagementSection.Models]: { label: localize('models', "Models"), icon: Codicon.vm, description: localize('modelsDesc', "Configure and manage language models available for use.") }, @@ -475,6 +481,7 @@ export class AICustomizationManagementEditor extends EditorPane { this.mcpListWidget?.layout(height - 16, width - 24); this.pluginListWidget?.layout(height - 16, width - 24); this.toolsListWidget?.layout(height - 16, width - 24); + this.automationsListWidget?.layout(height - 16, width - 24); const modelsFooterHeight = this.modelsFooterElement?.offsetHeight || 80; this.modelsWidget?.layout(height - 16 - modelsFooterHeight, width); if (this.viewMode === 'editor' && this.embeddedEditor && this.embeddedEditorContainer) { @@ -537,6 +544,11 @@ export class AICustomizationManagementEditor extends EditorPane { const descriptor = this.harnessService.findHarnessById(activeId); const hidden = new Set(descriptor?.hiddenSections ?? []); + // Also hide the Automations section when the feature setting is off. + if (this.configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) !== true) { + hidden.add(AICustomizationManagementSection.Automations); + } + this.sections.length = 0; for (const s of this.allSections) { if (!hidden.has(s.id)) { @@ -628,6 +640,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (e.affectsConfiguration(ChatConfiguration.ChatCustomizationsStructuredPreviewEnabled)) { this.onStructuredPreviewSettingChanged(); } + if (e.affectsConfiguration(CHAT_AUTOMATIONS_ENABLED_SETTING)) { + this.rebuildVisibleSections(); + } })); } @@ -844,6 +859,13 @@ export class AICustomizationManagementEditor extends EditorPane { })); } + // Container for Automations content + if (hasSections.has(AICustomizationManagementSection.Automations)) { + this.automationsContentContainer = DOM.append(contentInner, $('.automations-content-container')); + this.automationsListWidget = this.editorDisposables.add(this.instantiationService.createInstance(AutomationsListWidget)); + this.automationsContentContainer.appendChild(this.automationsListWidget.element); + } + // Embedded editor container this.editorContentContainer = DOM.append(contentInner, $('.editor-content-container')); this.createEmbeddedEditor(); @@ -871,6 +893,12 @@ export class AICustomizationManagementEditor extends EditorPane { })); this.pluginListWidget.fireItemCount(); } + if (this.automationsListWidget) { + this.editorDisposables.add(this.automationsListWidget.onDidChangeItemCount(count => { + this.updateSectionCount(AICustomizationManagementSection.Automations, count); + })); + this.automationsListWidget.fireItemCount(); + } if (this.modelsWidget) { this.editorDisposables.add(this.modelsWidget.onDidChangeItemCount(count => { this.updateSectionCount(AICustomizationManagementSection.Models, count); @@ -1020,6 +1048,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.modelsWidget?.focusSearch(); } else if (section === AICustomizationManagementSection.Tools) { this.toolsListWidget?.focusSearch(); + } else if (section === AICustomizationManagementSection.Automations) { + this.automationsListWidget?.focus(); } else { this.listWidget?.focusSearch(); } @@ -1065,6 +1095,7 @@ export class AICustomizationManagementEditor extends EditorPane { const isMcpSection = this.selectedSection === AICustomizationManagementSection.McpServers; const isPluginsSection = this.selectedSection === AICustomizationManagementSection.Plugins; const isToolsSection = this.selectedSection === AICustomizationManagementSection.Tools; + const isAutomationsSection = this.selectedSection === AICustomizationManagementSection.Automations; if (this.welcomePage) { this.welcomePage.container.style.display = isWelcome && !isEditorMode && !isDetailMode ? '' : 'none'; @@ -1084,6 +1115,9 @@ export class AICustomizationManagementEditor extends EditorPane { if (this.pluginContentContainer) { this.pluginContentContainer.style.display = !isEditorMode && !isDetailMode && isPluginsSection ? '' : 'none'; } + if (this.automationsContentContainer) { + this.automationsContentContainer.style.display = !isEditorMode && !isDetailMode && isAutomationsSection ? '' : 'none'; + } if (this.pluginDetailContainer) { this.pluginDetailContainer.style.display = isPluginDetailMode ? '' : 'none'; } @@ -1125,7 +1159,7 @@ export class AICustomizationManagementEditor extends EditorPane { /** * Creates a new prompt file and opens it in the embedded editor. */ - private async createNewItemManual(type: PromptsType, target: 'workspace' | 'user' | 'workspace-root', rootFileName?: string): Promise<void> { + private async createNewItemManual(type: PromptsType, target: 'local' | 'user' | 'workspace-root', rootFileName?: string): Promise<void> { this.telemetryService.publicLog2<CustomizationEditorCreateItemEvent, CustomizationEditorCreateItemClassification>('chatCustomizationEditor.createItem', { section: this.selectedSection ?? 'welcome', promptType: type, @@ -1176,15 +1210,23 @@ export class AICustomizationManagementEditor extends EditorPane { } return; } - - const targetDir = await this.resolveTargetDirectoryWithPicker(type, target); + const sessionResource = this.harnessService.activeSessionResource.get(); + const picker = this.instantiationService.createInstance(CustomizationLocationPicker); + const targetDir = await picker.resolveTargetDirectoryWithPicker( + sessionResource, + type, + target, + ); if (targetDir === null) { return; // User cancelled the picker } - // targetDir may be undefined when no matching folder exists for the - // requested storage type (e.g. skills have no user-storage folder). - // Pass it through — the command handles undefined by showing its own - // folder picker via askForPromptSourceFolder. + + if (targetDir === undefined) { + // targetDir may be undefined when no matching folder exists for the + // requested storage type (e.g. skills have no user-storage folder). + await this.instantiationService.invokeFunction(showNoFoldersDialog, type); + return; + } // When the active harness overrides the file extension (e.g. Claude // rules use .md instead of .instructions.md), pass it through so the @@ -1193,11 +1235,11 @@ export class AICustomizationManagementEditor extends EditorPane { const options: INewPromptOptions = { targetFolder: targetDir, - targetStorage: target === 'user' ? PromptsStorage.user : PromptsStorage.local, + targetStorage: target === AICustomizationSources.user ? PromptsStorage.user : PromptsStorage.local, fileExtension: override?.fileExtension, openFile: async (uri) => { - const isWorkspace = target === 'workspace'; - await this.showEmbeddedEditor(uri, basename(uri), type, target === 'user' ? PromptsStorage.user : PromptsStorage.local, isWorkspace); + const isWorkspace = target === AICustomizationSources.local; + await this.showEmbeddedEditor(uri, basename(uri), type, target, isWorkspace); return this.embeddedEditor; }, }; @@ -1215,72 +1257,6 @@ export class AICustomizationManagementEditor extends EditorPane { this.listWidget.refresh(); } - /** - * Resolves the target directory for creating a new customization file. - * If multiple source folders exist for the given storage type, shows a - * picker to let the user choose. Otherwise, returns the single match. - * - * Source folders come from the active harness's item provider (via the - * items model) — each session can supply its own set of customization - * locations through `ICustomizationItemProvider.provideSourceFolders`. - * - * @returns the resolved URI, `undefined` when no folder is available, - * or `null` when the user cancelled the picker. - */ - private async resolveTargetDirectoryWithPicker(type: PromptsType, target: 'workspace' | 'user'): Promise<URI | undefined | null> { - const sessionResource = this.harnessService.activeSessionResource.get(); - const activeDescriptor = this.harnessService.getActiveDescriptor(); - const provider = activeDescriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => activeDescriptor); - if (!provider.provideSourceFolders) { - return undefined; - } - const allFolders = await provider.provideSourceFolders(sessionResource, type, CancellationToken.None); - if (!allFolders) { - // Provider returned no source folders for this type/session. - return undefined; - } - - const projectRoot = this.workspaceService.getActiveProjectRoot(); - const matchingFolders: ICustomizationSourceFolder[] = []; - const hasSeen = new ResourceSet(); - for (const f of allFolders) { - if (target === 'workspace') { - if (projectRoot && isEqualOrParent(f.uri, projectRoot) && !hasSeen.has(f.uri)) { - hasSeen.add(f.uri); - matchingFolders.push(f); - } - } else { - if ((!projectRoot || !isEqualOrParent(f.uri, projectRoot)) && !hasSeen.has(f.uri)) { - hasSeen.add(f.uri); - matchingFolders.push(f); - } - } - } - - if (matchingFolders.length === 0) { - // No matching folders — return undefined so the command can fall - // back to askForPromptSourceFolder (not null which means cancellation) - return undefined; - } - - if (matchingFolders.length === 1) { - return matchingFolders[0].uri; - } - - // Multiple directories — ask the user which one to use - const items: (IQuickPickItem & { uri: URI })[] = matchingFolders.map(folder => ({ - label: folder.label, - description: folder.uri.fsPath, - uri: folder.uri, - })); - - const picked = await this.quickInputService.pick(items, { - placeHolder: localize('selectTargetDirectory', "Select a directory for the new customization file"), - }); - - return picked?.uri ?? null; - } - override updateStyles(): void { // The modal provides its own panel chrome, so the split view separator // is intentionally hidden here regardless of theme. @@ -1364,6 +1340,8 @@ export class AICustomizationManagementEditor extends EditorPane { this.modelsWidget?.focusSearch(); } else if (this.selectedSection === AICustomizationManagementSection.Tools) { this.toolsListWidget?.focusSearch(); + } else if (this.selectedSection === AICustomizationManagementSection.Automations) { + this.automationsListWidget?.focus(); } else { this.listWidget?.focusSearch(); } @@ -1761,7 +1739,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (workspaceFolder) { items.push({ label: localize('workspaceSaveTarget', "Workspace"), - description: workspaceFolder.fsPath, + description: this.labelService.getUriLabel(workspaceFolder, { relative: true }), target: 'workspace', folder: workspaceFolder, }); @@ -1771,7 +1749,7 @@ export class AICustomizationManagementEditor extends EditorPane { if (userFolder) { items.push({ label: localize('userSaveTarget', "User"), - description: userFolder.fsPath, + description: this.labelService.getUriLabel(userFolder, { relative: true }), target: 'user', folder: userFolder, }); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.ts new file mode 100644 index 00000000000000..2b68e27b1b2b89 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsAccessibilityHelp.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 * as nls from '../../../../../nls.js'; +import { + AccessibleContentProvider, + AccessibleViewProviderId, + AccessibleViewType, +} from '../../../../../platform/accessibility/browser/accessibleView.js'; +import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; +import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js'; +import { AICustomizationManagementSection } from '../../common/aiCustomizationWorkspaceService.js'; +import { + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION, +} from './aiCustomizationManagement.js'; + +/** + * Alt+F1 / "Open Accessibility Help" provider for the Automations + * section of the Agent Customizations editor. Mirrors the structure + * used by sibling sections (problems, scm, debug). The provider is + * activated when the editor is focused and the active section is + * `Automations`. + */ +export class AutomationsAccessibilityHelp implements IAccessibleViewImplementation { + readonly type = AccessibleViewType.Help; + readonly priority = 105; + readonly name = 'automations'; + readonly when = ContextKeyExpr.and( + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_EDITOR, + CONTEXT_AI_CUSTOMIZATION_MANAGEMENT_SECTION.isEqualTo(AICustomizationManagementSection.Automations), + ); + + getProvider(accessor: ServicesAccessor): AccessibleContentProvider { + const keybindingService = accessor.get(IKeybindingService); + // Must be a real AccessibleContentProvider instance: the accessible-view + // service does `instanceof` checks and otherwise falls back to + // ExtensionContentProvider semantics, breaking verbositySettingKey + // propagation. (The repo accessibility skill discourages custom subclasses.) + return new AccessibleContentProvider( + AccessibleViewProviderId.Automations, + { type: AccessibleViewType.Help }, + () => buildAutomationsHelpContent(keybindingService), + () => { /* no-op: the accessible view returns focus to the previously focused element. */ }, + AccessibilityVerbositySettingId.Automations, + ); + } +} + +/** + * Renders the static Automations accessibility-help content. Exported so + * tests can validate the rendered string without instantiating an + * {@link AccessibleContentProvider}. + */ +export function buildAutomationsHelpContent(keybindingService: IKeybindingService): string { + const lines: string[] = []; + + lines.push(nls.localize('automations.help.header', 'Accessibility Help: Automations')); + lines.push(nls.localize('automations.help.intro', 'Automations let you schedule agent sessions to run on a cadence you choose. When an automation is due, a fresh chat session is created in the background and the prompt is sent automatically.')); + lines.push(''); + + lines.push(nls.localize('automations.help.layoutHeader', 'Layout:')); + lines.push(nls.localize('automations.help.layoutDesc', 'The Automations section shows a list of all your automations. Each row displays the automation\u2019s name, schedule (Manual, Hourly, Daily at a time, or Weekly on a day at a time), the workspace folder it targets, the next scheduled run, and a preview of the prompt. Row action buttons follow on the right.')); + lines.push(''); + + lines.push(nls.localize('automations.help.actionsHeader', 'Row Actions (Tab between them):')); + lines.push(nls.localize('automations.help.actionRun', '- Run now: spawns a new agent session immediately using the automation\u2019s prompt. The trigger is recorded as Manual.')); + lines.push(nls.localize('automations.help.actionToggle', '- Toggle enabled: pauses or resumes scheduled runs. Disabled automations skip their scheduled tick but can still be run manually.')); + lines.push(nls.localize('automations.help.actionEdit', '- Edit: opens the create/edit dialog with the current values prefilled.')); + lines.push(nls.localize('automations.help.actionDelete', '- Delete: removes the automation after a confirmation prompt. Runs already in flight continue to completion.')); + lines.push(nls.localize('automations.help.actionHistory', '- Show history: expands an inline list of recent runs for the automation, including their status and any error messages.')); + lines.push(''); + + lines.push(nls.localize('automations.help.dialogHeader', 'Create/Edit Dialog:')); + lines.push(nls.localize('automations.help.dialogFields', 'The dialog has a Name field, a multi-line Prompt field, a Schedule selector (Manual, Hourly, Daily, Weekly), Time and Day-of-Week fields that appear when relevant, a Workspace folder selector with a Browse button, a Session type selector that appears when the selected folder offers more than one session type, an Agent Mode selector (Use default, Agent, Ask, Edit), and a Permission Mode selector (Use default, Default Approvals, Bypass Approvals, Autopilot). The selected Agent Mode and Permission Mode are replayed when the scheduler starts a run, so the chat opens with the same configuration every time. The Browse button opens a folder picker so you can target any folder, even one that is not currently open. Tab and Shift+Tab move between fields; Enter activates the Save button when the form is valid.')); + lines.push(nls.localize('automations.help.dialogValidation', 'Save is disabled until Name, Prompt, and Workspace folder are all set. Activate Cancel or press Escape to dismiss without saving.')); + lines.push(''); + + lines.push(nls.localize('automations.help.historyHeader', 'Run History:')); + lines.push(nls.localize('automations.help.historyDesc', 'Each run records its status (Pending, Running, Completed, Failed), the trigger that started it (Schedule, Manual, or Catch-up after VS Code restarts), the time it started, and the time it took. Failed runs include the failure reason.')); + lines.push(''); + + lines.push(nls.localize('automations.help.statusHeader', 'Screen Reader Announcements:')); + lines.push(nls.localize('automations.help.statusDesc', 'The Automations list announces state changes when you create, update, delete, toggle, or manually run an automation. Verbose announcements can be turned off via the accessibility.verbosity.automations setting.')); + lines.push(''); + + lines.push(nls.localize('automations.help.settingsHeader', 'Settings ({0} opens Settings):', describeCommand(keybindingService, 'workbench.action.openSettings') || 'Ctrl+,')); + lines.push(nls.localize('automations.help.settingVerbosity', '- `accessibility.verbosity.automations`: Controls whether this Accessibility Help hint is announced when the Automations section is focused.')); + lines.push(''); + + lines.push(nls.localize('automations.help.closingHeader', 'Closing this dialog:')); + lines.push(nls.localize('automations.help.closingDesc', 'Press Escape to close this dialog and return focus to the Automations list.')); + + return lines.join('\n'); +} + +function describeCommand(keybindingService: IKeybindingService, commandId: string): string | undefined { + const kb = keybindingService.lookupKeybinding(commandId); + return kb?.getAriaLabel() ?? undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts new file mode 100644 index 00000000000000..36b7cbcb592fef --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/automationsListWidget.ts @@ -0,0 +1,806 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/aiCustomizationManagement.css'; +import * as DOM from '../../../../../base/browser/dom.js'; +import { Button } from '../../../../../base/browser/ui/button/button.js'; +import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { IListRenderer, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Emitter } from '../../../../../base/common/event.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../base/common/observable.js'; +import { fromNow, getDurationString } from '../../../../../base/common/date.js'; +import * as resources from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { status } from '../../../../../base/browser/ui/aria/aria.js'; +import { IAutomation, IAutomationRun, AutomationRunStatus, AutomationRunTrigger } from '../../common/automations/automation.js'; +import { IAutomationRunner } from '../../common/automations/automationRunner.js'; +import { IAutomationService } from '../../common/automations/automationService.js'; +import { IAutomationDialogService } from '../../common/automations/automationDialogService.js'; +import { CHAT_AUTOMATIONS_ENABLED_SETTING } from '../../common/automations/automationsEnabled.js'; +import { DAYS_OF_WEEK } from '../../common/automations/schedule.js'; +import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; +import { openSession as openSessionFromOpener } from '../agentSessions/agentSessionsOpener.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; + +const $ = DOM.$; + +const AUTOMATION_ROW_HEIGHT = 72; +const HISTORY_ROW_HEIGHT = 28; +const HISTORY_HEADER_HEIGHT = 32; +const HISTORY_EMPTY_HEIGHT = 28; +const HISTORY_MORE_HEIGHT = 22; +const MAX_VISIBLE_RUNS = 20; + +interface IAutomationItemEntry { + readonly type: 'automation-item'; + readonly automation: IAutomation; + readonly runs: readonly IAutomationRun[]; + readonly expanded: boolean; + readonly inFlight: boolean; +} + +export type IAutomationListEntry = IAutomationItemEntry; + +interface IAutomationRowTemplateData { + readonly container: HTMLElement; + readonly row: HTMLElement; + readonly nameEl: HTMLElement; + readonly nameTextEl: HTMLElement; + readonly disabledBadge: HTMLElement; + readonly scheduleEl: HTMLElement; + readonly sep1: HTMLElement; + readonly nextEl: HTMLElement; + readonly sepFolder: HTMLElement; + readonly folderEl: HTMLElement; + readonly sep2: HTMLElement; + readonly lastEl: HTMLElement; + readonly promptEl: HTMLElement; + readonly actions: HTMLElement; + readonly historyPanel: HTMLElement; + readonly disposables: DisposableStore; +} + +class AutomationItemDelegate implements IListVirtualDelegate<IAutomationListEntry> { + // Initial estimate only. Actual row height is measured from the DOM because + // the list is created with `supportDynamicHeights` (meta wraps, prompt wraps, + // and run-error text is variable-height). See `hasDynamicHeight` below. + getHeight(element: IAutomationListEntry): number { + if (!element.expanded) { + return AUTOMATION_ROW_HEIGHT; + } + const runs = element.runs; + const visibleRuns = Math.min(runs.length, MAX_VISIBLE_RUNS); + if (visibleRuns === 0) { + return AUTOMATION_ROW_HEIGHT + HISTORY_EMPTY_HEIGHT; + } + let historyHeight = HISTORY_HEADER_HEIGHT + visibleRuns * HISTORY_ROW_HEIGHT; + if (runs.length > MAX_VISIBLE_RUNS) { + historyHeight += HISTORY_MORE_HEIGHT; + } + return AUTOMATION_ROW_HEIGHT + historyHeight; + } + + hasDynamicHeight(_element: IAutomationListEntry): boolean { + return true; + } + + getTemplateId(_element: IAutomationListEntry): string { + return 'automationItem'; + } +} + +class AutomationItemRenderer implements IListRenderer<IAutomationItemEntry, IAutomationRowTemplateData> { + readonly templateId = 'automationItem'; + + constructor( + private readonly widget: AutomationsListWidget, + private readonly hoverService: IHoverService, + private readonly notificationService: INotificationService, + private readonly editorService: IEditorService, + private readonly editorGroupsService: IEditorGroupsService, + private readonly logService: ILogService, + private readonly agentSessionsService: IAgentSessionsService, + private readonly instantiationService: IInstantiationService, + ) { } + + renderTemplate(container: HTMLElement): IAutomationRowTemplateData { + const disposables = new DisposableStore(); + container.classList.add('automations-row-wrapper'); + + const row = DOM.append(container, $('.automations-row')); + const main = DOM.append(row, $('.automations-row-main')); + const nameEl = DOM.append(main, $('.automations-row-name')); + const nameTextEl = DOM.append(nameEl, $('span.automations-row-name-text')); + const disabledBadge = DOM.append(nameEl, $('span.automations-row-disabled-badge')); + + const metaEl = DOM.append(main, $('.automations-row-meta')); + const scheduleEl = DOM.append(metaEl, $('span.automations-row-schedule')); + const sep1 = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const nextEl = DOM.append(metaEl, $('span.automations-row-next')); + const sepFolder = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const folderEl = DOM.append(metaEl, $('span.automations-row-folder')); + const sep2 = DOM.append(metaEl, $('span.automations-row-meta-sep')); + const lastEl = DOM.append(metaEl, $('span.automations-row-last')); + + const promptEl = DOM.append(main, $('.automations-row-prompt')); + const actions = DOM.append(row, $('.automations-row-actions')); + const historyPanel = DOM.append(container, $('.automations-row-history')); + + return { container, row, nameEl, nameTextEl, disabledBadge, scheduleEl, sep1, nextEl, sepFolder, folderEl, sep2, lastEl, promptEl, actions, historyPanel, disposables }; + } + + renderElement(element: IAutomationItemEntry, _index: number, templateData: IAutomationRowTemplateData): void { + templateData.disposables.clear(); + const { automation, runs, expanded, inFlight } = element; + + templateData.nameTextEl.textContent = automation.name; + templateData.row.classList.toggle('automations-row-disabled', !automation.enabled); + templateData.disabledBadge.textContent = !automation.enabled ? localize('automationDisabled', "Disabled") : ''; + templateData.disabledBadge.style.display = !automation.enabled ? '' : 'none'; + + templateData.scheduleEl.textContent = formatSchedule(automation); + templateData.sep1.textContent = '·'; + templateData.nextEl.textContent = formatNextRun(automation); + templateData.sepFolder.textContent = '·'; + const folderLabel = this.widget.formatFolderLabel(automation.folderUri); + templateData.folderEl.textContent = localize('automationFolderLabel', "in {0}", folderLabel); + templateData.folderEl.title = automation.folderUri.toString(); + + if (automation.lastRunAt) { + templateData.sep2.textContent = '·'; + templateData.sep2.style.display = ''; + templateData.lastEl.textContent = localize('lastRun', "Last run {0}", formatRelativeTimeOrIso(automation.lastRunAt)); + templateData.lastEl.style.display = ''; + } else { + templateData.sep2.style.display = 'none'; + templateData.lastEl.style.display = 'none'; + } + + templateData.promptEl.textContent = truncate(automation.prompt, 160); + templateData.promptEl.title = automation.prompt; + + templateData.disposables.add(DOM.addDisposableListener(templateData.row, 'click', (e) => { + if (DOM.isAncestor(e.target as HTMLElement, templateData.actions)) { + return; + } + this.widget.toggleExpanded(automation.id); + })); + + DOM.clearNode(templateData.actions); + templateData.disposables.add(DOM.addDisposableListener(templateData.actions, 'click', (e) => { + e.stopPropagation(); + })); + this.renderActions(templateData, automation, expanded, inFlight); + + DOM.clearNode(templateData.historyPanel); + templateData.historyPanel.id = `automation-history-${automation.id}`; + if (expanded) { + this.renderHistoryPanel(templateData, automation, runs); + } + templateData.historyPanel.style.display = expanded ? '' : 'none'; + } + + private renderActions(templateData: IAutomationRowTemplateData, automation: IAutomation, expanded: boolean, inFlight: boolean): void { + const { actions, disposables } = templateData; + + const runBtn = this.createIconButton(actions, Codicon.play, localize('runNow', "Run now"), inFlight, disposables); + disposables.add(DOM.addStandardDisposableListener(runBtn, 'click', () => { + void this.widget.runNow(automation); + })); + + const toggleIcon = automation.enabled ? Codicon.eye : Codicon.eyeClosed; + const toggleTooltip = automation.enabled ? localize('disableAutomation', "Disable") : localize('enableAutomation', "Enable"); + const toggleBtn = this.createIconButton(actions, toggleIcon, toggleTooltip, false, disposables); + disposables.add(DOM.addStandardDisposableListener(toggleBtn, 'click', () => { + void this.widget.toggleEnabled(automation); + })); + + const editBtn = this.createIconButton(actions, Codicon.edit, localize('editAutomation', "Edit"), false, disposables); + disposables.add(DOM.addStandardDisposableListener(editBtn, 'click', () => { + void this.widget.openEditDialog(automation); + })); + + const deleteBtn = this.createIconButton(actions, Codicon.trash, localize('deleteAutomation', "Delete"), false, disposables); + disposables.add(DOM.addStandardDisposableListener(deleteBtn, 'click', () => { + void this.widget.deleteAutomation(automation); + })); + + const histIcon = expanded ? Codicon.chevronDown : Codicon.chevronRight; + const histTooltip = expanded ? localize('hideHistory', "Hide history") : localize('showHistory', "Show history"); + const histBtn = this.createIconButton(actions, histIcon, histTooltip, false, disposables); + histBtn.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + histBtn.setAttribute('aria-controls', `automation-history-${automation.id}`); + disposables.add(DOM.addStandardDisposableListener(histBtn, 'click', () => { + this.widget.toggleExpanded(automation.id); + })); + } + + private renderHistoryPanel(templateData: IAutomationRowTemplateData, automation: IAutomation, runs: readonly IAutomationRun[]): void { + const panel = templateData.historyPanel; + panel.setAttribute('role', 'region'); + panel.setAttribute('aria-label', localize('historyAriaLabel', "Run history for {0}", automation.name)); + + if (runs.length === 0) { + const empty = DOM.append(panel, $('.automations-history-empty')); + empty.textContent = localize('noRunsYet', "No runs yet."); + return; + } + + const heading = DOM.append(panel, $('h4.automations-history-heading')); + heading.textContent = localize('runHistory', "Run history"); + + const runsList = DOM.append(panel, $('ul.automations-history-list')); + const visibleRuns = runs.slice(0, MAX_VISIBLE_RUNS); + for (const run of visibleRuns) { + this.renderRunRow(runsList, run, templateData.disposables); + } + if (runs.length > MAX_VISIBLE_RUNS) { + const more = DOM.append(panel, $('.automations-history-more')); + more.textContent = localize('historyMore', "{0} more run(s) not shown.", runs.length - visibleRuns.length); + } + } + + private renderRunRow(container: HTMLElement, run: IAutomationRun, disposables: DisposableStore): void { + const li = DOM.append(container, $('li.automations-history-row', { + 'data-run-id': run.id, + 'data-run-status': run.status, + })); + + const statusIcon = DOM.append(li, $('span.automations-history-status.codicon')); + const { iconId, spin } = runStatusIcon(run.status); + statusIcon.classList.add(`codicon-${iconId}`); + if (spin) { + statusIcon.classList.add('codicon-modifier-spin'); + } + statusIcon.setAttribute('aria-hidden', 'true'); + + const text = DOM.append(li, $('.automations-history-row-text')); + const first = DOM.append(text, $('.automations-history-row-first')); + const statusLabel = DOM.append(first, $('span.automations-history-row-status')); + statusLabel.textContent = runStatusLabel(run.status); + const sep = DOM.append(first, $('span.automations-history-row-sep')); + sep.textContent = '·'; + const trig = DOM.append(first, $('span.automations-history-row-trigger')); + trig.textContent = runTriggerLabel(run.trigger); + const sep2 = DOM.append(first, $('span.automations-history-row-sep')); + sep2.textContent = '·'; + const started = DOM.append(first, $('span.automations-history-row-started')); + started.textContent = localize('runStarted', "Started {0}", formatRelativeTimeOrIso(run.startedAt)); + const dur = formatRunDuration(run); + if (dur) { + const sep3 = DOM.append(first, $('span.automations-history-row-sep')); + sep3.textContent = '·'; + const durEl = DOM.append(first, $('span.automations-history-row-duration')); + durEl.textContent = dur; + } + + if (run.errorMessage) { + const err = DOM.append(text, $('.automations-history-row-error')); + err.textContent = run.errorMessage; + err.setAttribute('role', 'status'); + err.setAttribute('aria-live', 'polite'); + } + + if (run.sessionResource) { + const openButton = DOM.append(li, $('span.automations-history-row-open.codicon.codicon-link-external')); + openButton.setAttribute('role', 'button'); + openButton.setAttribute('tabindex', '0'); + openButton.setAttribute('aria-label', localize('openRunSession', "Open session")); + disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), openButton, localize('openRunSession', "Open session"))); + const openSession = (e: Event) => { + e.stopPropagation(); + const sessionResource = URI.parse(run.sessionResource!); + this.logService.debug(`[AutomationsListWidget] Opening session: ${sessionResource.toString()}`); + const activeEditor = this.editorService.activeEditor; + const activeGroupId = this.editorGroupsService.activeGroup.id; + const agentSession = this.agentSessionsService.getSession(sessionResource); + if (!agentSession) { + this.logService.warn(`[AutomationsListWidget] Session not found for ${sessionResource.toString()}`); + this.notificationService.error(localize('openRunSessionFailed', "Failed to open automation session")); + return; + } + this.instantiationService.invokeFunction(openSessionFromOpener, agentSession).then(() => { + if (activeEditor) { + this.editorService.closeEditor({ editor: activeEditor, groupId: activeGroupId }); + } + }).catch((err) => { + this.logService.error(`[AutomationsListWidget] openSession failed for ${sessionResource.toString()}`, err); + this.notificationService.error(localize('openRunSessionFailed', "Failed to open automation session")); + }); + }; + disposables.add(DOM.addDisposableListener(openButton, 'click', openSession)); + disposables.add(DOM.addDisposableListener(openButton, 'keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openSession(e); + } + })); + } + } + + private createIconButton(container: HTMLElement, icon: ThemeIcon, tooltip: string, disabled: boolean, disposables: DisposableStore): HTMLElement { + const button = DOM.append(container, $('button.automations-row-action-button', { + type: 'button', + 'aria-label': tooltip, + tabindex: '0', + })) as HTMLButtonElement; + button.classList.add(...ThemeIcon.asClassNameArray(icon)); + button.disabled = disabled; + disposables.add(this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), button, tooltip)); + return button; + } + + disposeTemplate(templateData: IAutomationRowTemplateData): void { + templateData.disposables.dispose(); + } +} + +/** + * Widget that renders the Automations section of the AI Customization editor + * using a virtualized {@link WorkbenchList}. + */ +export class AutomationsListWidget extends Disposable { + + readonly element: HTMLElement; + + private readonly _onDidChangeItemCount = this._register(new Emitter<number>()); + readonly onDidChangeItemCount = this._onDidChangeItemCount.event; + + private readonly headerEl: HTMLElement; + private readonly listContainer: HTMLElement; + private readonly emptyContainer: HTMLElement; + private list!: WorkbenchList<IAutomationListEntry>; + + private readonly newButtonHover = this._register(new MutableDisposable()); + private readonly newEmptyStateButtonHover = this._register(new MutableDisposable()); + private readonly _emptyStateStore = this._register(new DisposableStore()); + + private readonly runInFlight = new Set<string>(); + private readonly expandedRows = new Set<string>(); + private displayEntries: IAutomationListEntry[] = []; + + private lastHeight = 0; + private lastWidth = 0; + private _layoutDeferred = false; + private readonly _layoutRAF = this._register(new MutableDisposable()); + + constructor( + @IAutomationService private readonly automationService: IAutomationService, + @IAutomationRunner private readonly automationRunner: IAutomationRunner, + @IDialogService private readonly dialogService: IDialogService, + @IAutomationDialogService private readonly automationDialogService: IAutomationDialogService, + @IHoverService private readonly hoverService: IHoverService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @ILogService private readonly logService: ILogService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @INotificationService private readonly notificationService: INotificationService, + @IEditorService private readonly editorService: IEditorService, + @IEditorGroupsService private readonly editorGroupsService: IEditorGroupsService, + @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, + ) { + super(); + + this.element = $('.automations-list-widget'); + this.headerEl = DOM.append(this.element, $('.automations-header')); + this.emptyContainer = DOM.append(this.element, $('.automations-empty-state')); + this.emptyContainer.style.display = 'none'; + this.listContainer = DOM.append(this.element, $('.automations-list')); + + this.renderHeader(); + this.createList(); + + this._register(autorun(reader => { + const items = this.automationService.automations.read(reader); + this.automationService.runs.read(reader); + this.updateList(items); + this._onDidChangeItemCount.fire(items.length); + })); + } + + private renderHeader(): void { + const titleRow = DOM.append(this.headerEl, $('.automations-header-row')); + const titleEl = DOM.append(titleRow, $('h2.automations-header-title')); + titleEl.textContent = localize('automationsHeaderTitle', "Automations"); + const subtitleEl = DOM.append(this.headerEl, $('p.automations-header-subtitle')); + subtitleEl.textContent = localize('automationsHeaderSubtitle', "Schedule agent sessions to run on a cadence you choose."); + + const newButton = this._register(new Button(titleRow, { ...defaultButtonStyles, title: localize('newAutomation', "New automation") })); + newButton.label = localize('newAutomation', "New automation"); + newButton.element.classList.add('automations-new-button'); + this._register(newButton.onDidClick(() => this.openCreateDialog())); + this.newButtonHover.value = this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), newButton.element, localize('newAutomationTooltip', "Create a new automation")); + } + + private createList(): void { + const delegate = new AutomationItemDelegate(); + const renderer = new AutomationItemRenderer(this, this.hoverService, this.notificationService, this.editorService, this.editorGroupsService, this.logService, this.agentSessionsService, this.instantiationService); + + this.list = this._register(this.instantiationService.createInstance( + WorkbenchList<IAutomationListEntry>, + 'AutomationsManagementList', + this.listContainer, + delegate, + [renderer], + { + multipleSelectionSupport: false, + setRowLineHeight: false, + supportDynamicHeights: true, + horizontalScrolling: false, + accessibilityProvider: { + getAriaLabel: (element: IAutomationListEntry) => { + const a = element.automation; + return a.enabled + ? localize('automationAriaLabel', "{0}, {1}", a.name, formatSchedule(a)) + : localize('automationAriaLabelDisabled', "{0}, disabled", a.name); + }, + getWidgetAriaLabel() { + return localize('automationsListAriaLabel', "Automations"); + } + }, + identityProvider: { + getId(element: IAutomationListEntry) { + return element.automation.id; + } + } + } + )); + + this._register(this.list.onDidChangeSelection(() => { + if (this.list.getSelection().length > 0) { + this.list.setSelection([]); + } + })); + } + + private updateList(items: readonly IAutomation[]): void { + if (items.length === 0) { + this.element.classList.add('automations-empty'); + this.emptyContainer.style.display = ''; + this.listContainer.style.display = 'none'; + this.renderEmptyState(); + this.displayEntries = []; + this.list.splice(0, this.list.length, []); + return; + } + + this.element.classList.remove('automations-empty'); + this.emptyContainer.style.display = 'none'; + this.listContainer.style.display = ''; + this.newEmptyStateButtonHover.clear(); + + this.displayEntries = items.map(automation => ({ + type: 'automation-item' as const, + automation, + runs: this.automationService.runsFor(automation.id).get(), + expanded: this.expandedRows.has(automation.id), + inFlight: this.runInFlight.has(automation.id), + })); + + this.list.splice(0, this.list.length, this.displayEntries); + } + + private renderEmptyState(): void { + this._emptyStateStore.clear(); + DOM.clearNode(this.emptyContainer); + this.emptyContainer.setAttribute('role', 'status'); + const title = DOM.append(this.emptyContainer, $('h3.automations-empty-title')); + title.textContent = localize('automationsEmptyTitle', "No automations yet"); + const message = DOM.append(this.emptyContainer, $('p.automations-empty-message')); + message.textContent = localize('automationsEmptyMessage', "Create an automation to schedule an agent session to run on a cadence you choose."); + + const ctaButton = this._emptyStateStore.add(new Button(this.emptyContainer, { ...defaultButtonStyles })); + ctaButton.label = localize('automationsEmptyCta', "Create automation"); + ctaButton.element.classList.add('automations-empty-cta'); + this._emptyStateStore.add(ctaButton.onDidClick(() => this.openCreateDialog())); + this.newEmptyStateButtonHover.value = this.hoverService.setupManagedHover(getDefaultHoverDelegate('element'), ctaButton.element, localize('newAutomationTooltip', "Create a new automation")); + } + + toggleExpanded(automationId: string): void { + if (this.expandedRows.has(automationId)) { + this.expandedRows.delete(automationId); + } else { + this.expandedRows.add(automationId); + } + this.updateList(this.automationService.automations.get()); + } + + async runNow(automation: IAutomation): Promise<void> { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + if (this.runInFlight.has(automation.id)) { + return; + } + this.runInFlight.add(automation.id); + this.updateList(this.automationService.automations.get()); + const previousRunId = this.automationService.runsFor(automation.id).get()[0]?.id; + try { + // The runner does not support cancellation yet. + await this.automationRunner.runOnce(automation, 'manual', 0, CancellationToken.None); + const latestRun = this.automationService.runsFor(automation.id).get()[0]; + if (latestRun && latestRun.id !== previousRunId && latestRun.status !== 'failed') { + status(localize('automationStartedStatus', "Started automation {0}", automation.name)); + } + } catch (err) { + this.logService.error('[Automations] runNow failed unexpectedly', err); + } finally { + this.runInFlight.delete(automation.id); + this.updateList(this.automationService.automations.get()); + } + } + + async toggleEnabled(automation: IAutomation): Promise<void> { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.updateAutomation(automation.id, { enabled: !automation.enabled }); + status(automation.enabled + ? localize('automationDisabledStatus', "Disabled automation {0}", automation.name) + : localize('automationEnabledStatus', "Enabled automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to toggle automation', err); + } + } + + async deleteAutomation(automation: IAutomation): Promise<void> { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.dialogService.confirm({ + type: 'warning', + message: localize('confirmDeleteAutomation', "Delete automation \u201C{0}\u201D?", automation.name), + detail: localize('confirmDeleteAutomationDetail', "Runs already in flight will continue. This cannot be undone."), + primaryButton: localize('delete', "Delete"), + }); + if (!result.confirmed) { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.deleteAutomation(automation.id); + status(localize('automationDeletedStatus', "Deleted automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to delete automation', err); + } + } + + async openEditDialog(automation: IAutomation): Promise<void> { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.automationDialogService.showAutomationDialog({ + existing: automation, + }); + if (!result || result.kind !== 'update') { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + await this.automationService.updateAutomation(result.id, result.value); + status(localize('automationUpdatedStatus', "Updated automation {0}", automation.name)); + } catch (err) { + this.logService.error('[Automations] Failed to update automation', err); + await this.dialogService.error( + localize('automationUpdateFailed', "Failed to update automation."), + err instanceof Error ? err.message : String(err), + ); + } + } + + formatFolderLabel(folderUri: URI): string { + const folders = this.workspaceContextService.getWorkspace().folders; + const match = folders.find(f => resources.isEqual(f.uri, folderUri)); + if (match) { + return match.name || match.uri.toString(); + } + const segments = folderUri.path.split('/').filter(s => s.length > 0); + return segments[segments.length - 1] ?? folderUri.toString(); + } + + private _isEnabled(): boolean { + return this.configurationService.getValue<boolean>(CHAT_AUTOMATIONS_ENABLED_SETTING) === true; + } + + private async _notifyDisabled(): Promise<void> { + await this.dialogService.info( + localize('automationsDisabledTitle', "Automations are disabled."), + localize('automationsDisabledDetail', "Enable \u201C{0}\u201D to make changes.", CHAT_AUTOMATIONS_ENABLED_SETTING), + ); + } + + private async openCreateDialog(): Promise<void> { + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + const result = await this.automationDialogService.showAutomationDialog({}); + if (!result || result.kind !== 'create') { + return; + } + if (!this._isEnabled()) { + await this._notifyDisabled(); + return; + } + try { + const created = await this.automationService.createAutomation(result.value); + status(localize('automationCreatedStatus', "Created automation {0}", created.name)); + } catch (err) { + this.logService.error('[Automations] Failed to create automation', err); + await this.dialogService.error( + localize('automationCreateFailed', "Failed to create automation."), + err instanceof Error ? err.message : String(err), + ); + } + } + + layout(height: number, width: number): void { + this.lastHeight = height; + this.lastWidth = width; + + this.element.style.height = `${height}px`; + + // Measure the header to calculate the list height. + // When offsetHeight returns 0 the container may have just become visible + // after display:none and the browser hasn't reflowed yet. Defer layout + // once so measurements are accurate. Only retry once to avoid an endless + // loop when the widget is created while permanently hidden. + const headerHeight = this.headerEl.offsetHeight; + if (headerHeight === 0 && !this._layoutDeferred) { + this._layoutDeferred = true; + this._layoutRAF.value = DOM.scheduleAtNextAnimationFrame(DOM.getWindow(this.element), () => { + this._layoutDeferred = false; + this.layout(this.lastHeight, this.lastWidth); + }); + return; + } + const listHeight = Math.max(0, height - headerHeight); + + this.listContainer.style.height = `${listHeight}px`; + this.list.layout(listHeight, width); + } + + fireItemCount(): void { + this._onDidChangeItemCount.fire(this.automationService.automations.get().length); + } + + /** Test-only: number of rows currently in the virtualized list. */ + get itemCount(): number { + return this.list.length; + } + + /** + * Test-only: snapshot of the view-model rows the list is displaying. + * The virtualized {@link WorkbenchList} does not lay out rows in a unit-test + * DOM, so tests assert the derived render state (expansion, runs, in-flight) + * here instead of querying row elements. + */ + getDisplayEntriesForTest(): readonly IAutomationListEntry[] { + return this.displayEntries; + } + + focus(): void { + if (this.list.length > 0) { + this.list.domFocus(); + } + } +} + +function formatSchedule(a: IAutomation): string { + switch (a.schedule.interval) { + case 'manual': + return localize('scheduleManual', "Manual"); + case 'hourly': + return localize('scheduleHourly', "Hourly"); + case 'daily': + return localize('scheduleDaily', "Daily at {0}", formatHourMinute(a.schedule.scheduleHour, a.schedule.scheduleMinute)); + case 'weekly': { + const day = dayName(a.schedule.scheduleDay); + return localize('scheduleWeekly', "Weekly on {0} at {1}", day, formatHourMinute(a.schedule.scheduleHour, a.schedule.scheduleMinute)); + } + } +} + +function formatHourMinute(hour: number, minute: number): string { + const date = new Date(); + date.setHours(Math.max(0, Math.min(23, hour | 0)), Math.max(0, Math.min(59, minute | 0)), 0, 0); + return date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); +} + +function dayName(day: number): string { + const idx = ((day % 7) + 7) % 7; + return DAYS_OF_WEEK[idx]; +} + +function formatNextRun(a: IAutomation): string { + if (a.schedule.interval === 'manual' || !a.nextRunAt) { + return localize('nextRunNever', "No scheduled run"); + } + return localize('nextRun', "Next run {0}", formatRelativeTimeOrIso(a.nextRunAt)); +} + +function formatRelativeTimeOrIso(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) { + return iso; + } + const date = new Date(t); + const rel = fromNow(date, true); + const absolute = date.toLocaleString(); + return `${rel} (${absolute})`; +} + +function truncate(s: string, max: number): string { + const single = s.replace(/\s+/g, ' ').trim(); + if (single.length <= max) { + return single; + } + return single.slice(0, Math.max(0, max - 1)) + '\u2026'; +} + +function runStatusIcon(status: AutomationRunStatus): { iconId: string; spin: boolean } { + switch (status) { + case 'pending': return { iconId: 'circle-outline', spin: false }; + case 'running': return { iconId: 'sync', spin: true }; + case 'completed': return { iconId: 'check', spin: false }; + case 'failed': return { iconId: 'error', spin: false }; + } +} + +function runStatusLabel(status: AutomationRunStatus): string { + switch (status) { + case 'pending': return localize('runStatusPending', "Pending"); + case 'running': return localize('runStatusRunning', "Running"); + case 'completed': return localize('runStatusCompleted', "Completed"); + case 'failed': return localize('runStatusFailed', "Failed"); + } +} + +function runTriggerLabel(trigger: AutomationRunTrigger): string { + switch (trigger) { + case 'schedule': return localize('runTriggerSchedule', "Scheduled"); + case 'manual': return localize('runTriggerManual', "Manual"); + case 'catch_up': return localize('runTriggerCatchUp', "Catch-up"); + } +} + +function formatRunDuration(run: IAutomationRun): string | undefined { + if (!run.completedAt) { + return undefined; + } + const startMs = Date.parse(run.startedAt); + const endMs = Date.parse(run.completedAt); + if (Number.isNaN(startMs) || Number.isNaN(endMs)) { + return undefined; + } + const durationMs = Math.max(0, endMs - startMs); + return getDurationString(durationMs); +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts index 04d78eaf6296b0..3ff88b2360e218 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/customizationCreatorService.ts @@ -11,15 +11,15 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { getPromptFileDefaultLocations } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; -import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IQuickInputService, IQuickPickItem } from '../../../../../platform/quickinput/common/quickInput.js'; import { localize } from '../../../../../nls.js'; -import { ICustomizationHarnessService, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; -import { ResourceSet } from '../../../../../base/common/map.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; /** * Service that opens an AI-guided chat session to help the user create @@ -38,12 +38,15 @@ export class CustomizationCreatorService { @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IPromptsService private readonly promptsService: IPromptsService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, - @IInstantiationService private readonly instantiationService: IInstantiationService ) { } async createWithAI(type: PromptsType): Promise<void> { + const currentSessionResource = this.harnessService.activeSessionResource.get(); + + // Ask for the name before entering chat const typeLabel = getTypeLabel(type); const name = await this.quickInputService.input({ @@ -67,7 +70,12 @@ export class CustomizationCreatorService { // directory and have those changes tracked. // Capture project root BEFORE opening new chat (which may change active session) - const targetDir = await this.resolveTargetDirectoryWithPicker(type); + const picker = this.instantiationService.createInstance(CustomizationLocationPicker); + const targetDir = await picker.resolveTargetDirectoryWithPicker( + currentSessionResource, + type, + 'local', + ); if (targetDir === null) { return; // User cancelled the picker } @@ -108,6 +116,23 @@ export class CustomizationCreatorService { return resolveWorkspaceTargetDirectory(this.workspaceService, type); } + /** + * Resolves the user-level directory for a new customization file. + */ + async resolveUserDirectory(type: PromptsType): Promise<URI | undefined> { + return resolveUserTargetDirectory(this.promptsService, type); + } +} + + +export class CustomizationLocationPicker { + constructor( + @IQuickInputService private readonly quickInputService: IQuickInputService, + @ICustomizationHarnessService private readonly harnessService: ICustomizationHarnessService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ILabelService private readonly labelService: ILabelService + ) { } + /** * Resolves the target directory for creating a new customization file. * If multiple source folders exist for the given storage type, shows a @@ -120,10 +145,10 @@ export class CustomizationCreatorService { * @returns the resolved URI, `undefined` when no folder is available, * or `null` when the user cancelled the picker. */ - private async resolveTargetDirectoryWithPicker(type: PromptsType): Promise<URI | undefined | null> { - const sessionResource = this.harnessService.activeSessionResource.get(); - const activeDescriptor = this.harnessService.getActiveDescriptor(); - const provider = activeDescriptor.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider, () => activeDescriptor); + public async resolveTargetDirectoryWithPicker(sessionResource: URI, type: PromptsType, target: 'local' | 'user'): Promise<URI | undefined | null> { + const sessionType = getChatSessionType(sessionResource); + const descriptor = this.harnessService.findHarnessById(sessionType); + const provider = descriptor?.itemProvider ?? this.instantiationService.createInstance(PromptsServiceCustomizationItemProvider); if (!provider.provideSourceFolders) { return undefined; } @@ -133,30 +158,21 @@ export class CustomizationCreatorService { return undefined; } - const projectRoot = this.workspaceService.getActiveProjectRoot(); - const matchingFolders: ICustomizationSourceFolder[] = []; - const hasSeen = new ResourceSet(); - for (const f of allFolders) { - if (projectRoot && isEqualOrParent(f.uri, projectRoot) && !hasSeen.has(f.uri)) { - hasSeen.add(f.uri); - matchingFolders.push(f); - } - } - + const matchingFolders = allFolders.filter(f => f.source === target); if (matchingFolders.length === 0) { // No matching folders — return undefined so the command can fall // back to askForPromptSourceFolder (not null which means cancellation) return undefined; } - if (matchingFolders.length === 1) { - return matchingFolders[0].uri; - } + // if (matchingFolders.length === 1) { + // return matchingFolders[0].uri; + // } // Multiple directories — ask the user which one to use const items: (IQuickPickItem & { uri: URI })[] = matchingFolders.map(folder => ({ label: folder.label, - description: folder.uri.fsPath, + description: this.labelService.getUriLabel(folder.uri, { relative: true }), uri: folder.uri, })); @@ -166,13 +182,6 @@ export class CustomizationCreatorService { return picked?.uri ?? null; } - - /** - * Resolves the user-level directory for a new customization file. - */ - async resolveUserDirectory(type: PromptsType): Promise<URI | undefined> { - return resolveUserTargetDirectory(this.promptsService, type); - } } /** diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index e47164c162b020..e4a6487197b6a7 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -32,7 +32,7 @@ import { IContextMenuService, IContextViewService } from '../../../../../platfor import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Delayer } from '../../../../../base/common/async.js'; import { Action, IAction, Separator } from '../../../../../base/common/actions.js'; -import { getContextMenuActions } from '../../../../contrib/mcp/browser/mcpServerActions.js'; +import { ConfigureModelAccessAction, getContextMenuActions, RestartServerAction, ShowSamplingRequestsAction, StartServerAction, StopServerAction } from '../../../../contrib/mcp/browser/mcpServerActions.js'; import { LocalMcpServerScope } from '../../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -426,8 +426,40 @@ function getRuntimeServerMatchKeys(server: IMcpServer): string[] { return getUniqueMcpMatchKeys([server.definition.id, server.definition.label]); } -function getActiveSessionServerOptionsAction(commandService: ICommandService, sessionResource: URI, server: AgentHostMcpServer): Action { - return new Action( +function getActiveSessionServerLifecycleAction(server: AgentHostMcpServer): Action | undefined { + if (!server.enabled) { + return undefined; + } + return server.status === McpServerStatus.Stopped || server.status === McpServerStatus.Error + ? new Action( + 'mcpServer.activeSession.start', + localize('activeSessionMcpServerStart', "Start Server"), + undefined, + true, + () => server.start() + ) + : new Action( + 'mcpServer.activeSession.stop', + localize('activeSessionMcpServerStop', "Stop Server"), + undefined, + true, + () => server.stop() + ); +} + +function getActiveSessionServerOptionsActions(commandService: ICommandService, sessionResource: URI, server: AgentHostMcpServer): IAction[] { + const lifecycleAction = getActiveSessionServerLifecycleAction(server); + const enablementAction = new Action( + server.enabled ? 'mcpServer.activeSession.disable' : 'mcpServer.activeSession.enable', + server.enabled ? localize('activeSessionMcpServerDisable', "Disable Server") : localize('activeSessionMcpServerEnable', "Enable Server"), + undefined, + true, + () => { + server.setEnabled(!server.enabled); + return Promise.resolve(); + } + ); + const optionsAction = new Action( 'mcpServer.activeSession.options', localize('activeSessionMcpServerOptions', "Server Options"), undefined, @@ -436,6 +468,15 @@ function getActiveSessionServerOptionsAction(commandService: ICommandService, se await commandService.executeCommand(McpCommandIds.AgentHostServerOptions, sessionResource, server.id); } ); + return lifecycleAction ? [lifecycleAction, enablementAction, optionsAction] : [enablementAction, optionsAction]; +} + +function shouldHideLocalActionForActiveSessionServer(action: IAction): boolean { + return action instanceof StartServerAction + || action instanceof StopServerAction + || action instanceof RestartServerAction + || action instanceof ConfigureModelAccessAction + || action instanceof ShowSamplingRequestsAction; } function createBuiltinEntry(server: IMcpServer, activeSessionServer?: AgentHostMcpServer): IMcpBuiltinItemEntry { @@ -1234,10 +1275,11 @@ export class McpListWidget extends Disposable { if (e.element.type === 'session-server-item') { const disposables = new DisposableStore(); - const optionsAction = disposables.add(getActiveSessionServerOptionsAction(this.commandService, this.customizationHarnessService.activeSessionResource.get(), e.element.server)); + const activeSessionActions = getActiveSessionServerOptionsActions(this.commandService, this.customizationHarnessService.activeSessionResource.get(), e.element.server); + activeSessionActions.forEach(action => isDisposable(action) && disposables.add(action)); this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [optionsAction], + getActions: () => activeSessionActions, onHide: () => disposables.dispose(), }); return; @@ -1247,52 +1289,66 @@ export class McpListWidget extends Disposable { if (e.element.type === 'builtin-item') { const collectionId = e.element.collectionId; const pluginUriStr = getPluginUriFromCollectionId(collectionId); - if (!pluginUriStr) { + if (!pluginUriStr && !e.element.activeSessionServer) { return; } - const plugin = this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr); - if (!plugin) { + const plugin = pluginUriStr ? this.agentPluginService.plugins.get().find(p => p.uri.toString() === pluginUriStr) : undefined; + if (!plugin && !e.element.activeSessionServer) { return; } const disposables = new DisposableStore(); - const showPluginAction = disposables.add(new Action( - 'mcpServer.showPlugin', - localize('showPlugin', "Show Plugin"), - undefined, - true, - async () => { - const item = { - kind: AgentPluginItemKind.Installed as const, - name: plugin.label, - description: plugin.fromMarketplace?.description ?? '', - marketplace: plugin.fromMarketplace?.marketplace, - plugin, - }; - this._onDidRequestShowPlugin.fire(item); + const actions: IAction[] = []; + const lifecycleAction = e.element.activeSessionServer ? getActiveSessionServerLifecycleAction(e.element.activeSessionServer) : undefined; + if (lifecycleAction) { + actions.push(disposables.add(lifecycleAction)); + } + if (plugin) { + if (actions.length > 0) { + actions.push(new Separator()); } - )); - const uninstallAction = disposables.add(new Action( - 'mcpServer.uninstallPlugin', - localize('uninstallPlugin', "Uninstall Plugin"), - undefined, - true, - async () => { - const result = await this.dialogService.confirm({ - message: localize('confirmUninstallPluginMcp', "This MCP server is provided by the plugin '{0}'", plugin.label), - detail: localize('confirmUninstallPluginMcpDetail', "Individual MCP servers from a plugin cannot be removed separately. Would you like to uninstall the entire plugin?"), - primaryButton: localize('uninstallPluginBtn', "Uninstall Plugin"), - type: 'question', - }); - if (result.confirmed) { - plugin.remove?.(); + actions.push(disposables.add(new Action( + 'mcpServer.showPlugin', + localize('showPlugin', "Show Plugin"), + undefined, + true, + async () => { + const item = { + kind: AgentPluginItemKind.Installed as const, + name: plugin.label, + description: plugin.fromMarketplace?.description ?? '', + marketplace: plugin.fromMarketplace?.marketplace, + plugin, + }; + this._onDidRequestShowPlugin.fire(item); } - } - )); + ))); + actions.push(disposables.add(new Action( + 'mcpServer.uninstallPlugin', + localize('uninstallPlugin', "Uninstall Plugin"), + undefined, + true, + async () => { + const result = await this.dialogService.confirm({ + message: localize('confirmUninstallPluginMcp', "This MCP server is provided by the plugin '{0}'", plugin.label), + detail: localize('confirmUninstallPluginMcpDetail', "Individual MCP servers from a plugin cannot be removed separately. Would you like to uninstall the entire plugin?"), + primaryButton: localize('uninstallPluginBtn', "Uninstall Plugin"), + type: 'question', + }); + if (result.confirmed) { + plugin.remove?.(); + } + } + ))); + } + if (actions.length === 0) { + disposables.dispose(); + return; + } this.contextMenuService.showContextMenu({ getAnchor: () => e.anchor, - getActions: () => [showPluginAction, uninstallAction], + getActions: () => actions, onHide: () => disposables.dispose(), }); return; @@ -1309,14 +1365,26 @@ export class McpListWidget extends Disposable { // Get context menu actions from the MCP module const groups: IAction[][] = getContextMenuActions(mcpServer, false, this.instantiationService); const actions: IAction[] = []; + const activeSessionLifecycleAction = serverEntry.activeSessionServer ? getActiveSessionServerLifecycleAction(serverEntry.activeSessionServer) : undefined; + if (activeSessionLifecycleAction) { + disposables.add(activeSessionLifecycleAction); + actions.push(activeSessionLifecycleAction, new Separator()); + } for (const menuActions of groups) { for (const menuAction of menuActions) { - actions.push(menuAction); if (isDisposable(menuAction)) { disposables.add(menuAction); } } - actions.push(new Separator()); + const visibleMenuActions = serverEntry.activeSessionServer + ? menuActions.filter(action => !shouldHideLocalActionForActiveSessionServer(action)) + : menuActions; + for (const menuAction of visibleMenuActions) { + actions.push(menuAction); + } + if (visibleMenuActions.length > 0) { + actions.push(new Separator()); + } } // Remove trailing separator if (actions.length > 0 && actions[actions.length - 1] instanceof Separator) { 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 ab344fb4a8c467..c235ca03e96b41 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/media/aiCustomizationManagement.css @@ -11,6 +11,13 @@ overflow: hidden; } +/* Inset button focus rings so they aren't clipped by a widget's overflow:hidden edge. !important overrides button.css. */ +.ai-customization-management-editor .monaco-button:focus, +.ai-customization-list-widget .monaco-button:focus, +.mcp-list-widget .monaco-button:focus { + outline-offset: -1px !important; +} + /* Sidebar */ .ai-customization-management-editor .management-sidebar { height: 100%; @@ -654,6 +661,13 @@ text-decoration: underline; } +/* Inset the focus ring so it isn't clipped by the widget's overflow:hidden at the header's edge. */ +.ai-customization-list-widget .section-title-header .section-title-link:focus-visible, +.mcp-list-widget .section-title-header .section-title-link:focus-visible, +.ai-customization-management-editor .tools-list-widget .section-title-header .section-title-link:focus-visible { + outline-offset: -1px; +} + /* Section footer at bottom of list widget */ .ai-customization-list-widget .section-footer { flex-shrink: 0; @@ -761,7 +775,8 @@ .ai-customization-management-editor .mcp-content-container, .ai-customization-management-editor .plugin-content-container, .ai-customization-management-editor .models-content-container, -.ai-customization-management-editor .tools-content-container { +.ai-customization-management-editor .tools-content-container, +.ai-customization-management-editor .automations-content-container { height: 100%; display: flex; flex-direction: column; @@ -1454,14 +1469,6 @@ display: flex; gap: 4px; flex-shrink: 0; - /* Horizontal-only padding so focused button outlines do not clip against */ - /* the widget's overflow:hidden. Vertical padding is omitted so the button */ - /* group does not make the search row taller than on other sections. */ - padding: 0 1px; -} - -.mcp-list-widget .list-button-group .monaco-button:focus-visible { - outline-offset: -1px; } /* Embedded Editor View */ @@ -1772,3 +1779,1083 @@ pane is first mounted. View switches inside the modal are not animated. */ } /* Welcome page styles live in the welcome page variant stylesheets. */ + + +/* Automations section + * + * The outer `.automations-content-container` shares the panel-surface + * styling with the prompts / MCP / plugin / models containers (see the + * shared rule above near line 758) so the Automations tab visually + * matches its siblings: 40px panel padding, agentsPanel background, + * rounded border. The list-widget below fills that panel. + * + * No standalone rule is needed here. The shared selector covers it. */ + +.automations-list-widget { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.automations-list-widget .automations-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + gap: 8px; + text-align: center; + flex: 1; +} + +.automations-list-widget .automations-empty-state .automations-empty-title { + font-size: 16px; + font-weight: 500; + color: var(--vscode-foreground); + margin: 0; +} + +.automations-list-widget .automations-empty-state .automations-empty-message { + font-size: var(--vscode-agents-fontSize-body1); + color: var(--vscode-descriptionForeground); + max-width: 320px; + line-height: 1.4; + margin: 0; +} + +.automations-list-widget .automations-empty-state .automations-empty-cta { + margin-top: 16px; + min-width: 160px; + align-self: center; +} + +/* Header. This mirrors `.section-title-header` on the prompts side so the + * Automations tab reads with the same vertical rhythm and underline + * treatment as Agents / Skills / Prompts / Instructions. Horizontal + * padding is absent because the parent `.automations-content-container` + * already owns 40px panel padding. */ +.automations-list-widget .automations-header { + flex-shrink: 0; + padding: 0 0 32px; + border-bottom: 1px solid var(--vscode-panel-border); +} + +.automations-list-widget .automations-header-row { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 8px 16px; + flex-wrap: wrap; +} + +.automations-list-widget .automations-header-title { + font-size: var(--vscode-agents-fontSize-heading2); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); + margin: 0; + min-width: 0; +} + +.automations-list-widget .automations-header-subtitle { + font-size: var(--vscode-agents-fontSize-body1); + line-height: 1.45; + color: var(--vscode-descriptionForeground); + margin: 0 0 8px 0; + /* Reserve ~2 lines so vertical rhythm matches the prompts-side + * `.section-title-description` even when our subtitle is short. */ + min-height: 38px; +} + +.automations-list-widget .automations-new-button { + width: auto; + flex: 0 0 auto; + padding: 4px 14px; +} + +/* When the widget is empty, hide the header so the empty-state CTA reads + * as the primary action and there is not a duplicate New button. */ +.automations-list-widget.automations-empty .automations-header { + display: none; +} + +/* List */ +.automations-list-widget .automations-list { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: auto; + gap: 6px; +} + +.automations-list-widget .automations-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 12px; + padding: 12px 16px; + border-radius: 6px; + cursor: pointer; +} + +.automations-list-widget .automations-row:not(.automations-row-disabled):hover { + background-color: var(--vscode-list-hoverBackground); +} + +.automations-list-widget .automations-row-wrapper:has(> .automations-row-history) .automations-row:not(.automations-row-disabled):hover { + background-color: transparent; +} + +.automations-list-widget .automations-row:has(:focus-visible) { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.automations-list-widget .automations-row-disabled { + opacity: 0.6; +} + +.automations-list-widget .automations-row-main { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 0; +} + +.automations-list-widget .automations-row-name { + font-size: var(--vscode-agents-fontSize-body1); + font-weight: 600; + color: var(--vscode-foreground); + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + word-break: break-word; +} + +.automations-list-widget .automations-row-disabled-badge { + font-size: 11px; + font-weight: 400; + padding: 1px 6px; + border-radius: 3px; + background-color: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} + +.automations-list-widget .automations-row-meta { + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + flex-wrap: wrap; +} + +.automations-list-widget .automations-row-meta-sep { + opacity: 0.6; +} + +.automations-list-widget .automations-row-prompt { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + word-break: break-word; +} + +.automations-list-widget .automations-row-actions { + display: flex; + flex-direction: row; + gap: 4px; + align-items: center; + flex: 0 0 auto; +} + +.automations-list-widget .automations-row-action-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--vscode-icon-foreground); + cursor: pointer; + font-size: 16px; +} + +.automations-list-widget .automations-row-action-button:hover:not(:disabled) { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.automations-list-widget .automations-row-action-button:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.automations-list-widget .automations-row-action-button:disabled { + opacity: 0.4; + cursor: default; +} + +/* Run history (inline) */ +.automations-list-widget .automations-row-wrapper { + display: flex; + flex-direction: column; + border-radius: 6px; + overflow: hidden; +} + +/* + * When a wrapper has an expanded history panel, flatten the row's bottom + * corners so the row + history read as one visually connected card. + */ +.automations-list-widget .automations-row-wrapper:has(> .automations-row-history) .automations-row { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +} + +.automations-list-widget .automations-row-history { + padding: 4px 16px 12px 40px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.automations-list-widget .automations-history-heading { + font-size: 11px; + font-weight: 600; + margin: 4px 0; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} + +.automations-list-widget .automations-history-empty { + font-size: 12px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +.automations-list-widget .automations-history-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.automations-list-widget .automations-history-row { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: 8px; + font-size: 12px; + padding: 6px 10px; + border-radius: 4px; + background-color: var(--vscode-editorWidget-background, transparent); +} + +.automations-list-widget .automations-history-status { + flex: 0 0 auto; + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.automations-list-widget .automations-history-row[data-run-status="completed"] .automations-history-status { + color: var(--vscode-testing-iconPassed, var(--vscode-icon-foreground)); +} + +.automations-list-widget .automations-history-row[data-run-status="failed"] .automations-history-status { + color: var(--vscode-testing-iconFailed, var(--vscode-errorForeground)); +} + +.automations-list-widget .automations-history-row[data-run-status="running"] .automations-history-status { + color: var(--vscode-charts-blue, var(--vscode-icon-foreground)); +} + +.automations-list-widget .automations-history-row-text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +.automations-list-widget .automations-history-row-first { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 6px; + color: var(--vscode-foreground); +} + +.automations-list-widget .automations-history-row-status { + font-weight: 600; +} + +.automations-list-widget .automations-history-row-sep { + color: var(--vscode-descriptionForeground); + opacity: 0.6; +} + +.automations-list-widget .automations-history-row-trigger, +.automations-list-widget .automations-history-row-started, +.automations-list-widget .automations-history-row-duration { + color: var(--vscode-descriptionForeground); +} + +.automations-list-widget .automations-history-row-error { + color: var(--vscode-errorForeground); + font-size: 11px; + white-space: pre-wrap; + word-break: break-word; +} + +.automations-list-widget .automations-history-row-open { + flex: 0 0 auto; + width: 24px; + height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + background: transparent; + opacity: 0; + color: var(--vscode-icon-foreground); + font-size: 16px; +} + +.automations-list-widget .automations-history-row:hover .automations-history-row-open, +.automations-list-widget .automations-history-row-open:focus-visible { + opacity: 1; +} + +.automations-list-widget .automations-history-row-open:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.automations-list-widget .automations-history-row-open:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.automations-list-widget .automations-history-more { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + +/* Dialog */ +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message-detail { + display: none; +} + +/* + * The dialog's built-in title (rendered into `.dialog-message-text`) + * stays in the DOM so `aria-labelledby="monaco-dialog-message-text"` + * still binds for screen readers, but is visually hidden. Our own + * `.automation-titlebar` provides the visible chrome. Visually-hidden + * pattern (not `display: none`) because some AT/screen-reader combos + * skip aria-labelledby targets when `display: none` is in effect. + * Same treatment for the unused dialog icon slot. + */ +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message { + margin: 0; + padding: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-container > .dialog-message .dialog-message-text { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row > .dialog-icon.codicon { + display: none; +} + +/* + * Lock the dialog box to a stable width. Without this rule the dialog + * inherits `width: min-content` from `dialog.css` and re-flows as the + * embedded chat input's chips change size (e.g. when the model picker + * resolves a shorter label). Use min(640px, 92vw) so the dialog still + * shrinks gracefully on narrow viewports. + * + * Padding is stripped (vs. the base 8px) so our `.automation-titlebar` + * stripe can paint edge-to-edge inside the dialog body. The form pane + * re-adds its own padding. + * + * Note: `extraClasses` on Dialog adds the class to `.monaco-dialog-box` + * itself (not an ancestor), so we attach the rule directly here. + */ +.monaco-dialog-box.automation-dialog { + width: min(640px, 92vw); + min-width: min(520px, 92vw); + max-width: 92vw; + padding: 0; + background-color: var(--vscode-editorWidget-background); + color: var(--vscode-editorWidget-foreground); + /* Anchor for the absolutely-positioned close-X (see below). */ + position: relative; +} + +/* + * Float the close-X over the titlebar so the title text sits flush + * with the top edge of the modal (QuickInput-style). Without this, + * `.dialog-toolbar-row` reserves ~24px of dead space above the title. + * `z-index` keeps the X above the sticky titlebar. + */ +.monaco-dialog-box.automation-dialog .dialog-toolbar-row { + position: absolute; + top: 0; + right: 0; + height: auto; + padding: 4px 6px 0 0; + z-index: 2; +} + +.automation-dialog-body { + display: flex; + flex-direction: column; + min-height: 0; +} + +/* + * Strip the base dialog padding on the message row and container so + * our titlebar can full-bleed. The form pane below re-adds horizontal + * padding so the form fields don't crash into the dialog edge. + * + * Container still owns the scrolling (per base/.../dialog.css), so + * the titlebar uses `position: sticky; top: 0` to stay visible as the + * form scrolls underneath it. + */ +.monaco-dialog-box.automation-dialog .dialog-message-row { + padding: 0; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container { + padding: 0; + /* + * The base dialog leaves the message container at content size + * (`align-self: stretch` only stretches height in a horizontal flex + * row). Our form fields are `width: 100%` of this container, so they + * track the container's intrinsic min-content width. It shrinks + * over the first second as the embedded chat input's chip labels + * (model name, mode name, etc.) resolve and the toolbar's + * min-content drops. Forcing the container to fill the row width + * keeps every field at the dialog's locked width from first paint + * onwards. `min-width: 0` lets the container shrink inside the row + * (which has `overflow: hidden`) instead of being pushed by long + * chip labels. + */ + flex: 1 1 auto; + min-width: 0; +} + +/* + * Titlebar background matches the dialog body so the header reads as + * part of the modal, with no contrasting stripe. Both are anchored to + * `editorWidget.background`, the dialog widget's own native background + * (see defaultDialogStyles), so the header and body are always the same + * color in every theme. Set explicitly (not transparent) because the + * titlebar is sticky. A transparent background would let scrolled form + * content show through. No `border-bottom`. Sticky so the title stays + * in view as the form scrolls (the dialog's message-container is the + * scroll host). + */ +.automation-titlebar { + position: sticky; + top: 0; + z-index: 1; + /* Right padding reserves room for the floating close-X chip. */ + padding: 8px 36px 8px 14px; + text-align: left; + font-weight: var(--vscode-agents-fontWeight-semiBold); + font-size: var(--vscode-agents-fontSize-heading2); + color: var(--vscode-editorWidget-foreground); + background-color: var(--vscode-editorWidget-background); +} + +.automation-description { + padding: 8px 14px; + font-size: var(--vscode-agents-fontSize-body2, 12px); + color: var(--vscode-descriptionForeground); + line-height: 1.4; +} + +.automation-form-pane { + flex: 1 1 auto; + min-width: 0; + min-height: 0; + padding: 4px 14px 6px; +} + +/* + * Theme the native overflow scrollbar on the dialog body so it matches + * the rest of VS Code. .dialog-message-container is the scroll host + * (see base/browser/ui/dialog/dialog.css). Without this the dialog + * shows the platform's default scrollbar which clashes visually with + * the chat input embedded above. + */ +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar { + width: 10px; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-track { + background-color: transparent; +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb { + min-height: 20px; + background-color: var(--vscode-scrollbarSlider-background); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:hover { + background-color: var(--vscode-scrollbarSlider-hoverBackground); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-thumb:active { + background-color: var(--vscode-scrollbarSlider-activeBackground); +} + +.monaco-dialog-box.automation-dialog .dialog-message-row .dialog-message-container::-webkit-scrollbar-corner { + display: none; +} + +/* + * Z-index bridge for popups while the automation dialog is open. + * The dialog modal block is z-index 2575 (see base/.../dialog.css) + * and shares that value with .context-view (set inline in + * contextview.ts as `2575 + layer`). DOM order means the dialog + * shows on top, hiding any popup the chat input chips, the quick + * input, or a context menu try to open. We can't change the inline + * z-index from outside, so use `!important` on rules scoped to + * `body:has(.automation-dialog)`. They only apply while the + * automations dialog is on screen, so other dialogs' popup stacking + * is untouched. + */ +body:has(.automation-dialog) .context-view.monaco-component { + z-index: 2600 !important; +} + +body:has(.automation-dialog) .quick-input-widget { + z-index: 2600 !important; +} + +body:has(.automation-dialog) .monaco-menu-container { + z-index: 2600 !important; +} + +.automation-form { + display: flex; + flex-direction: column; + gap: 10px; + padding: 2px 2px 4px; +} + +/* + * Host for the embedded ChatInputPart. The composer brings its own + * background, border, and rounded corners (see chat.css + * `.interactive-input-part`), so we just give it room to breathe and let + * it fill the dialog's column width. `min-height` is sized for ~5 lines + * of body text + the toolbar row so the editor doesn't feel cramped on + * first open; the editor itself grows as the user types. + */ +.automation-form-prompt-host { + display: flex; + flex-direction: column; + min-height: 140px; + /* + * The host carries the `.interactive-session` class so the shared + * chat input CSS (chip colors, focus borders, etc.) matches what + * users see in a real chat session. That class also sets + * `max-width: 950px; margin: auto; height: 100%` (chat.css), which + * is meant for the full chat panel. In our flex column it makes + * the host take its content's min-content width and center, + * collapsing the chat input to ~chip-bar width. Re-assert + * full-width sizing so the host fills the dialog's form column. + */ + width: auto; + max-width: 100%; + margin: 0; + height: auto; + align-self: stretch; +} + +.automation-form-prompt-host .interactive-input-part { + /* + * `.interactive-input-part` in chat.css has `margin: 0 12px`, which + * (combined with the default `width: auto`) is designed to inset the + * composer inside a wider chat panel. In our dialog the prompt host + * is already the form column, so the margin pushes the composer 12px + * past the host on each side. Its right border ends up sitting + * outside the dialog. Zero out the margin so the composer aligns + * with the other form fields. + */ + margin: 0; + /* Don't let long custom-mode names or model identifiers push the + * modal wider than its declared max-width. */ + max-width: 100%; + min-width: 0; +} + +.automation-form-prompt-host .interactive-input-editor, +.automation-form-prompt-host .monaco-editor, +.automation-form-prompt-host .monaco-editor-background, +.automation-form-prompt-host .monaco-editor .margin { + background-color: var(--vscode-settings-textInputBackground, var(--vscode-input-background)); +} + +.automation-form-prompt-host .interactive-input-and-side-toolbar { + /* Allow the input column to shrink below its content's preferred + * width so flex children (chips, custom-mode names) don't push the + * modal wider than its declared max-width. */ + min-width: 0; +} + +/* + * Hide chips from the embedded chat input that don't fit the automation + * dialog's reduced surface: + * - "Configure Tools" (`workbench.action.chat.configureTools`, + * `.codicon-settings`). Tool selection is not relevant for a + * scheduled prompt. + * - "Attach context" (`workbench.action.chat.attachContext`, + * `.codicon-add`, the `+`). The dialog doesn't support attachments. + * the prompt is the only payload sent at run time. + * - "List MCP Servers" (`workbench.mcp.listServer`, `.codicon-server`) + * MCP server management belongs in the live chat surface, not in + * a one-off prompt definition. The action view item is custom + * (`MenuEntryActionViewItem`) but still renders the standard + * `.action-label.codicon-server` icon, so the same selector pattern + * matches. + * Other chips in this toolbar use either custom action view items + * (Model / Mode / Session pickers) or different codicons, so codicon + * selectors are unique to the chips we want to remove. The descendant + * selector (not `>`) matches even when the action-view-item wraps the + * `.action-label` in an extra container (e.g. dropdown variants), which + * keeps the rule resilient to view-item refactors. Hiding the + * `.action-item` (not just the `.action-label`) removes the slot from + * keyboard tab order too. + */ +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-settings), +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-add), +.automation-form-prompt-host .chat-input-toolbar .action-item:has(.action-label.codicon-server) { + display: none; +} + +/* + * Suppress every inter-chip divider drawn by chat.css inside the dialog, + * in both the primary (`.chat-input-toolbar`) and secondary + * (`.chat-secondary-input-toolbar`) toolbars. CSS `+` matches by DOM + * order regardless of `display:none`, so the divider would otherwise + * appear as an orphan vertical line on whichever visible chip follows + * our hidden `+`/settings items. For example the bar drawn to the left + * of the Folder/Worktree isolation chip because it follows the Copilot + * CLI chip in the secondary toolbar (microsoft/vscode-internalbacklog#8304). The remaining visible + * chips (Agent, model, mode, isolation) already have enough intrinsic + * padding to read as separate without the divider. Dropping dividers + * entirely is simpler and more resilient than per-pair suppression rules + * that depend on DOM order. + */ +.automation-form-prompt-host .chat-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item::before, +.automation-form-prompt-host .chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item::before { + display: none; +} + +/* + * Cancel the dialog's `<ul>` indent for the chat input toolbars. + * + * `dialog.css:120` applies `padding-inline-start: 20px` to every `<ul>` + * inside `.dialog-message-container` (meant to reduce the excessive + * default indent on bulleted lists in dialog body text). The + * `monaco-action-bar`'s `.actions-container` is rendered as a `<ul>`, so + * the chat input's primary and secondary toolbars inherit that 20px + * left indent. This pushes the Agent / Copilot CLI chips ~20px right of + * the chat input's bottom-left corner. + * + * `actionbar.css` sets `.actions-container { padding: 0 }`, but the + * dialog rule uses a logical property (`padding-inline-start`), which + * cascades-after the physical shorthand and wins regardless of selector + * specificity. Override the logical property explicitly here. + * + * Scoped to `.automation-form-prompt-host` so the regular chat panel + * (which isn't inside a dialog) is unaffected. Any `<ul>` + * elements in the dialog's actual message body text retain the + * intended 20px indent. + */ +.automation-form-prompt-host .chat-input-toolbar .monaco-action-bar .actions-container, +.automation-form-prompt-host .chat-secondary-toolbar .monaco-action-bar .actions-container { + padding-inline-start: 0; + margin-left: 0; +} + +/* + * Sessions-layer workspace picker visual override. + * + * Two cascading sources need to be defeated to match Mode/Model: + * + * 1. `.sessions-chat-picker-slot.sessions-chat-workspace-picker + * .action-label` (sessions-layer `chatWidget.css:244-269`) sets a + * welcome-flow visual: label 18px, inner span 18px, icons 16px, + * chevron, generous padding, `color: var(--vscode-foreground)`. + * + * 2. `.monaco-action-bar .action-label` (actionbar.css:48) sets the + * base toolbar font-size to **11px**. This is what Mode/Model + * inherit. They don't set font-size themselves. The action bar + * base rule wins for their `.action-label`. The workspace picker's + * own sessions-layer rule (source 1) overrides that base, so we + * need to put the 11px back explicitly. `font-size: inherit` + * does NOT work because none of `.sessions-chat-picker-slot`'s + * ancestors carry an 11px font-size. The 11px lives on sibling + * `.action-label` elements via `.monaco-action-bar .action-label`, + * not on an ancestor we can inherit from. + * + * Result: match the 11px base, neutralize the welcome-flow oversizing, + * keep the Mode/Model color (`var(--vscode-icon-foreground)`), match + * the chip metrics from `.chat-input-toolbar .chat-input-picker-item + * .action-label` (chat.css:1822), and hide the chevron (Mode/Model + * don't render one). + * + * Scoped to `.automation-form-prompt-host` so the welcome view's + * standalone workspace picker keeps its larger sizing. + */ +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { + height: 16px; + padding: 3px 6px; + font-size: 11px; + line-height: normal; + color: var(--vscode-icon-foreground); +} + +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { + font-size: inherit; + line-height: inherit; +} + +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { + font-size: 12px; +} + +/* + * Mode and Model pickers in this toolbar don't render a dropdown + * chevron. They are recognized as chips by their padding + hover. Hide + * the chevron that `_renderTriggerLabel` always appends so the + * workspace chip reads the same way. + */ +.automation-form-prompt-host .chat-input-toolbar .sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-chevron { + display: none; +} + +.automation-form-row { + display: flex; + flex-direction: column; + gap: 4px; +} + +.automation-form-row.automation-form-checkbox-row { + flex-direction: row; + align-items: center; + gap: 8px; + margin-top: 2px; + padding-top: 10px; + border-top: 1px solid var(--vscode-widget-border, transparent); +} + +/* + * Lay the Schedule / Time / Day controls along a single horizontal axis. + * Each control lives in its own `.automation-form-schedule-group` + * (label-above-control flex column). Time and Day join the row only + * when the interval is daily or weekly; flex-wrap keeps the layout + * stable if a translation pushes the row past the dialog width. + */ +.automation-form-row.automation-form-schedule-row { + flex-direction: row; + align-items: flex-end; + flex-wrap: wrap; + gap: 12px; +} + +.automation-form-schedule-group { + display: flex; + flex-direction: column; + gap: 4px; + /* + * Each visible group splits the schedule row equally. Hidden + * groups (`display: none` from `applyIntervalVisibility`) drop out + * of flex distribution, so: + * - manual / hourly → Schedule fills 100% of the row + * - daily → Schedule + Time split 50/50 + * - weekly → Schedule + Time + Day split 33/33/33 + * `min-width: 0` lets the contained SelectBox shrink past its + * intrinsic content width when the row is narrow, before the + * row's `flex-wrap: wrap` kicks in as a final safety net. + */ + flex: 1 1 0; + min-width: 0; +} + +/* SelectBox container sizing. Give Schedule / Time / Day the full + * width of their parent group (which itself flex-shares the row). + * + * `.monaco-select-box` ships with no intrinsic padding or height (the + * only baked-in metrics live in the `.monaco-action-bar .action-item` + * selector, which we're not inside of). Mirror the standalone-form + * pattern used by the settings editor (`settingsEditor2.css:689-694`). + * It uses `height: 26px` and `padding: 2px 6px`. This makes the dropdowns match the + * Name InputBox above them instead of reading as toolbar widgets + * crammed into a form. */ +.automation-form-schedule-select-container { + display: flex; + min-width: 0; + width: 100%; +} + +.automation-form-schedule-select-container .monaco-select-box { + height: 26px; + padding: 2px 8px; +} + +/* + * Form-row labels read as section titles ("Name", "Schedule", "Time", + * "Day of week", "Prompt"). Mirror the established agents + * design-system section-label pattern (see `.overview-section + * .section-label` at line ~734: label1 / fontWeight-medium / + * foreground) so the labels carry visual weight as section headings + * and clearly out-rank their controls. + * + * `margin-bottom` adds breathing room between the title text and + * its control so each form row reads as a discrete section. + */ +.automation-form-label { + font-size: var(--vscode-agents-fontSize-label1); + font-weight: var(--vscode-agents-fontWeight-semiBold); + color: var(--vscode-foreground); + line-height: 1.4; + margin-bottom: 2px; + cursor: default; +} + +.automation-form-hint { + font-size: var(--vscode-agents-fontSize-body2, 11px); + color: var(--vscode-descriptionForeground); + line-height: 1.4; + min-height: 1em; +} + +.automation-form-checkbox-label { + font-size: var(--vscode-agents-fontSize-body1, 13px); + color: var(--vscode-foreground); + cursor: pointer; +} + +/* + * Host for the native `InputBox` widget. The widget owns its own + * `<input>` and styling (border, padding, focus ring); the host just + * lets it grow to the row width. + */ +.automation-form-input-host { + display: flex; + width: 100%; +} + +.automation-form-input-host > .monaco-inputbox { + flex: 1 1 auto; + min-width: 0; +} + +.automation-form-input, +.automation-form-select, +.automation-form-textarea { + font-family: inherit; + font-size: var(--vscode-agents-fontSize-body1, 13px); + color: var(--vscode-settings-textInputForeground, var(--vscode-input-foreground)); + background-color: var(--vscode-settings-textInputBackground, var(--vscode-input-background)); + border: 1px solid var(--vscode-settings-textInputBorder, var(--vscode-input-border, var(--vscode-contrastBorder, transparent))); + border-radius: 2px; + padding: 4px 6px; + box-sizing: border-box; + width: 100%; + min-height: 26px; +} + +.automation-form-select { + color: var(--vscode-settings-dropdownForeground, var(--vscode-dropdown-foreground)); + background-color: var(--vscode-settings-dropdownBackground, var(--vscode-dropdown-background)); + border-color: var(--vscode-settings-dropdownBorder, var(--vscode-dropdown-border, var(--vscode-contrastBorder, transparent))); +} + +.automation-form-textarea { + resize: vertical; + min-height: 60px; + line-height: 1.4; +} + +.automation-form-input:focus, +.automation-form-select:focus, +.automation-form-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + border-color: var(--vscode-focusBorder); +} + +.automation-form-empty { + font-size: 12px; + color: var(--vscode-errorForeground); + padding: 6px 0; +} + +/* + * Folder picker. This uses the shared `WorkspacePicker` from the sessions + * layer (see `sessionWorkspacePicker.ts`). The picker renders an + * `<a.action-label>` chip trigger inside + * `.sessions-chat-picker-slot.sessions-chat-workspace-picker`. We restyle + * it to fit the dialog's compact, 13px form scale. The upstream styling + * lives in `sessions/contrib/chat/browser/media/chatWidget.css` and is + * sized for the new-session "hero" UI (18px font, larger chip) which is + * too large for our modal. + * + * The dropdown content itself renders through `IActionWidgetService`, + * which has its own global CSS. No overrides needed here. + */ +/* + * Isolation + branch group: parented into the chat input's + * `.chat-secondary-toolbar` so it sits at the bottom-right of the chat + * input, sharing a row with `Copilot CLI | Default Approvals`. + * `margin-left: auto` pushes the group to the right edge within that + * flex row. + * + * Visual styling mirrors the new-session view's + * `.new-chat-bottom-container` chip vocabulary (see + * `sessions/contrib/chat/browser/media/chatWidget.css` lines 178-209): + * 16px tall, 11px font, 12px codicons, icon-foreground color, no + * border. The `|` divider between the Folder chip and the branch slot + * uses the same `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` + * trick the new-session row uses to draw a 1px vertical separator + * without adding a DOM element. + * + * Communicates *where* the automation will run. It is presentational only (no + * click handler, no keyboard target). + */ +.automation-form-prompt-host .chat-secondary-toolbar .automation-form-isolation-group { + display: inline-flex; + align-items: center; + margin-left: auto; + min-width: 0; + gap: 5px; +} + +.automation-form-prompt-host .automation-form-isolation-chip, +.automation-form-prompt-host .automation-form-branch-slot { + display: inline-flex; + align-items: center; + gap: 4px; + height: 16px; + padding: 3px 6px; + font-size: 11px; + color: var(--vscode-icon-foreground); + background: transparent; + border: none; + min-width: 0; +} + +.automation-form-prompt-host .automation-form-isolation-chip > .codicon, +.automation-form-prompt-host .automation-form-branch-slot > .codicon { + font-size: 12px; + color: var(--vscode-icon-foreground); + flex-shrink: 0; +} + +.automation-form-prompt-host .automation-form-isolation-chip { + cursor: pointer; + border-radius: var(--vscode-cornerRadius-small); +} + +.automation-form-prompt-host .automation-form-isolation-chip:not(.automation-form-isolation-chip-disabled):hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.automation-form-prompt-host .automation-form-isolation-chip-disabled { + cursor: default; +} + +.automation-form-prompt-host .automation-form-isolation-label, +.automation-form-prompt-host .automation-form-branch-name { + font-size: 11px; +} + +.automation-form-prompt-host .automation-form-branch-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Mirror the new-session `|` divider between repo-config chips: + * `box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border)` paints + * a 1px-wide bar in the gap to the chip's left. */ +.automation-form-prompt-host .automation-form-isolation-group > .automation-form-branch-slot { + box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); +} + +.automation-form-prompt-host .chat-secondary-toolbar .automation-form-harness-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 16px; + padding: 3px 6px; + font-size: 11px; + color: var(--vscode-icon-foreground); + background: transparent; + border: none; + opacity: 0.6; + cursor: default; +} + +.automation-form-prompt-host .automation-form-harness-chip > .codicon { + font-size: 12px; + flex-shrink: 0; +} + +.automation-form-prompt-host .automation-form-harness-label { + font-size: 11px; +} + +/* Missing-branch state (no folder / no git repo / detached / empty): use + * description-foreground + italic so it visually reads as "informational + * placeholder, not a real ref name". */ +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing, +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing > .codicon { + color: var(--vscode-descriptionForeground); +} + +.automation-form-prompt-host .automation-form-branch-slot.automation-form-branch-missing .automation-form-branch-name { + font-style: italic; +} + +.automations-list-widget .automations-row-folder { + color: var(--vscode-descriptionForeground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 240px; +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts index cf3c955414482b..cd68c26ea9c944 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -12,12 +12,12 @@ import { basename, dirname } from '../../../../../base/common/resources.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { IAICustomizationWorkspaceService, applySourceFilter, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService, AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ICustomAgent, IPromptsService, matchesSessionType, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { ICustomizationItem, ICustomizationItemProvider, ICustomizationSourceFolder, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; +import { ICustomizationItem, ICustomizationItemProvider, ICustomizationSourceFolder } from '../../common/customizationHarnessService.js'; import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; import { getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -31,7 +31,6 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt readonly onDidChange: Event<void>; constructor( - private readonly getActiveDescriptor: () => IHarnessDescriptor, @IPromptsService private readonly promptsService: IPromptsService, @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, @IProductService private readonly productService: IProductService, @@ -42,6 +41,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt this.promptsService.onDidChangeSkills, this.promptsService.onDidChangeHooks, this.promptsService.onDidChangeInstructions, + this.promptsService.onDidChangeAgentInstructions, ); } @@ -67,6 +67,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt return folders.map(folder => ({ uri: folder.uri, label: this.promptsService.getPromptLocationLabel(folder), + source: folder.storage })); } @@ -180,7 +181,7 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); } - return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); + return this.applyBuiltinGroupKeys(items, extensionInfoByUri); } private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise<void> { @@ -329,12 +330,4 @@ export class PromptsServiceCustomizationItemProvider implements ICustomizationIt }); } - private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): readonly ICustomizationItem[] { - const descriptor = this.getActiveDescriptor(); - const filter = descriptor.getStorageSourceFilter(promptType); - const items = applySourceFilter(groupedItems, filter); - - return items; - } - } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts index 9a6557acae863b..4ff7b9fb229c0d 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/toolsListWidget.ts @@ -4,28 +4,36 @@ *--------------------------------------------------------------------------------------------*/ import * as DOM from '../../../../../base/browser/dom.js'; +import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { Button } from '../../../../../base/browser/ui/button/button.js'; import { HighlightedLabel } from '../../../../../base/browser/ui/highlightedlabel/highlightedLabel.js'; import { InputBox } from '../../../../../base/browser/ui/inputbox/inputBox.js'; -import { IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; +import { IListContextMenuEvent, IListVirtualDelegate } from '../../../../../base/browser/ui/list/list.js'; import { DomScrollableElement } from '../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Checkbox, TriStateCheckbox } from '../../../../../base/browser/ui/toggle/toggle.js'; +import { StandardMouseEvent } from '../../../../../base/browser/mouseEvent.js'; +import { IAnchor } from '../../../../../base/browser/ui/contextview/contextview.js'; +import { Action } from '../../../../../base/common/actions.js'; import { Delayer } from '../../../../../base/common/async.js'; -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { Emitter } from '../../../../../base/common/event.js'; import { IMatch, matchesContiguousSubString } from '../../../../../base/common/filters.js'; +import { KeyCode } from '../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { autorun, derived, IObservable, IReader, observableValue } from '../../../../../base/common/observable.js'; +import { autorun, derived, IObservable, IReader, observableSignalFromEvent, observableValue } from '../../../../../base/common/observable.js'; import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; -import { IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IContextMenuService, IContextViewService } from '../../../../../platform/contextview/browser/contextView.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { WorkbenchList } from '../../../../../platform/list/browser/listService.js'; import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { IExtensionManifestPropertiesService } from '../../../../services/extensions/common/extensionManifestPropertiesService.js'; +import { IWorkbenchEnvironmentService } from '../../../../services/environment/common/environmentService.js'; import { ExtensionState, IExtension, IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { GalleryItemInstallState, GalleryItemRenderer, IGalleryItemProvider } from './galleryItemRenderer.js'; import { ILanguageModelToolsService, IToolData, IToolSet, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; @@ -34,6 +42,17 @@ import './media/aiCustomizationManagement.css'; const $ = DOM.$; +interface ITreeRow { + readonly kind: 'set' | 'tool'; + readonly rowId: string; + readonly toolSetId: string; + readonly element: HTMLElement; + readonly toggleNode: HTMLElement; + readonly group?: HTMLElement; + readonly children?: ITreeRow[]; + readonly parent?: ITreeRow; +} + interface IToolViewModel { readonly tool: IToolData; readonly nameMatches?: IMatch[]; @@ -127,7 +146,7 @@ export class ToolsListWidget extends Disposable { private _searchRow!: HTMLElement; private _treeContainer!: HTMLElement; private _treeScrollable!: DomScrollableElement; - private _browseButtonContainer!: HTMLElement; + private _browseButtonContainer: HTMLElement | undefined; private _backButtonContainer!: HTMLElement; private _galleryContainer!: HTMLElement; private _galleryEmpty!: HTMLElement; @@ -140,6 +159,10 @@ export class ToolsListWidget extends Disposable { private _lastHeight = 0; private _lastWidth = 0; + private _activeRowId: string | undefined; + private _rows: ITreeRow[] = []; + private readonly _rowByElement = new Map<HTMLElement, ITreeRow>(); + /** Read-only tool sets injected for the current session type (e.g. the Copilot CLI built-ins). */ private readonly _staticReadOnlySets: readonly IToolSet[]; @@ -148,9 +171,13 @@ export class ToolsListWidget extends Disposable { @ILanguageModelToolsService private readonly _toolsService: ILanguageModelToolsService, @IAgentHostToolSetEnablementService private readonly _enablementService: IAgentHostToolSetEnablementService, @IContextViewService private readonly _contextViewService: IContextViewService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IDialogService private readonly _dialogService: IDialogService, @IOpenerService private readonly _openerService: IOpenerService, @IInstantiationService private readonly _instantiationService: IInstantiationService, @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, + @IExtensionManifestPropertiesService private readonly _extensionManifestPropertiesService: IExtensionManifestPropertiesService, + @IWorkbenchEnvironmentService private readonly _environmentService: IWorkbenchEnvironmentService, ) { super(); @@ -162,8 +189,16 @@ export class ToolsListWidget extends Disposable { // Wrap the tree in a DomScrollableElement for an overlay scrollbar (not the native one). this._treeContainer = $('.tools-list-tree'); - this._treeContainer.setAttribute('role', 'group'); + this._treeContainer.setAttribute('role', 'tree'); this._treeContainer.setAttribute('aria-label', localize('toolsTreeAria', "Tool groups")); + // Tree-style keyboard navigation with a roving tabIndex, so the tree is a single tab stop. + this._register(DOM.addStandardDisposableListener(this._treeContainer, DOM.EventType.KEY_DOWN, e => this._onTreeKeyDown(e))); + this._register(DOM.addDisposableListener(this._treeContainer, DOM.EventType.FOCUS_IN, e => { + const row = this._rowFromTarget(e.target as HTMLElement); + if (row) { + this._setRovingRow(row); + } + })); this._treeScrollable = this._register(new DomScrollableElement(this._treeContainer, { horizontal: ScrollbarVisibility.Hidden, vertical: ScrollbarVisibility.Auto, @@ -227,11 +262,13 @@ export class ToolsListWidget extends Disposable { }).catch(() => { /* delayer disposed */ }); })); - const browseLabel = localize('toolsBrowseMarketplace', "Browse Marketplace"); - this._browseButtonContainer = DOM.append(this._searchRow, $('.tools-list-browse-button-container')); - const browseButton = this._register(new Button(this._browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: browseLabel, ariaLabel: browseLabel })); - browseButton.label = `$(${Codicon.library.id}) ${browseLabel}`; - this._register(browseButton.onDidClick(() => this._setBrowseMode(true))); + if (!this._environmentService.isSessionsWindow) { + const browseLabel = localize('toolsBrowseMarketplace', "Browse Marketplace"); + this._browseButtonContainer = DOM.append(this._searchRow, $('.tools-list-browse-button-container')); + const browseButton = this._register(new Button(this._browseButtonContainer, { ...defaultButtonStyles, secondary: true, supportIcons: true, title: browseLabel, ariaLabel: browseLabel })); + browseButton.label = `$(${Codicon.library.id}) ${browseLabel}`; + this._register(browseButton.onDidClick(() => this._setBrowseMode(true))); + } const backLabel = localize('toolsBrowseBack', "Back"); this._backButtonContainer = DOM.append(this._searchRow, $('.tools-list-browse-button-container')); @@ -269,6 +306,8 @@ export class ToolsListWidget extends Disposable { this._onDidSelectExtension.fire(e.element); } })); + + this._register(this._galleryList.onContextMenu(e => this._onGalleryContextMenu(e))); } private _readState(reader: IReader): IToolEnablementState { @@ -296,7 +335,10 @@ export class ToolsListWidget extends Disposable { } private _createViewModel(): IObservable<readonly IToolSetViewModel[]> { + // Refresh when extensions change so tool sets from an uninstalled extension drop out immediately (their tools linger in the extension host until reload). + const extensionsChanged = observableSignalFromEvent(this, this._extensionsWorkbenchService.onChange); return derived(reader => { + extensionsChanged.read(reader); const query = this._searchQuery.read(reader).trim(); const result: IToolSetViewModel[] = []; @@ -315,6 +357,14 @@ export class ToolsListWidget extends Disposable { if (ts.deprecated) { return undefined; } + // Hide extension-provided sets whose extension is gone or being removed. + if (ts.source.type === 'extension') { + const extensionId = ts.source.extensionId; + const installed = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, extensionId)); + if (!installed || installed.state === ExtensionState.Uninstalling || installed.state === ExtensionState.Uninstalled) { + return undefined; + } + } const memberTools = Array.from(ts.getTools(reader)); if (memberTools.length === 0) { return undefined; @@ -363,6 +413,9 @@ export class ToolsListWidget extends Disposable { /** Enters/leaves marketplace browse mode, swapping the tree for the gallery list. */ private _setBrowseMode(browse: boolean): void { + if (browse && this._environmentService.isSessionsWindow) { + return; + } if (this._browseMode === browse) { return; } @@ -370,7 +423,9 @@ export class ToolsListWidget extends Disposable { this._treeScrollable.getDomNode().style.display = browse ? 'none' : ''; this._galleryContainer.style.display = browse ? '' : 'none'; - this._browseButtonContainer.style.display = browse ? 'none' : ''; + if (this._browseButtonContainer) { + this._browseButtonContainer.style.display = browse ? 'none' : ''; + } this._backButtonContainer.style.display = browse ? '' : 'none'; this._searchInput.setPlaceHolder(browse @@ -408,7 +463,11 @@ export class ToolsListWidget extends Disposable { return; } const items = pager.firstPage; - if (items.length === 0) { + const filteredItems = await this._filterGalleryResults(items, cts.token); + if (cts.token.isCancellationRequested) { + return; + } + if (filteredItems.length === 0) { this._setGalleryMessage( localize('toolsBrowseNoResults', "No tool extensions match '{0}'", userText || TOOLS_MARKETPLACE_QUERY), localize('tryDifferentSearch', "Try a different search term")); @@ -416,7 +475,7 @@ export class ToolsListWidget extends Disposable { } this._galleryEmpty.style.display = 'none'; this._galleryListContainer.style.display = ''; - this._galleryList.splice(0, this._galleryList.length, items); + this._galleryList.splice(0, this._galleryList.length, filteredItems); } catch { if (!cts.token.isCancellationRequested) { this._setGalleryMessage( @@ -426,6 +485,35 @@ export class ToolsListWidget extends Disposable { } } + /** + * Keeps only extensions that contribute language model tools and, in the Agents window, can run there + * ({@link IExtensionManifestPropertiesService.canExecuteOnSessionsWindow}); the `executesCode` hint skips + * manifest fetches for extensions that can never run. + */ + private async _filterGalleryResults(extensions: readonly IExtension[], token: CancellationToken): Promise<IExtension[]> { + const requireAgentsWindowSupport = this._environmentService.isSessionsWindow; + const results = await Promise.all(extensions.map(async extension => { + // In the Agents window, code-executing extensions can never run: reject before fetching the manifest. + if (requireAgentsWindowSupport && extension.gallery?.properties.executesCode) { + return undefined; + } + try { + const manifest = await extension.getManifest(token); + if (!manifest?.contributes?.languageModelTools?.length) { + return undefined; + } + if (requireAgentsWindowSupport && !this._extensionManifestPropertiesService.canExecuteOnSessionsWindow(manifest)) { + return undefined; + } + return extension; + } catch { + // Ignore extensions whose manifest cannot be resolved. + return undefined; + } + })); + return results.filter((extension): extension is IExtension => !!extension); + } + private _setGalleryMessage(text: string, subtext?: string): void { // Drop any stale rows so only the message shows. this._galleryList.splice(0, this._galleryList.length, []); @@ -451,7 +539,11 @@ export class ToolsListWidget extends Disposable { } private _render(model: readonly IToolSetViewModel[]): void { + // A live update (search/tool-set change) rebuilds rows; keep keyboard focus in the tree if it was there. + const hadFocus = DOM.isAncestor(this._treeContainer.ownerDocument.activeElement, this._treeContainer); this._rowStore.clear(); + this._rows = []; + this._rowByElement.clear(); DOM.clearNode(this._treeContainer); if (model.length === 0) { @@ -471,16 +563,27 @@ export class ToolsListWidget extends Disposable { } for (const vm of model) { - this._renderToolSet(vm); + const setRow = this._renderToolSet(vm); + this._addRow(setRow); + for (const child of setRow.children!) { + this._addRow(child); + } } + this._initRovingTabIndex(hadFocus); this._treeScrollable.scanDomNode(); } - private _renderToolSet(vm: IToolSetViewModel): void { + private _addRow(row: ITreeRow): void { + this._rows.push(row); + this._rowByElement.set(row.element, row); + } + + private _renderToolSet(vm: IToolSetViewModel): ITreeRow { const ts = vm.toolSet; const row = DOM.append(this._treeContainer, $('.tools-list-setrow')); - // Focusable on click (not in tab order) for a list-style focus outline; keyboard users reach the inner - // checkbox/chevron, which light up the row via :focus-within. + // Tree item with a roving tabIndex: navigated with arrows, toggled with Space; not a Tab stop. + row.setAttribute('role', 'treeitem'); + row.setAttribute('aria-level', '1'); row.tabIndex = -1; const setName = ts.description ?? ts.referenceName; @@ -491,6 +594,7 @@ export class ToolsListWidget extends Disposable { getToolSetTriState(this._currentState(), ts.id, vm.allToolIds), defaultCheckboxStyles, )); + checkbox.domNode.tabIndex = -1; row.appendChild(checkbox.domNode); if (vm.readOnly) { checkbox.disable(); @@ -514,15 +618,9 @@ export class ToolsListWidget extends Disposable { const count = DOM.append(row, $('span.tools-list-row-count')); + // Decorative chevron: expand state is on the row (aria-expanded); toggled by row click or arrows. const chevron = DOM.append(row, $('a.tools-list-chevron.codicon')) as HTMLAnchorElement; - chevron.setAttribute('role', 'button'); - chevron.setAttribute('tabindex', '0'); - this._rowStore.add(DOM.addDisposableListener(chevron, 'keydown', e => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - toggleExpand(); - } - })); + chevron.setAttribute('aria-hidden', 'true'); this._rowStore.add(DOM.addDisposableListener(row, 'click', e => { if (checkbox.domNode.contains(e.target as Node)) { @@ -532,53 +630,81 @@ export class ToolsListWidget extends Disposable { toggleExpand(); })); + // Extension-provided tool sets can be uninstalled via the context menu. + this._rowStore.add(DOM.addDisposableListener(row, 'contextmenu', e => { + const extension = this._resolveExtensionForToolSet(ts); + if (!extension) { + return; + } + DOM.EventHelper.stop(e, true); + const anchor: HTMLElement | StandardMouseEvent = e.button === 2 ? new StandardMouseEvent(DOM.getWindow(row), e) : row; + this._showExtensionContextMenu(anchor, extension); + })); + const group = DOM.append(this._treeContainer, $('.tools-list-children')); + group.id = `tools-group-${ts.id}`; group.setAttribute('role', 'group'); group.setAttribute('aria-label', setName); + // The child group is a DOM sibling (flat flex layout), so associate it with the parent item via aria-owns. + row.setAttribute('aria-owns', group.id); + + const setRow: ITreeRow = { + kind: 'set', + rowId: `set:${ts.id}`, + toolSetId: ts.id, + element: row, + toggleNode: checkbox.domNode, + group, + children: [], + }; for (const tool of vm.visibleTools) { - this._renderTool(group, vm, tool); + setRow.children!.push(this._renderTool(group, setRow, vm, tool)); } // Tri-state and count reflect enablement; update in place so a toggle never rebuilds the row. this._rowStore.add(autorun(reader => { const state = this._readState(reader); - checkbox.checked = getToolSetTriState(state, ts.id, vm.allToolIds); + const triState = getToolSetTriState(state, ts.id, vm.allToolIds); + checkbox.checked = triState; + this._updateRowAriaChecked(row, triState); const enabledCount = vm.allToolIds.reduce((n, id) => n + (isToolEnabledInSet(state, ts.id, id) ? 1 : 0), 0); count.textContent = `${enabledCount}/${vm.allToolIds.length}`; count.setAttribute('aria-label', localize('toolsRowEnabledOfTotal', "{0} of {1} tools enabled", enabledCount, vm.allToolIds.length)); })); - // Expand/collapse toggles child visibility in place (no rebuild) so chevron focus is kept. + // Expand/collapse toggles child visibility in place (no rebuild) so row focus is kept. this._rowStore.add(autorun(reader => { const expanded = vm.forceExpanded || this._expanded.read(reader).has(ts.id); group.style.display = expanded ? '' : 'none'; chevron.classList.toggle('codicon-chevron-down', expanded); chevron.classList.toggle('codicon-chevron-right', !expanded); - chevron.setAttribute('aria-expanded', String(expanded)); - chevron.setAttribute('aria-label', expanded - ? localize('toolsCollapseAria', "Collapse {0}", setName) - : localize('toolsExpandAria', "Expand {0}", setName)); + row.setAttribute('aria-expanded', String(expanded)); this._treeScrollable.scanDomNode(); })); + + return setRow; } - private _renderTool(group: HTMLElement, vm: IToolSetViewModel, toolVm: IToolViewModel): void { + private _renderTool(group: HTMLElement, parent: ITreeRow, vm: IToolSetViewModel, toolVm: IToolViewModel): ITreeRow { const tool = toolVm.tool; const enabled = isToolEnabledInSet(this._currentState(), vm.toolSet.id, tool.id); const toolName = tool.displayName ?? tool.id; const row = DOM.append(group, $('.tools-list-toolrow')); row.classList.toggle('readonly', vm.readOnly); - if (!vm.readOnly) { - row.tabIndex = -1; - } + // Tree item at level 2; read-only tools stay navigable (only the checkbox is disabled). + row.setAttribute('role', 'treeitem'); + row.setAttribute('aria-level', '2'); + row.tabIndex = -1; const checkbox = this._rowStore.add(new Checkbox( localize('toolsToolCheckbox', "Enable {0}", toolName), enabled, defaultCheckboxStyles, )); + checkbox.domNode.tabIndex = -1; row.appendChild(checkbox.domNode); + this._updateRowAriaChecked(row, enabled); if (vm.readOnly) { checkbox.disable(); checkbox.setTitle(localize('toolsSetReadOnly', "These are the agent's built-in tools and cannot be changed.")); @@ -595,9 +721,11 @@ export class ToolsListWidget extends Disposable { this._enablementService.setToolEnabled(this._sessionType, vm.toolSet.id, tool.id, !checkbox.checked); })); - // Keep the checkbox in sync with state in place (e.g. when the parent set is toggled). + // Keep the checkbox and the treeitem's aria-checked in sync (e.g. when the parent set is toggled). this._rowStore.add(autorun(reader => { - checkbox.checked = isToolEnabledInSet(this._readState(reader), vm.toolSet.id, tool.id); + const toolEnabled = isToolEnabledInSet(this._readState(reader), vm.toolSet.id, tool.id); + checkbox.checked = toolEnabled; + this._updateRowAriaChecked(row, toolEnabled); })); } @@ -610,6 +738,15 @@ export class ToolsListWidget extends Disposable { const subtext = DOM.append(text, $('span.tools-list-row-subtext')); subtext.textContent = description; } + + return { + kind: 'tool', + rowId: `tool:${vm.toolSet.id}:${tool.id}`, + toolSetId: vm.toolSet.id, + element: row, + toggleNode: checkbox.domNode, + parent, + }; } /** @@ -628,6 +765,11 @@ export class ToolsListWidget extends Disposable { return extension?.description || localize('toolsSetExtensionDetail', "Tools contributed by {0}", source.label); } + /** Mirror a row's enablement onto its `treeitem` so assistive tech announces it while navigating. */ + private _updateRowAriaChecked(element: HTMLElement, state: boolean | 'mixed'): void { + element.setAttribute('aria-checked', state === 'mixed' ? 'mixed' : String(state)); + } + private _toggleCollapsed(toolSetId: string): void { const next = new Set(this._expanded.get()); if (next.has(toolSetId)) { @@ -638,11 +780,201 @@ export class ToolsListWidget extends Disposable { this._expanded.set(next, undefined); } + private _setExpanded(toolSetId: string, expanded: boolean): void { + const next = new Set(this._expanded.get()); + if (expanded === next.has(toolSetId)) { + return; + } + if (expanded) { + next.add(toolSetId); + } else { + next.delete(toolSetId); + } + this._expanded.set(next, undefined); + } + + // --- Tree keyboard navigation --- + + private _isExpanded(setRow: ITreeRow): boolean { + return setRow.group!.style.display !== 'none'; + } + + /** Rows the user can currently land on: all set rows plus tool rows inside expanded sets, in tree order. */ + private _visibleRows(): ITreeRow[] { + return this._rows.filter(r => r.kind === 'set' || this._isExpanded(r.parent!)); + } + + /** Keep a single roving `tabIndex=0` on the given row so the tree is one tab stop. */ + private _setRovingRow(row: ITreeRow): void { + for (const r of this._rows) { + r.element.tabIndex = r === row ? 0 : -1; + } + this._activeRowId = row.rowId; + } + + private _focusRow(row: ITreeRow): void { + this._setRovingRow(row); + row.element.focus(); + } + + /** Resolve the row owning a focus/keyboard target by walking up to a known row element. */ + private _rowFromTarget(target: HTMLElement | null): ITreeRow | undefined { + for (let el = target; el && el !== this._treeContainer; el = el.parentElement) { + const row = this._rowByElement.get(el); + if (row) { + return row; + } + } + return undefined; + } + + /** After a (re)render, restore the roving tabIndex to the previously active row, else the first row. */ + private _initRovingTabIndex(refocus = false): void { + let active = this._activeRowId ? this._rows.find(r => r.rowId === this._activeRowId) : undefined; + if (!active || (active.kind === 'tool' && !this._isExpanded(active.parent!))) { + active = this._visibleRows()[0]; + } + for (const r of this._rows) { + r.element.tabIndex = r === active ? 0 : -1; + } + this._activeRowId = active?.rowId; + if (refocus && active) { + active.element.focus(); + } + } + + private _onTreeKeyDown(e: IKeyboardEvent): void { + const row = this._rowFromTarget(e.target); + if (!row) { + return; + } + let handled = true; + switch (e.keyCode) { + case KeyCode.DownArrow: + this._focusRelative(row, 1); + break; + case KeyCode.UpArrow: + this._focusRelative(row, -1); + break; + case KeyCode.RightArrow: + handled = this._onExpandKey(row); + break; + case KeyCode.LeftArrow: + handled = this._onCollapseKey(row); + break; + case KeyCode.Home: + this._focusEdge(true); + break; + case KeyCode.End: + this._focusEdge(false); + break; + case KeyCode.Space: + case KeyCode.Enter: + // Reuse the row's checkbox wiring; disabled (read-only) checkboxes ignore the click. + row.toggleNode.click(); + break; + default: + handled = false; + } + if (handled) { + e.preventDefault(); + e.stopPropagation(); + } + } + + private _focusRelative(row: ITreeRow, delta: number): void { + const rows = this._visibleRows(); + const index = rows.indexOf(row); + const next = index === -1 ? undefined : rows[index + delta]; + if (next) { + this._focusRow(next); + } + } + + private _focusEdge(first: boolean): void { + const rows = this._visibleRows(); + this._focusRow(first ? rows[0] : rows[rows.length - 1]); + } + + /** Right arrow: expand a collapsed set, or move into its first child when already expanded. */ + private _onExpandKey(row: ITreeRow): boolean { + if (row.kind !== 'set') { + return false; + } + if (!this._isExpanded(row)) { + this._setExpanded(row.toolSetId, true); + } else if (row.children!.length) { + this._focusRow(row.children![0]); + } + return true; + } + + /** Left arrow: collapse an expanded set, or move a tool row up to its parent set. */ + private _onCollapseKey(row: ITreeRow): boolean { + if (row.kind === 'set') { + if (this._isExpanded(row)) { + this._setExpanded(row.toolSetId, false); + return true; + } + return false; + } + this._focusRow(row.parent!); + return true; + } + private _currentState(): IToolEnablementState { return this._enablementService.getState(this._sessionType); } -} + /** Resolve the installed, non-builtin extension backing an extension-provided tool set. */ + private _resolveExtensionForToolSet(ts: IToolSet): IExtension | undefined { + if (ts.source.type !== 'extension') { + return undefined; + } + const source = ts.source; + const extension = this._extensionsWorkbenchService.local.find(e => ExtensionIdentifier.equals(e.identifier.id, source.extensionId)); + if (!extension || extension.local?.isBuiltin) { + return undefined; + } + return extension; + } + + private _onGalleryContextMenu(e: IListContextMenuEvent<IExtension>): void { + const extension = e.element; + if (!extension || extension.state !== ExtensionState.Installed || extension.local?.isBuiltin) { + return; + } + this._showExtensionContextMenu(e.anchor, extension); + } + + private _showExtensionContextMenu(anchor: HTMLElement | StandardMouseEvent | IAnchor, extension: IExtension): void { + const disposables = new DisposableStore(); + const uninstallAction = disposables.add(new Action( + 'toolsList.uninstallExtension', + localize('uninstallExtension', "Uninstall Extension"), + undefined, + true, + () => this._uninstallExtension(extension), + )); + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => [uninstallAction], + onHide: () => disposables.dispose(), + }); + } + + private async _uninstallExtension(extension: IExtension): Promise<void> { + const result = await this._dialogService.confirm({ + message: localize('confirmUninstallToolExtension', "Do you want to uninstall the extension '{0}'?", extension.displayName), + detail: localize('confirmUninstallToolExtensionDetail', "This extension may contribute more than tools. Uninstalling it removes all of its contributions."), + primaryButton: localize('uninstallExtensionBtn', "Uninstall Extension"), + type: 'question', + }); + if (result.confirmed) { + await this._extensionsWorkbenchService.uninstall(extension); + } + } +} /** * The Copilot CLI's built-in tools, surfaced read-only for reference. Mirrored from the published diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts index 5794ff454d52c2..4d6c8c9395e368 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentWidgets.ts @@ -70,8 +70,7 @@ import { IChatContentReference } from '../../common/chatService/chatService.js'; import { coerceImageBuffer } from '../../common/chatImageExtraction.js'; import { ChatConfiguration } from '../../common/constants.js'; import { getImageAttachmentLimit, IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IBrowserViewVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js'; -import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, isAutoLanguageModel } from '../../common/languageModels.js'; import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { getCleanPromptName } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatContextService } from '../contextContrib/chatContextService.js'; @@ -230,7 +229,13 @@ abstract class AbstractChatAttachmentWidget extends Disposable { } function modelSupportsVision(currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined) { - return currentLanguageModel?.metadata.capabilities?.vision ?? false; + return isAutoLanguageModel(currentLanguageModel) || (currentLanguageModel?.metadata.capabilities?.vision ?? false); +} + +export function getEffectiveImageOmittedState(omittedState: OmittedState | undefined, currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, isCurrentInput: boolean | undefined): OmittedState | undefined { + return isAutoLanguageModel(currentLanguageModel) && isCurrentInput && omittedState === OmittedState.Full + ? OmittedState.NotOmitted + : omittedState; } @@ -481,7 +486,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { resource: URI | undefined, attachment: IChatRequestVariableEntry, currentLanguageModel: ILanguageModelChatMetadataAndIdentifier | undefined, - options: { shouldFocusClearButton: boolean; supportsDeletion: boolean }, + options: { shouldFocusClearButton: boolean; supportsDeletion: boolean; isCurrentInput?: boolean }, container: HTMLElement, contextResourceLabels: ResourceLabels, @ICommandService commandService: ICommandService, @@ -491,7 +496,6 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @IInstantiationService instantiationService: IInstantiationService, @ILabelService private readonly labelService: ILabelService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, @IChatImageCarouselService private readonly chatImageCarouselService: IChatImageCarouselService, @IFileDialogService private readonly fileDialogService: IFileDialogService, @IFileService private readonly fileService: IFileService, @@ -500,13 +504,21 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService, configurationService); this.element.classList.add('image-attachment'); + const isAutoModel = isAutoLanguageModel(currentLanguageModel); + const modelName = currentLanguageModel?.metadata.name; + const omittedState = getEffectiveImageOmittedState(attachment.omittedState, currentLanguageModel, options.isCurrentInput); + this.element.classList.toggle('auto-image-warning', isAutoModel); let ariaLabel: string; - if (attachment.omittedState === OmittedState.Full) { + if (omittedState === OmittedState.Full && modelName && !modelSupportsVision(currentLanguageModel)) { + ariaLabel = localize('chat.unsupportedImageAttachment', "Image not sent because {0} does not support images: {1}", modelName, attachment.name); + } else if (omittedState === OmittedState.Full) { ariaLabel = localize('chat.omittedImageAttachment', "Omitted this image: {0}", attachment.name); - } else if (attachment.omittedState === OmittedState.Partial) { + } else if (omittedState === OmittedState.Partial) { ariaLabel = localize('chat.partiallyOmittedImageAttachment', "Partially omitted this image: {0}", attachment.name); - } else if (attachment.omittedState === OmittedState.ImageLimitExceeded) { + } else if (omittedState === OmittedState.ImageLimitExceeded) { ariaLabel = localize('chat.imageLimitExceededAttachment', "Image not sent due to limit: {0}", attachment.name); + } else if (isAutoModel) { + ariaLabel = localize('chat.autoImageAttachment', "Attached image, {0}. Image support depends on the model selected by Auto.", attachment.name); } else { ariaLabel = localize('chat.imageAttachment', "Attached image, {0}", attachment.name); } @@ -516,7 +528,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const imageData = coerceImageBuffer(attachment.value); const clickHandler = async () => { if ((resource || imageData) && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled)) { - await this.openInCarousel(attachment.id, attachment.name, imageData, resource); + await this.openInCarousel(attachment.id, attachment.name, imageData, resource, options.isCurrentInput); } else if (resource) { await this.openResource(resource, { editorOptions: { preserveFocus: true } }, false, undefined); } @@ -525,7 +537,7 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : 'Current model'; const fullName = resource ? this.labelService.getUriLabel(resource) : (attachment.fullName || attachment.name); - this._register(createImageElements(resource, attachment.name, fullName, this.element, imageData ?? (attachment.value as Uint8Array), attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); + this._register(createImageElements(resource, attachment.name, fullName, this.element, imageData ?? (attachment.value as Uint8Array), attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, omittedState)); this.attachSaveButton(resource, imageData, attachment.name, options.supportsDeletion); this.element.ariaLabel = this.appendDeletionHint(ariaLabel); @@ -545,9 +557,9 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget { } } - private async openInCarousel(id: string, name: string, data: Uint8Array | undefined, referenceUri: URI | undefined): Promise<void> { + private async openInCarousel(id: string, name: string, data: Uint8Array | undefined, referenceUri: URI | undefined, preferCurrentInput: boolean | undefined): Promise<void> { const resource = referenceUri ?? URI.from({ scheme: 'data', path: `${id}/${encodeURIComponent(name)}` }); - await this.chatImageCarouselService.openCarouselAtResource(resource, data); + await this.chatImageCarouselService.openCarouselAtResource(resource, data, { preferCurrentInput }); } private attachSaveButton(resource: URI | undefined, imageData: Uint8Array | undefined, name: string, supportsDeletion: boolean): void { @@ -602,8 +614,7 @@ function createImageElements(resource: URI | undefined, name: string, fullName: currentLanguageModelName: string | undefined, clickHandler: () => void, currentLanguageModel?: ILanguageModelChatMetadataAndIdentifier, - omittedState?: OmittedState, - previewFeaturesDisabled?: boolean): IDisposable { + omittedState?: OmittedState): IDisposable { const disposable = new DisposableStore(); if (omittedState === OmittedState.Partial) { @@ -617,7 +628,7 @@ function createImageElements(resource: URI | undefined, name: string, fullName: element.style.cursor = 'pointer'; } const supportsVision = modelSupportsVision(currentLanguageModel); - const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$((supportsVision && !previewFeaturesDisabled) ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); + const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$(supportsVision ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning')); const textLabel = dom.$('span.chat-attached-context-custom-text', {}, name); element.appendChild(pillIcon); element.appendChild(textLabel); @@ -632,14 +643,7 @@ function createImageElements(resource: URI | undefined, name: string, fullName: const hoverElement = dom.$('div.chat-attached-context-hover'); hoverElement.setAttribute('aria-label', ariaLabel); - if (previewFeaturesDisabled) { - element.classList.add('warning'); - hoverElement.textContent = localize('chat.imageAttachmentPreviewFeaturesDisabled', "Vision is disabled by your organization."); - disposable.add(hoverService.setupDelayedHover(element, { - content: hoverElement, - style: HoverStyle.Pointer, - })); - } else if ((!supportsVision && currentLanguageModel) || omittedState === OmittedState.Full) { + if ((!supportsVision && currentLanguageModel) || omittedState === OmittedState.Full) { element.classList.add('warning'); hoverElement.textContent = localize('chat.imageAttachmentHover', "{0} does not support images.", currentLanguageModelName ?? 'This model'); disposable.add(hoverService.setupDelayedHover(element, { @@ -666,6 +670,10 @@ function createImageElements(resource: URI | undefined, name: string, fullName: const imageContainer = dom.$('div.chat-attached-context-image-container', {}, hoverImage); hoverElement.appendChild(imageContainer); + if (isAutoLanguageModel(currentLanguageModel)) { + hoverElement.appendChild(dom.$('div', undefined, localize('chat.autoImageAttachmentHover', "Image support depends on the model selected by Auto."))); + } + if (resource) { const urlContainer = dom.$('a.chat-attached-context-url', {}, omittedState === OmittedState.Partial ? localize('chat.imageAttachmentWarning', "This GIF was partially omitted - current frame will be sent.") : fullName); const separator = dom.$('div.chat-attached-context-url-separator'); @@ -1039,7 +1047,6 @@ export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachme @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @INotebookService private readonly notebookService: INotebookService, @IInstantiationService private readonly instantiationService: IInstantiationService, - @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(attachment, options, container, contextResourceLabels, currentLanguageModel, commandService, openerService, configurationService); @@ -1100,7 +1107,7 @@ export class NotebookCellOutputChatAttachmentWidget extends AbstractChatAttachme const clickHandler = async () => await this.openResource(resource, { editorOptions: { preserveFocus: true } }, false, undefined); const currentLanguageModelName = this.currentLanguageModel ? this.languageModelsService.lookupLanguageModel(this.currentLanguageModel.identifier)?.name ?? this.currentLanguageModel.identifier : undefined; const buffer = this.getOutputItem(resource, attachment)?.data.buffer ?? new Uint8Array(); - this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled)); + this._register(createImageElements(resource, attachment.name, attachment.name, this.element, buffer, attachment.id, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState)); this.element.ariaLabel = this.appendDeletionHint(ariaLabel); } diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts index 1e8d4fe6c22830..6bfd28fe8d9a6d 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatDynamicVariables.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { coalesce } from '../../../../../base/common/arrays.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Disposable, dispose, isDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -27,6 +28,17 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC private _variables: IDynamicVariable[] = []; + private readonly _onDidChangeReferences = this._register(new Emitter<void>()); + /** + * Fires whenever the set of dynamic-variable references changes (added, + * removed, moved, or restored). Consumers that render UI derived from the + * references should listen to this instead of relying on + * `onDidChangeParsedInput`, which does not fire when a reference is added + * without changing the parsed request (e.g. a `/command` reference that the + * parser resolves as a slash-prompt part). + */ + readonly onDidChangeReferences: Event<void> = this._onDidChangeReferences.event; + get variables(): ReadonlyArray<IDynamicVariable> { return [...this._variables]; } @@ -108,6 +120,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC if (didChange || removed.length > 0) { this.widget.refreshParsedInput(); + this._onDidChangeReferences.fire(); } this.updateDecorations(); @@ -144,6 +157,7 @@ export class ChatDynamicVariableModel extends Disposable implements IChatWidgetC this._variables.push(ref); this.updateDecorations(); this.widget.refreshParsedInput(); + this._onDidChangeReferences.fire(); } private updateDecorations(): void { diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts index 4fdafd056ba8f2..184f2263f4f3d6 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatImplicitContext.ts @@ -248,8 +248,8 @@ export class ChatImplicitContextContribution extends Disposable implements IWork } const webviewEditor = this.findActiveWebviewEditor(); - if (webviewEditor?.input?.resource) { - const webviewContext = await this.chatContextService.contextForResource(webviewEditor.input.resource); + if (webviewEditor?.input instanceof WebviewInput && webviewEditor.input.resource) { + const webviewContext = await this.chatContextService.contextForResource(webviewEditor.input.resource, undefined, webviewEditor.input.viewType); if (webviewContext) { newValue = webviewContext; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 6f2ba717a91b64..f3a2b318472b3b 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -9,13 +9,14 @@ import { Schemas } from '../../../../base/common/network.js'; import { autorun, observableFromEvent } from '../../../../base/common/observable.js'; import { isMacintosh } from '../../../../base/common/platform.js'; import { PolicyCategory } from '../../../../base/common/policy.js'; -import '../../../../platform/agentHost/common/agentHost.config.contribution.js'; -import '../../../../platform/agentHost/browser/agentHost.config.contribution.js'; +import '../../../../platform/agentHost/common/agentHostEnablementService.js'; +import '../../../../platform/agentHost/browser/agentHostEnablementService.js'; import '../../../../platform/agentHost/common/agentHostStarter.config.contribution.js'; -import { AgentHostAhpJsonlLoggingSettingId, AgentHostCustomTerminalToolEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostSdkSandboxEnabledSettingId, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostAhpJsonlLoggingSettingId, AgentHostSdkSandboxEnabledSettingId, ClaudePreferAgentHostAgentsSettingId, ClaudePreferAgentHostEditorSettingId } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostCustomTerminalToolEnabledSettingId, AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId } from '../../../../platform/agentHost/common/copilotCliConfig.js'; import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../../../platform/networkFilter/common/networkFilterService.js'; import { AgentNetworkDomainSettingId } from '../../../../platform/networkFilter/common/settings.js'; -import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY, COPILOT_ENABLED_PLUGINS_KEY, COPILOT_EXTRA_MARKETPLACES_KEY, COPILOT_MODEL_KEY, COPILOT_STRICT_MARKETPLACES_KEY, managedModelValue, managedSettingValue } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../platform/sandbox/common/settings.js'; import { registerEditorFeature } from '../../../../editor/common/editorFeatures.js'; import * as nls from '../../../../nls.js'; @@ -60,7 +61,7 @@ import { ChatTransferService, IChatTransferService } from '../common/model/chatT import { LocalAgentDisabledInputTipContribution } from './agentSessions/localAgentDisabledInputTipContribution.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; +import { BYOKUtilityModelDefault, ChatAgentLocation, ChatConfiguration, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; @@ -170,6 +171,7 @@ import { ChatImplicitContextContribution } from './attachments/chatImplicitConte import './widget/input/editor/chatInputCompletions.js'; import './widget/input/editor/agentHostInputCompletions.js'; import './widget/input/editor/chatInputEditorContrib.js'; +import './widget/input/editor/chatInputCommandArgumentHint.js'; import './widget/input/editor/chatInputEditorHover.js'; import { LanguageModelToolsConfirmationService } from './tools/languageModelToolsConfirmationService.js'; import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; @@ -210,6 +212,7 @@ import { ExploreAgentDefaultModel } from './exploreAgentDefaultModel.js'; import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; import { UtilityModelContribution, UtilitySmallModelContribution } from './utilityModelContribution.js'; import { ChatImageCarouselService, IChatImageCarouselService } from './chatImageCarouselService.js'; +import { AgentHostImportConversationStore, IAgentHostImportConversationStore } from './agentSessions/agentHost/agentHostImportConversationStore.js'; CommandsRegistry.registerCommand('_chat.notifyQuestionCarouselAnswer', (accessor: ServicesAccessor, resolveId: string, answers?: import('../common/chatService/chatService.js').IChatQuestionAnswers) => { accessor.get(IChatService).notifyQuestionCarouselAnswer('', resolveId, answers); @@ -326,6 +329,13 @@ configurationRegistry.registerConfiguration({ default: true, agentsWindow: { default: false }, }, + 'chat.implicitContext.includeActiveEditor': { + type: 'boolean', + markdownDescription: nls.localize('chat.implicitContext.includeActiveEditor', "When enabled, the active editor is automatically forwarded as context, even when it would otherwise only be suggested. Selections and explicitly attached files are always included regardless of this setting.\n\nNote: this setting currently only applies to Agent Host sessions (such as the Copilot CLI)."), + default: true, + tags: ['experimental'], + agentsWindow: { default: false }, + }, 'chat.editing.autoAcceptDelay': { type: 'number', markdownDescription: nls.localize('chat.editing.autoAcceptDelay', "Delay after which changes made by chat are automatically accepted. Values are in seconds, `0` means disabled and `100` seconds is the maximum."), @@ -538,11 +548,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatDefaultModel', category: PolicyCategory.InteractiveSession, minimumVersion: '1.127', - value: (policyData) => { - const model = policyData.managedSettings?.[COPILOT_MODEL_KEY]; - const trimmed = typeof model === 'string' ? model.trim() : undefined; - return trimmed ? trimmed : undefined; - }, + value: managedModelValue(), managedSettings: { [COPILOT_MODEL_KEY]: { type: 'string' }, }, @@ -671,12 +677,6 @@ configurationRegistry.registerConfiguration({ }, } }, - 'chat.sendElementsToChat.attachImages': { - default: true, - markdownDescription: nls.localize('chat.sendElementsToChat.attachImages', "Controls whether a screenshot of the selected element will be added to the chat."), - type: 'boolean', - tags: ['experimental'] - }, [ChatConfiguration.ArtifactsEnabled]: { default: false, description: nls.localize('chat.artifacts.enabled', "Controls whether the artifacts view is available in chat."), @@ -778,7 +778,7 @@ configurationRegistry.registerConfiguration({ }, [ClaudePreferAgentHostAgentsSettingId]: { type: 'boolean', - description: nls.localize('chat.agents.claude.preferAgentHost', "When enabled, Claude sessions opened from the Agents Window run inside the agent host process instead of the GitHub Copilot Chat extension. Only one Claude implementation surfaces per window."), + markdownDescription: nls.localize('chat.agents.claude.preferAgentHost', "When enabled, Claude sessions opened from the Agents Window run inside the agent host process instead of the GitHub Copilot Chat extension. Only one Claude implementation surfaces per window. Requires `#chat.agentHost.enabled#`."), default: false, tags: ['experimental'], experiment: { mode: 'startup' }, @@ -826,6 +826,24 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.checkpoints.showFileChanges', "Controls whether to show chat checkpoint file changes."), default: false }, + [ChatConfiguration.TurnStatusPills]: { + type: 'object', + markdownDescription: nls.localize('chat.turnStatusPills', "Controls which agent turn status pills are shown above the chat input while a turn is in progress and inside the completed response. Only applies to agent sessions."), + properties: { + changes: { + type: 'boolean', + default: false, + description: nls.localize('chat.turnStatusPills.changes', "Show a pill summarizing the files changed and the lines added and removed in the turn."), + }, + preview: { + type: 'boolean', + default: false, + description: nls.localize('chat.turnStatusPills.preview', "Show a pill to preview a Markdown or HTML file created or edited in the turn."), + }, + }, + default: { changes: false, preview: false }, + additionalProperties: false, + }, [mcpAccessConfig]: { type: 'string', description: nls.localize('chat.mcp.access', "Controls access to installed Model Context Protocol servers."), @@ -1021,7 +1039,7 @@ configurationRegistry.registerConfiguration({ [ChatConfiguration.EnabledPlugins]: { type: 'object', additionalProperties: { type: 'boolean' }, - markdownDescription: nls.localize('chat.plugins.enabledPlugins', "Controls which [agent plugins](https://aka.ms/vscode-agent-plugins) are enabled or disabled. Keys are plugin IDs in `<plugin>@<marketplace>` form (where marketplace is defined in {1}); values enable (`true`) or disable (`false`) the plugin. Discovered alongside the path-keyed entries in {0}. When set by policy, only plugins mapped to `true` here are allowed to load.", `\`#${ChatConfiguration.PluginLocations}#\``, `\`#${ChatConfiguration.PluginMarketplaces}#\``), + markdownDescription: nls.localize('chat.plugins.enabledPlugins', "Controls which [agent plugins](https://aka.ms/vscode-agent-plugins) are enabled or disabled. Keys are plugin IDs in `<plugin>@<marketplace>` form (where marketplace is defined in {1}); values enable (`true`) or disable (`false`) the plugin. Discovered alongside the path-keyed entries in {0}. When set by policy, entries are additive: plugins mapped to `true` are enabled in addition to the user's own plugins, and only plugins mapped to `false` are blocked from loading.", `\`#${ChatConfiguration.PluginLocations}#\``, `\`#${ChatConfiguration.PluginMarketplaces}#\``), scope: ConfigurationScope.APPLICATION, policy: { name: 'ChatEnabledPlugins', @@ -1243,6 +1261,27 @@ configurationRegistry.registerConfiguration({ default: false, tags: ['experimental', 'advanced'], }, + [AgentHostReasoningEffortOverrideSettingId]: { + type: 'string', + markdownDescription: nls.localize('chat.agentHost.reasoningEffortOverride', "Overrides the reasoning effort for Copilot SDK agent sessions regardless of the per-model picker value. Set it to a level the selected model supports (for example `low`, `medium`, `high`, or `xhigh`) — choosing a level the model does not support may be rejected by the model. A value that isn't a recognized effort level is ignored and the session falls back to the picker value. Applied when a session is created and when its model changes. Only affects Copilot CLI agent sessions.\n\n**Note**: This is an advanced setting for experimentation."), + default: '', + tags: ['experimental', 'advanced'], + }, + [AgentHostModelCapabilityOverridesSettingId]: { + type: 'object', + markdownDescription: nls.localize('chat.agentHost.modelCapabilityOverrides', "Per-model capability overrides for Copilot SDK agent sessions, keyed by model id, intended for evaluating preview models against an existing model's profile. For each model id, declare an aliased `family` (for example `claude-opus-4-8`) to route the model to that family's tuned system prompt without a code change; the model id sent to the runtime is unaffected. Only affects Copilot CLI agent sessions.\n\n**Note**: This is an advanced setting for experimentation."), + additionalProperties: { + type: 'object', + properties: { + family: { + type: 'string', + description: nls.localize('chat.agentHost.modelCapabilityOverrides.family', "Alias the model's family for prompt/capability routing (e.g. `claude-opus-4-8`)."), + }, + }, + }, + default: {}, + tags: ['experimental', 'advanced'], + }, [AgentHostSdkSandboxEnabledSettingId]: { type: 'string', enum: [AgentSandboxEnabledValue.Off, AgentSandboxEnabledValue.On, AgentSandboxEnabledValue.AllowNetwork], @@ -1296,9 +1335,25 @@ configurationRegistry.registerConfiguration({ enumItemLabels: ExploreAgentDefaultModel.modelLabels, markdownEnumDescriptions: ExploreAgentDefaultModel.modelDescriptions }, + [ChatConfiguration.BYOKUtilityModelDefault]: { + type: 'string', + markdownDescription: nls.localize('chat.byokUtilityModelDefault.description', "Controls the default model used by built-in utility flows when the selected main agent model is a bring your own key (BYOK) model. This setting has no effect when the selected main agent model is provided by GitHub Copilot. A specific model configured in {0} or {1} takes precedence.", '`#chat.utilityModel#`', '`#chat.utilitySmallModel#`'), + enum: [BYOKUtilityModelDefault.None, BYOKUtilityModelDefault.MainAgent, BYOKUtilityModelDefault.Copilot], + enumItemLabels: [ + nls.localize('chat.byokUtilityModelDefault.none.label', "None"), + nls.localize('chat.byokUtilityModelDefault.mainAgent.label', "Main Agent Model"), + nls.localize('chat.byokUtilityModelDefault.copilot.label', "GitHub Copilot"), + ], + markdownEnumDescriptions: [ + nls.localize('chat.byokUtilityModelDefault.none.description', "Do not use a default utility model."), + nls.localize('chat.byokUtilityModelDefault.mainAgent.description', "Use the selected BYOK main agent model."), + nls.localize('chat.byokUtilityModelDefault.copilot.description', "Use the default GitHub Copilot utility models."), + ], + default: BYOKUtilityModelDefault.None, + }, [ChatConfiguration.UtilityModel]: { type: 'string', - description: nls.localize('chat.utilityModel.description', "Override the language model used by built-in utility flows. Leave empty to use the default model."), + description: nls.localize('chat.utilityModel.description', "Override the language model used by built-in utility flows. Leave empty to use the configured default behavior."), default: '', enum: UtilityModelContribution.modelIds, enumItemLabels: UtilityModelContribution.modelLabels, @@ -1306,7 +1361,7 @@ configurationRegistry.registerConfiguration({ }, [ChatConfiguration.UtilitySmallModel]: { type: 'string', - description: nls.localize('chat.utilitySmallModel.description', "Override the language model used by built-in small/fast utility flows. A fast and inexpensive model is recommended. Leave empty to use the default model."), + description: nls.localize('chat.utilitySmallModel.description', "Override the language model used by built-in small/fast utility flows. A fast and inexpensive model is recommended. Leave empty to use the configured default behavior."), default: '', enum: UtilitySmallModelContribution.modelIds, enumItemLabels: UtilitySmallModelContribution.modelLabels, @@ -1925,6 +1980,16 @@ Registry.as<IConfigurationMigrationRegistry>(Extensions.ConfigurationMigration). ['chat.detectParticipant.enabled', { value: value !== false }] ]) }, + { + key: 'chat.useCopilotModelsForUtilityModels', + migrateFn: (value: unknown, valueAccessor) => { + const result: ConfigurationKeyValuePairs = [['chat.useCopilotModelsForUtilityModels', { value: undefined }]]; + if (typeof value === 'boolean' && valueAccessor(ChatConfiguration.BYOKUtilityModelDefault) === undefined) { + result.push([ChatConfiguration.BYOKUtilityModelDefault, { value: value ? BYOKUtilityModelDefault.Copilot : BYOKUtilityModelDefault.None }]); + } + return result; + } + }, { key: 'chat.useClaudeSkills', migrateFn: (value, _accessor) => ([ @@ -2573,5 +2638,6 @@ registerSingleton(IPlanReviewFeedbackService, PlanReviewFeedbackService, Instant registerSingleton(IChatTipService, ChatTipService, InstantiationType.Delayed); registerSingleton(IChatDebugService, ChatDebugServiceImpl, InstantiationType.Delayed); registerSingleton(IChatImageCarouselService, ChatImageCarouselService, InstantiationType.Delayed); +registerSingleton(IAgentHostImportConversationStore, AgentHostImportConversationStore, InstantiationType.Delayed); ChatWidget.CONTRIBS.push(ChatDynamicVariableModel); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index e112944deabb46..e3fec1e1a8863c 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -39,6 +39,23 @@ export interface IWorkspacePickerItem { readonly isFolder: boolean; } +/** + * Narrow contract for a workspace picker hosted as a chip in + * {@link ChatInputPart}'s primary toolbar. Implementers own all selection + * state; the chip is purely a rendering / interaction surface. + * + * Used by the automations dialog so a single picker instance can drive both + * the form-row trigger and a toolbar chip without duplicating state. + */ +export interface IChatInputWorkspacePicker { + /** + * Renders a trigger chip into the given container. The returned disposable + * removes only this trigger; sibling triggers (e.g. the form-row trigger + * in the automations dialog) keep working. + */ + renderTrigger(container: HTMLElement): IDisposable; +} + /** * Delegate interface for the workspace picker. * Allows consumers to get and set the target workspace for chat submissions in empty window contexts. @@ -103,6 +120,14 @@ export interface ISessionTypePickerDelegate { * Used to gate cloud delegation which requires a GitHub repository. */ hasGitRepository?(): boolean; + /** + * Optional visibility filter for the session-type dropdown. When + * provided, the picker hides any session type for which this returns + * `false`. Use to scope the dropdown to a host-specific subset (e.g. + * the automations dialog, which only supports a handful of session + * types). When omitted, every contributed session type is shown. + */ + isSessionTypeVisible?(type: AgentSessionTarget): boolean; } export const IChatWidgetService = createDecorator<IChatWidgetService>('chatWidgetService'); @@ -336,6 +361,10 @@ export interface IChatAcceptInputOptions { * If Steering, also sets yieldRequested on any active request to signal it should wrap up. */ queue?: ChatRequestQueueKind; + /** + * Cancels the current request before sending this message instead of falling back to queueing. + */ + cancelCurrentRequest?: boolean; preserveFocus?: boolean; } diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 886e06f0f3ab4e..a637e64587e5ec 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -448,7 +448,7 @@ registerAction2(class RemoveAction extends Action2 { mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, }, - when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate()), + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate(), ChatContextKeys.readOnly.negate()), weight: KeybindingWeight.WorkbenchContrib, }, menu: [ @@ -456,7 +456,7 @@ registerAction2(class RemoveAction extends Action2 { id: MenuId.ChatMessageTitle, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input').negate(), ContextKeyExpr.equals(`config.${ChatConfiguration.CheckpointsEnabled}`, false), ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession)), + when: ContextKeyExpr.and(ContextKeyExpr.equals(`config.${ChatConfiguration.EditRequests}`, 'input').negate(), ContextKeyExpr.equals(`config.${ChatConfiguration.CheckpointsEnabled}`, false), ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), ChatContextKeys.readOnly.negate()), } ] }); @@ -503,7 +503,7 @@ registerAction2(class RestoreCheckpointAction extends Action2 { mac: { primary: KeyMod.CtrlCmd | KeyCode.Backspace, }, - when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate()), + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, EditorContextKeys.textInputFocus.negate(), ChatContextKeys.inChatQuestionCarousel.negate(), ChatContextKeys.readOnly.negate()), weight: KeybindingWeight.WorkbenchContrib, }, menu: [ @@ -511,7 +511,7 @@ registerAction2(class RestoreCheckpointAction extends Action2 { id: MenuId.ChatMessageCheckpoint, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), ChatContextKeys.isFirstRequest.negate()) + when: ContextKeyExpr.and(ChatContextKeys.isRequest, ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), ChatContextKeys.isFirstRequest.negate(), ChatContextKeys.readOnly.negate()) } ] }); @@ -558,7 +558,7 @@ registerAction2(class StartOverAction extends Action2 { id: MenuId.ChatMessageCheckpoint, group: 'navigation', order: 2, - when: ContextKeyExpr.and(ChatContextKeys.isRequest, ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), ChatContextKeys.isFirstRequest) + when: ContextKeyExpr.and(ChatContextKeys.isRequest, ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), ChatContextKeys.isFirstRequest, ChatContextKeys.readOnly.negate()) } ] }); @@ -592,7 +592,8 @@ registerAction2(class RestoreLastCheckpoint extends Action2 { precondition: ContextKeyExpr.and( ChatContextKeys.inChatSession, ContextKeyExpr.equals(`config.${ChatConfiguration.CheckpointsEnabled}`, true), - ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession) + ContextKeyExpr.or(ChatContextKeys.lockedToCodingAgent.negate(), ChatContextKeyExprs.isAgentHostSession), + ChatContextKeys.readOnly.negate() ) }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts b/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts index c8bc1f13180ed2..3e779c5a96d9e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatImageCarouselService.ts @@ -14,7 +14,8 @@ import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IFileService } from '../../../../platform/files/common/files.js'; -import { extractImagesFromChatRequest, extractImagesFromChatResponse, IChatExtractedImage } from '../common/chatImageExtraction.js'; +import type { IChatRequestVariableEntry } from '../common/attachments/chatVariableEntries.js'; +import { extractImagesFromChatRequest, extractImagesFromChatResponse, extractImagesFromChatVariables, IChatExtractedImage } from '../common/chatImageExtraction.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM, isResponseVM } from '../common/model/chatViewModel.js'; import { IChatWidgetService } from './chat.js'; @@ -30,7 +31,7 @@ export interface IChatImageCarouselService { * @param resource The URI of the clicked image to start the carousel at. * @param data Optional raw image data (e.g. for input attachment images that are Uint8Arrays). */ - openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void>; + openCarouselAtResource(resource: URI, data?: Uint8Array, options?: { readonly preferCurrentInput?: boolean }): Promise<void>; } //#region Carousel data types @@ -76,6 +77,7 @@ export interface ICarouselSingleImageArgs { export async function collectCarouselSections( items: (IChatRequestViewModel | IChatResponseViewModel)[], readFile: (uri: URI) => Promise<Uint8Array>, + currentInput?: { readonly text: string; readonly attachments: readonly IChatRequestVariableEntry[] }, ): Promise<ICarouselSection[]> { const sections: ICarouselSection[] = []; @@ -126,6 +128,16 @@ export async function collectCarouselSections( } } + if (currentInput) { + const inputImages = deduplicateConsecutiveImages(extractImagesFromChatVariables(currentInput.attachments)); + if (inputImages.length > 0) { + sections.push({ + title: currentInput.text.trim() || localize('chatImageCarousel.currentInput', "Current Input"), + images: inputImages.map(({ uri, name, mimeType, data, caption }) => ({ id: uri.toString(), name, mimeType, data: data.buffer, caption: toCaptionText(caption) })) + }); + } + } + return sections; } @@ -162,7 +174,17 @@ export function findClickedImageIndex( sections: ICarouselSection[], resource: URI, data?: Uint8Array, + preferredSectionIndex?: number, ): number { + if (preferredSectionIndex !== undefined && preferredSectionIndex >= 0 && preferredSectionIndex < sections.length) { + const preferredSection = sections[preferredSectionIndex]; + const uriIndex = findImageInListByUri(preferredSection.images, resource); + const localIndex = uriIndex >= 0 ? uriIndex : (data ? findImageInListByData(preferredSection.images, data) : -1); + if (localIndex >= 0) { + return sections.slice(0, preferredSectionIndex).reduce((total, section) => total + section.images.length, 0) + localIndex; + } + } + let globalOffset = 0; for (const section of sections) { @@ -270,7 +292,7 @@ export class ChatImageCarouselService implements IChatImageCarouselService { @IFileService private readonly fileService: IFileService, ) { } - async openCarouselAtResource(resource: URI, data?: Uint8Array): Promise<void> { + async openCarouselAtResource(resource: URI, data?: Uint8Array, options?: { readonly preferCurrentInput?: boolean }): Promise<void> { const widget = this.chatWidgetService.lastFocusedWidget; if (!widget?.viewModel) { await this.openSingleImage(resource, data); @@ -282,7 +304,13 @@ export class ChatImageCarouselService implements IChatImageCarouselService { ); const readFile = async (uri: URI) => (await this.fileService.readFile(uri)).value.buffer; const sections = await collectCarouselSections(items, readFile); - const clickedGlobalIndex = findClickedImageIndex(sections, resource, data); + const currentInputSections = await collectCarouselSections([], readFile, { + text: widget.getInput(), + attachments: widget.attachmentModel.attachments, + }); + const preferredSectionIndex = options?.preferCurrentInput && currentInputSections.length > 0 ? sections.length : undefined; + sections.push(...currentInputSections); + const clickedGlobalIndex = findClickedImageIndex(sections, resource, data, preferredSectionIndex); if (clickedGlobalIndex === -1 || sections.length === 0) { await this.openSingleImage(resource, data); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 04c6c176ccfc7e..07f18dbe8eed1c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -561,7 +561,7 @@ class ModelNameColumnRenderer extends ModelsTableColumnRenderer<IModelNameColumn templateData.deprecationLink.link = { label, href: resolveProviderDeprecationLink(deprecationLink, this.productService.urlProtocol).toString(), - title: localize('models.deprecation.link.tooltip', "This provider is deprecated. Migrate to the official extension.") + title: localize('models.deprecation.link.tooltip', "The Ollama model provider is deprecated. Please migrate to the official extension.") }; templateData.deprecationLinkContainer.style.display = ''; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts index b76ec6816c993a..04dae55b93d1e2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessionPickerActionItem.ts @@ -15,6 +15,7 @@ import { IKeybindingService } from '../../../../../platform/keybinding/common/ke import { ActionWidgetDropdownActionViewItem } from '../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem } from '../../common/chatSessionsService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { renderIcon } from '../../../../../base/browser/ui/iconLabel/iconLabels.js'; @@ -55,6 +56,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI @ICommandService protected readonly commandService: ICommandService, @ITelemetryService telemetryService: ITelemetryService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IOpenerService private readonly openerService: IOpenerService, ) { const { group, item } = initialState; const actionWithLabel: IAction = { @@ -190,7 +192,7 @@ export class ChatSessionPickerActionItem extends ActionWidgetDropdownActionViewI isDefaultForLocation: {}, }, }; - const hover = getModelHoverContent(syntheticModel, isUBB); + const hover = getModelHoverContent(syntheticModel, isUBB, undefined, this.openerService); if (hover) { return { content: hover.element, disposable: hover.disposable }; } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index cc9af9d6739866..87e17bf0c1b4a9 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -42,6 +42,7 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { IAgentHostImportConversationStore, type IAgentHostImportConversation } from '../agentSessions/agentHost/agentHostImportConversationStore.js'; import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { getChatSessionType, isUntitledChatSession, LocalChatSessionUri } from '../../common/model/chatUri.js'; @@ -961,6 +962,12 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return provider.provideChatInputCompletions(sessionResource, params, token); } + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string { + const sessionType = getChatSessionType(sessionResource); + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; + return this._contentProviders.get(resolvedType)?.resolveChatResponseUri?.(sessionResource, href, kind) ?? href; + } + async getChatInputCompletionTriggerCharacters(sessionType: string): Promise<readonly string[] | undefined> { const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const provider = this._contentProviders.get(resolvedType); @@ -1162,7 +1169,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const sessionType = getChatSessionType(sessionResource); if (!(await raceCancellationError(this.canResolveChatSession(sessionType), token))) { - throw Error(`Can not find provider for ${sessionResource}`); + throw Error(`Cannot find provider '${sessionType}'`); } // Check again after async provider resolution @@ -1176,7 +1183,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const provider = this._contentProviders.get(resolvedType); if (!provider) { - throw Error(`Can not find provider for ${sessionResource}`); + throw Error(`Cannot find provider '${resolvedType}'`); } let session: IChatSession; @@ -1494,6 +1501,12 @@ type NewChatSessionSendOptions = { readonly prompt: string; readonly attachedContext?: IChatRequestVariableEntry[]; readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; + /** + * A prior conversation to seed into the new session as real, editable turns + * ("Continue in…" migration). Consumed once when the backend session is + * created; see {@link IAgentHostImportConversationStore}. + */ + readonly importConversation?: IAgentHostImportConversation; }; export type NewChatSessionOpenOptions = { @@ -1512,10 +1525,18 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const editorService = accessor.get(IEditorService); const customizationHarnessService = accessor.get(ICustomizationHarnessService); const toolsService = accessor.get(ILanguageModelToolsService); + const importConversationStore = accessor.get(IAgentHostImportConversationStore); // Determine resource to open const sessionResource = getResourceForNewChatSession(openOptions); + // Stash any imported ("Continue in…") conversation before the session is + // opened: opening can eagerly pre-create the backend session (via the chat + // input picker), which consumes this to seed the turns as editable history. + if (chatSendOptions?.importConversation && chatSendOptions.importConversation.turns.length > 0) { + importConversationStore.set(sessionResource, chatSendOptions.importConversation); + } + // Open chat session try { switch (openOptions.position) { @@ -1533,6 +1554,7 @@ export async function openChatSession(accessor: ServicesAccessor, openOptions: N const options: IChatEditorOptions = { override: ChatEditorInput.EditorID, pinned: true, + ...(openOptions.type === AgentSessionProviders.Local ? { explicitSessionType: localChatSessionType } : {}), title: { fallback: localize('chatEditorContributionName', "{0}", openOptions.displayName), } diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css index 21013c41fbd470..9bb645da0ecc0d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/media/chatSessionPickerActionItem.css @@ -26,6 +26,7 @@ .chat-session-option-label { overflow: hidden; text-overflow: ellipsis; + margin-left: 6px; } span.codicon { diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index c8c88d2ea5addd..745d25d656621e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -239,19 +239,36 @@ export class ChatStatusDashboard extends DomWidget { const includedTitle = this.chatEntitlementService.quotas.usageBasedBilling ? localize('includedTitleTBB', "Credits") : localize('includedTitle', "Premium Requests"); + const getIncludedDescription = () => { + if (isPooledQuotaDepleted) { + return { + compact: localize('premiumLimitReachedCompact', "{0} limit reached.", includedTitle), + default: localize('premiumLimitReached', "Organization limit reached.") + }; + } + + if (typeof premiumChat?.creditsUsed === 'number') { + return { + compact: localize('premiumCreditsUsedCompact', "{0} used", this.quotaCreditsFormatter.value.format(premiumChat.creditsUsed)), + default: localize('premiumCreditsUsed', "{0} used", this.quotaCreditsFormatter.value.format(premiumChat.creditsUsed)) + }; + } + + return { + compact: localize('premiumIncludedCompact', "{0} included with your organization's plan.", includedTitle), + default: localize('premiumIncluded', "Included with your organization's plan.") + }; + }; + const includedDescription = getIncludedDescription(); const includedContainer = this.element.appendChild($('div.quota-indicator.included')); if (this.options?.compactQuotaLayout) { const planName = getChatPlanName(this.chatEntitlementService.entitlement); includedContainer.classList.add('compact'); includedContainer.appendChild($('div.quota-title', undefined, planName)); - includedContainer.appendChild($('div.description', undefined, isPooledQuotaDepleted - ? localize('premiumLimitReachedCompact', "{0} limit reached.", includedTitle) - : localize('premiumIncludedCompact', "{0} included with your organization's plan.", includedTitle))); + includedContainer.appendChild($('div.description', undefined, includedDescription.compact)); } else { includedContainer.appendChild($('div.quota-title', undefined, includedTitle)); - includedContainer.appendChild($('div.description', undefined, isPooledQuotaDepleted - ? localize('premiumLimitReached', "Organization limit reached.") - : localize('premiumIncluded', "Included with your organization's plan."))); + includedContainer.appendChild($('div.description', undefined, includedDescription.default)); } } diff --git a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContextService.ts b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContextService.ts index e85c5ff1a5a466..ddab51273fbc7c 100644 --- a/src/vs/workbench/contrib/chat/browser/contextContrib/chatContextService.ts +++ b/src/vs/workbench/contrib/chat/browser/contextContrib/chatContextService.ts @@ -20,12 +20,22 @@ export const IChatContextService = createDecorator<IChatContextService>('chatCon export interface IChatContextService extends ChatContextService { } +/** + * A selector describing which tabs a resource context provider applies to. Either a + * {@link LanguageSelector} matched against a resource's URI, or a webview `viewType`. + */ +export type ChatTabSelector = { uri: LanguageSelector } | { viewType: string }; + +function isViewTypeTabSelector(selector: ChatTabSelector): selector is { viewType: string } { + return (selector as { viewType?: string }).viewType !== undefined; +} + interface IChatContextProviderEntry { picker?: { title: string; icon: ThemeIcon }; workspaceProvider?: IChatWorkspaceContextProvider; explicitProvider?: IChatExplicitContextProvider; resourceProvider?: { - selector: LanguageSelector; + selector: ChatTabSelector; provider: IChatResourceContextProvider; }; } @@ -86,7 +96,7 @@ export class ChatContextService extends Disposable { this._registerWithPickService(id); } - registerChatResourceContextProvider(id: string, selector: LanguageSelector, provider: IChatResourceContextProvider): void { + registerChatResourceContextProvider(id: string, selector: ChatTabSelector, provider: IChatResourceContextProvider): void { const providerEntry = this._providers.get(id) ?? {}; providerEntry.resourceProvider = { selector, provider }; this._providers.set(id, providerEntry); @@ -122,17 +132,20 @@ export class ChatContextService extends Disposable { return items; } - async contextForResource(uri: URI, language?: string): Promise<StringChatContextValue | undefined> { - return this._contextForResource(uri, false, language); + async contextForResource(uri: URI, language?: string, viewType?: string): Promise<StringChatContextValue | undefined> { + return this._contextForResource(uri, false, language, viewType); } - private async _contextForResource(uri: URI, withValue: boolean, language?: string): Promise<StringChatContextValue | undefined> { + private async _contextForResource(uri: URI, withValue: boolean, language?: string, viewType?: string): Promise<StringChatContextValue | undefined> { const scoredProviders: Array<{ score: number; provider: IChatResourceContextProvider }> = []; for (const providerEntry of this._providers.values()) { if (!providerEntry.resourceProvider) { continue; } - const matchScore = score(providerEntry.resourceProvider.selector, uri, language ?? '', true, undefined, undefined); + const selector = providerEntry.resourceProvider.selector; + const matchScore = isViewTypeTabSelector(selector) + ? (viewType !== undefined && selector.viewType === viewType ? 10 : 0) + : score(selector.uri, uri, language ?? '', true, undefined, undefined); scoredProviders.push({ score: matchScore, provider: providerEntry.resourceProvider.provider }); } scoredProviders.sort((a, b) => b.score - a.score); @@ -140,7 +153,7 @@ export class ChatContextService extends Disposable { return; } const provider = scoredProviders[0].provider; - const context = (await provider.provideChatContext(uri, withValue, CancellationToken.None)); + const context = (await provider.provideChatContext(uri, withValue, viewType, CancellationToken.None)); if (!context) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/media/chatTurnPills.css b/src/vs/workbench/contrib/chat/browser/media/chatTurnPills.css new file mode 100644 index 00000000000000..5cae11b63bca74 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/media/chatTurnPills.css @@ -0,0 +1,162 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Pills reflecting a single turn's status (changes + preview). Shared between + the floating widget above the chat input and the completed chat response. This + file styles the pills themselves; callers position the `.chat-turn-pills` + toolbar element. */ + +.chat-turn-pills, +.chat-turn-pills .monaco-toolbar, +.chat-turn-pills .monaco-action-bar, +.chat-turn-pills .monaco-action-bar .actions-container { + display: flex; + align-items: center; + min-width: 0; +} + +.chat-turn-pills.hidden { + display: none; +} + +/* Space the pills evenly next to each other. */ +.chat-turn-pills .monaco-action-bar .actions-container { + gap: 6px; +} + +/* Changes pill: <icon> N Files +x -y, rendered as a compact secondary button. + Its sizing/colors come from the standard `.monaco-text-button.small.secondary` + styles; only inline layout is set here. */ +.chat-turn-pill-changes { + display: inline-flex; + align-items: center; + flex-shrink: 0; +} + +.chat-turn-pill-changes .monaco-button.chat-turn-pill-changes-button { + display: inline-flex; + width: auto; + gap: 4px; + font-variant-numeric: tabular-nums; + white-space: nowrap; + touch-action: manipulation; +} + +/* Hug the border with the focus ring instead of the base `outline-offset: 2px`, + which reads as a bloated ring on such a small pill. */ +.chat-turn-pill-changes .monaco-button.chat-turn-pill-changes-button:focus { + outline-offset: 0 !important; +} + +.monaco-workbench .chat-turn-pill-meta-icon.codicon[class*='codicon-'] { + display: inline-flex; + align-items: center; + justify-content: center; + width: var(--vscode-codiconFontSize-compact, 12px); + height: var(--vscode-codiconFontSize-compact, 12px); + margin: 0; + font-size: var(--vscode-codiconFontSize-compact, 12px); + flex-shrink: 0; +} + +.chat-turn-pill-meta-added { + color: var(--vscode-chat-linesAddedForeground); +} + +.chat-turn-pill-meta-removed { + color: var(--vscode-chat-linesRemovedForeground); +} + +/* Preview pill: a single unified control — a resource label (file icon + name + + "preview" description), an in-pill separator, and a dropdown chevron. The pill + background/border live on the container so the inner (transparent) buttons read + as one pill; each inner button is an independent, tab-focusable click zone. The + 3-class selector beats `.monaco-action-bar .action-item { display: block }`. */ +.chat-turn-pills .monaco-action-bar .chat-turn-pill-preview { + display: inline-flex; + align-items: stretch; + flex-shrink: 0; + /* Match the height of the changes pill (a small secondary button). */ + height: 22px; + max-width: 280px; + border-radius: 4px; + color: var(--vscode-button-secondaryForeground); + background-color: var(--vscode-button-secondaryBackground); + border: 1px solid var(--vscode-button-secondaryBorder, transparent); + overflow: hidden; +} + +.chat-turn-pill-preview .monaco-button { + touch-action: manipulation; +} + +/* Left zone: the resource label (file icon + name + "preview"). The height is + set by the pill, so no vertical padding here. */ +.chat-turn-pill-preview .monaco-button.chat-turn-pill-preview-primary { + display: inline-flex; + align-items: center; + width: auto; + min-width: 0; + padding: 0 8px; + overflow: hidden; +} + +.chat-turn-pill-preview-primary .monaco-icon-label { + /* Center the icon and the name/description together — the label is a flex + row whose file icon `::before` is a fixed 22px tall, so without this the + shorter text sits at the top of the stretched row rather than on the icon's + vertical center. */ + align-items: center; + min-width: 0; + overflow: hidden; + /* Match the changes pill, which uses the `.monaco-text-button.small` size. */ + font-size: 11px; +} + +.chat-turn-pill-preview .monaco-button.chat-turn-pill-preview-primary:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* In-pill separator between the label and the chevron. A 2px vertical margin + keeps it centered with a small gap at the top and bottom rather than spanning + the full pill height. */ +.chat-turn-pill-preview-separator { + width: 1px; + flex-shrink: 0; + margin: 2px 0; + background-color: var(--vscode-button-secondaryBorder, var(--vscode-menu-separatorBackground)); + opacity: 0.6; +} + +.chat-turn-pill-preview-separator.hidden { + display: none; +} + +/* Right zone: the dropdown chevron. The button stretches to the pill's full + height so its hover background spans top to bottom; the icon child stays + centered. */ +.chat-turn-pill-preview .monaco-button.chat-turn-pill-preview-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + padding: 0 5px; +} + +.chat-turn-pill-preview .monaco-button.chat-turn-pill-preview-chevron:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.chat-turn-pill-preview .monaco-button.chat-turn-pill-preview-chevron.hidden { + display: none; +} + +/* Keyboard focus indicator: an inset ring so it stays visible inside the pill's + `overflow: hidden` rounded corners. */ +.chat-turn-pill-preview .monaco-button:focus, +.chat-turn-pill-preview .monaco-button:focus-visible { + outline: none; + box-shadow: inset 0 0 0 1px var(--vscode-focusBorder); +} diff --git a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts index 46cc7a297abada..6e856b176505c7 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginInstallService.ts @@ -7,6 +7,8 @@ import { Action } from '../../../../base/common/actions.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { CancellationError } from '../../../../base/common/errors.js'; +import { untildify } from '../../../../base/common/labels.js'; +import { posix, win32 } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -17,6 +19,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IProgressService, ProgressLocation } from '../../../../platform/progress/common/progress.js'; import { IQuickInputService, IQuickPickItem } from '../../../../platform/quickinput/common/quickInput.js'; +import { IPathService } from '../../../services/path/common/pathService.js'; import { IAgentPluginRepositoryService } from '../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../common/constants.js'; import { IPluginInstallService, IInstallPluginFromSourceOptions, IInstallPluginFromSourceResult, IUpdateAllPluginsOptions, IUpdateAllPluginsResult } from '../common/plugins/pluginInstallService.js'; @@ -36,6 +39,7 @@ export class PluginInstallService implements IPluginInstallService { @ICommandService private readonly _commandService: ICommandService, @IQuickInputService private readonly _quickInputService: IQuickInputService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @IPathService private readonly _pathService: IPathService, ) { } async installPlugin(plugin: IMarketplacePlugin): Promise<void> { @@ -58,60 +62,29 @@ export class PluginInstallService implements IPluginInstallService { return this._installGitPlugin(plugin); } - async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void> { - const reference = parseMarketplaceReference(source); - if (!reference) { - this._notificationService.notify({ - severity: Severity.Error, - message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source), - }); - return; - } - - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - this._notificationService.notify({ - severity: Severity.Error, - message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."), - }); - return; - } - - const result = await this._doInstallFromSource(reference, options); - if (!result.success && result.message) { - this._notificationService.notify({ - severity: Severity.Error, - message: result.message, - }); - } - } - validatePluginSource(source: string): string | undefined { const reference = parseMarketplaceReference(source); - if (!reference) { - return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source); - } - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - return localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."); + if (reference || this._isLocalPathSource(source)) { + return undefined; } - return undefined; + return localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", source); } - async installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> { + async installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> { const reference = parseMarketplaceReference(source); - if (!reference) { - return { - success: false, - message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo) or a git clone URL.", source), - }; + if (reference && reference.kind !== MarketplaceReferenceKind.LocalFileUri) { + return this._doInstallFromSource(reference, options); } - if (reference.kind === MarketplaceReferenceKind.LocalFileUri) { - return { - success: false, - message: localize('localSourceNotSupported', "Local file paths are not supported. Enter a GitHub repository (owner/repo) or a git clone URL."), - }; + + const local = await this._resolveLocalDirectorySource(source); + if (local) { + return this._doInstallFromLocalSource(local.reference, local.configPath, options); } - return this._doInstallFromSource(reference, options); + return { + success: false, + message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", source), + }; } private async _doInstallFromSource(reference: IMarketplaceReference, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> { @@ -191,6 +164,79 @@ export class PluginInstallService implements IPluginInstallService { } // When targeting a specific plugin, find it, register it, and return. + return this._installDiscoveredPlugins(reference, discoveredPlugins, options); + } + + /** + * Installs a plugin from a local folder path (`file://` URI, absolute path, + * or `~`-prefixed path). Inspects the directory to decide whether it is a + * marketplace or a standalone plugin and writes to the appropriate setting: + * - a marketplace is registered under `chat.plugins.marketplaces`, + * - a standalone plugin path is registered under `chat.pluginLocations`. + */ + private async _doInstallFromLocalSource(reference: IMarketplaceReference, configPath: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> { + const repoDir = reference.localRepositoryUri; + if (!repoDir) { + return { + success: false, + message: localize('invalidSource', "'{0}' is not a valid plugin source. Enter a GitHub repository (owner/repo), a git clone URL, or a local folder path.", reference.rawValue), + }; + } + + let isDirectory = false; + try { + isDirectory = (await this._fileService.resolve(repoDir)).isDirectory; + } catch { + // resolve throws when the path doesn't exist — handled below. + } + if (!isDirectory) { + return { + success: false, + message: localize('localSourceNotFound', "The folder '{0}' does not exist or is not a directory.", repoDir.fsPath), + }; + } + + // A directory with a marketplace index is registered as a marketplace. + const discoveredPlugins = await this._pluginMarketplaceService.readPluginsFromDirectory(repoDir, reference); + if (discoveredPlugins.length > 0) { + // Verify trust before writing to config, mirroring the git path + // (_doInstallFromSource): declining the prompt must not persist the + // marketplace under `chat.plugins.marketplaces`. + const tempPlugin: IMarketplacePlugin = { + name: reference.displayLabel, + description: '', + version: '', + source: '', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: reference.displayLabel, + marketplaceReference: reference, + marketplaceType: MarketplaceType.OpenPlugin, + }; + if (!await this._ensureMarketplaceTrusted(tempPlugin)) { + return { success: false }; + } + return this._installDiscoveredPlugins(reference, discoveredPlugins, options); + } + + // Otherwise, a directory with a single-plugin manifest is registered as + // a standalone plugin location. + if (await this._pluginMarketplaceService.isPluginDirectory(repoDir)) { + await this._addPluginLocationToConfig(configPath); + return { success: true }; + } + + return { + success: false, + message: localize('localNoPlugins', "No plugin or marketplace found in '{0}'. This folder does not contain a plugin or marketplace manifest.", repoDir.fsPath), + }; + } + + /** + * Registers the marketplace and installs the discovered plugin(s): when a + * specific plugin is targeted it installs that one, when there is exactly + * one it installs it directly, and otherwise prompts the user to choose. + */ + private async _installDiscoveredPlugins(reference: IMarketplaceReference, discoveredPlugins: readonly IMarketplacePlugin[], options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult> { if (options?.plugin) { const matchedPlugin = discoveredPlugins.find(p => p.name === options.plugin); if (!matchedPlugin) { @@ -241,6 +287,73 @@ export class PluginInstallService implements IPluginInstallService { return this._configurationService.updateValue(ChatConfiguration.PluginMarketplaces, [...userValues, reference.rawValue]); } + private _addPluginLocationToConfig(pathKey: string) { + const current = this._configurationService.inspect<Record<string, boolean>>(ChatConfiguration.PluginLocations).userValue ?? {}; + if (current[pathKey] === true) { + return; + } + return this._configurationService.updateValue(ChatConfiguration.PluginLocations, { ...current, [pathKey]: true }); + } + + /** + * Returns `true` when the source string looks like a local folder path — + * a `file://` URI, an absolute filesystem path, or a `~`-prefixed path. + * This is a synchronous format check only; existence is verified later. + */ + private _isLocalPathSource(source: string): boolean { + const trimmed = source.trim(); + if (!trimmed) { + return false; + } + if (/^file:\/\//i.test(trimmed)) { + return true; + } + if (trimmed === '~' || trimmed.startsWith('~/') || trimmed.startsWith('~\\')) { + return true; + } + return win32.isAbsolute(trimmed) || posix.isAbsolute(trimmed); + } + + /** + * Resolves a local folder source string to a {@link MarketplaceReferenceKind.LocalFileUri} + * reference plus the path to persist in `chat.pluginLocations`. Tilde paths + * are expanded against the user home. Returns `undefined` when the string + * does not resolve to an absolute local folder. + */ + private async _resolveLocalDirectorySource(source: string): Promise<{ reference: IMarketplaceReference; configPath: string } | undefined> { + const trimmed = source.trim(); + + // Already a `file://` URI — parseMarketplaceReference yields a LocalFileUri reference. + const parsed = parseMarketplaceReference(trimmed); + if (parsed?.kind === MarketplaceReferenceKind.LocalFileUri && parsed.localRepositoryUri) { + return { reference: parsed, configPath: parsed.localRepositoryUri.fsPath }; + } + + if (!this._isLocalPathSource(trimmed)) { + return undefined; + } + + let resolvedPath = trimmed; + if (resolvedPath.startsWith('~')) { + const userHome = await this._pathService.userHome(); + const home = userHome.scheme === 'file' ? userHome.fsPath : userHome.path; + resolvedPath = untildify(resolvedPath, home); + } + + if (!win32.isAbsolute(resolvedPath) && !posix.isAbsolute(resolvedPath)) { + return undefined; + } + + const reference = parseMarketplaceReference(URI.file(resolvedPath).toString()); + if (reference?.kind !== MarketplaceReferenceKind.LocalFileUri) { + return undefined; + } + + // Preserve the user's original path form (e.g. `~/plugins/foo`) so that + // the persisted `chat.pluginLocations` key stays portable. + return { reference, configPath: trimmed }; + } + async updatePlugin(plugin: IMarketplacePlugin, silent?: boolean): Promise<boolean> { const kind = plugin.sourceDescriptor.kind; diff --git a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts index 54beb11dc687a0..b14379fa33b233 100644 --- a/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/pluginUrlHandler.ts @@ -13,6 +13,7 @@ import { ConfigurationTarget, IConfigurationService } from '../../../../platform import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; 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 { IURLHandler, IURLService } from '../../../../platform/url/common/url.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -46,6 +47,7 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi @IExtensionsWorkbenchService private readonly _extensionsWorkbenchService: IExtensionsWorkbenchService, @IHostService private readonly _hostService: IHostService, @ILogService private readonly _logService: ILogService, + @INotificationService private readonly _notificationService: INotificationService, @IEditorService private readonly _editorService: IEditorService, @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { @@ -110,7 +112,10 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi return this._handleInstallTargetedPlugin(source, ref.displayLabel, pluginName); } - await this._pluginInstallService.installPluginFromSource(source); + const result = await this._pluginInstallService.installPluginFromSource(source); + if (!result.success && result.message) { + this._notificationService.notify({ severity: Severity.Error, message: result.message }); + } this._extensionsWorkbenchService.openSearch(`@agentPlugins ${ref.displayLabel}`); return true; } @@ -121,7 +126,7 @@ export class PluginUrlHandler extends Disposable implements IWorkbenchContributi * then opens the plugin details in a modal editor. */ private async _handleInstallTargetedPlugin(source: string, displayLabel: string, pluginName: string): Promise<boolean> { - const result = await this._pluginInstallService.installPluginFromValidatedSource(source, { plugin: pluginName }); + const result = await this._pluginInstallService.installPluginFromSource(source, { plugin: pluginName }); if (!result.success) { if (result.message) { diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index ea8724e0e58b80..57134ef087d732 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -167,7 +167,7 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string * Note! this is a temporary solution and must be replaced with a dialog to select * a custom folder path, or switch to a different prompt type */ -async function showNoFoldersDialog(accessor: ServicesAccessor, type: PromptsType): Promise<void> { +export async function showNoFoldersDialog(accessor: ServicesAccessor, type: PromptsType): Promise<void> { const quickInputService = accessor.get(IQuickInputService); const openerService = accessor.get(IOpenerService); diff --git a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts index 2b08e0c7fede7e..a75272f83aa3c0 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/clientToolSetsContribution.ts @@ -36,18 +36,21 @@ export class ClientToolSetsContribution extends Disposable implements IWorkbench ) { super(); - this._register(this._registerDynamicToolSet(toolsService, { - id: 'vscode-tasks', - referenceName: 'vscodeTasks', - icon: Codicon.tasklist, - description: localize('clientToolSet.tasks.description', "Tasks"), - detail: localize('clientToolSet.tasks.detail', "Create and run tasks defined in your workspace."), - members: [ - 'createAndRunTask', - 'runTask', - 'getTaskOutput', - ], - })); + if (!workspaceService.isSessionsWindow) { + this._register(this._registerDynamicToolSet(toolsService, { + id: 'vscode-tasks', + referenceName: 'vscodeTasks', + icon: Codicon.tasklist, + description: localize('clientToolSet.tasks.description', "Tasks and Problems"), + detail: localize('clientToolSet.tasks.detail', "Create and run tasks and inspect workspace problems."), + members: [ + 'createAndRunTask', + 'runTask', + 'getTaskOutput', + 'problems', + ], + })); + } this._register(this._registerDynamicToolSet(toolsService, { id: 'vscode-browser', @@ -67,7 +70,6 @@ export class ClientToolSetsContribution extends Disposable implements IWorkbench members: [ 'runTests', 'testFailure', - 'problems', 'rename', 'usages', 'extensions', diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 8eab5457275ec7..e65da00df74433 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -617,10 +617,18 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo const { autoConfirmed: resolvedAutoConfirmed, preparedInvocation: updatedPreparedInvocation } = await this.resolveAutoConfirmFromHook(preToolUseHookResult, tool, dto, preparedInvocation, dto.context?.sessionResource); preparedInvocation = updatedPreparedInvocation; + // A caller (e.g. the agent host) may have resolved auto-approval + // out-of-band. Treat it like a local auto-confirmation so the + // invocation never briefly enters `WaitingForConfirmation`. A + // preToolUse hook that returned `ask` explicitly forces a + // confirmation, so never let `preApproved` override it. + const preResolvedAutoConfirmed = resolvedAutoConfirmed + ?? (preToolUseHookResult?.permissionDecision === 'ask' ? undefined : dto.preApproved); + // In Autopilot, run the risk classifier on an auto-approved call that would // otherwise show a confirmation. A "red" rating skips the call; anything else // (including a classifier failure) keeps the original auto-confirmation. - const { autoConfirmed, skipExplanation: riskSkipExplanation } = await this._maybeApplyAutopilotRiskGate(tool, dto, preparedInvocation, resolvedAutoConfirmed, token); + const { autoConfirmed, skipExplanation: riskSkipExplanation } = await this._maybeApplyAutopilotRiskGate(tool, dto, preparedInvocation, preResolvedAutoConfirmed, token); // Important: a tool invocation that will be autoconfirmed should never // be in the chat response in the `NeedsConfirmation` state, even briefly, diff --git a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts index d1c607ae4d487d..821a7203cccbb2 100644 --- a/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/utilityModelContribution.ts @@ -10,12 +10,10 @@ import { ChatConfiguration } from '../common/constants.js'; import { COPILOT_VENDOR_ID, ILanguageModelsService } from '../common/languageModels.js'; import { createDefaultModelArrays, DefaultModelContribution } from './defaultModelContribution.js'; -// The empty value for these settings means "use the built-in utility-family -// default" (i.e. whatever the copilot-utility / copilot-utility-small family -// resolves to via CAPI), not a vendor-specific default. Use setting-specific -// copy so the Settings UI doesn't say "Auto (Vendor Default)". +// The empty value uses the default utility behavior: a built-in model for a +// Copilot main model, or no model for BYOK unless the user opts in. const defaultEntryLabel = localize('chat.utilityModel.defaultEntry.label', 'Default'); -const defaultEntryDescription = localize('chat.utilityModel.defaultEntry.description', "Use the built-in default utility model"); +const defaultEntryDescription = localize('chat.utilityModel.defaultEntry.description', "Use the default behavior for utility models"); const utilityArrays = createDefaultModelArrays(defaultEntryLabel, defaultEntryDescription); const utilitySmallArrays = createDefaultModelArrays(defaultEntryLabel, defaultEntryDescription); diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts index 5951d12b5e4177..de34569d9ed576 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/micCaptureService.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { addDisposableListener } from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -91,6 +92,9 @@ export interface IMicCaptureService { /** Fired when a PTT segment ends. All chunks have been sent before this fires. */ readonly onPttEnd: Event<void>; + /** Base64 raw PCM16 chunks during barge-in monitoring (not turn input). */ + readonly onMonitorAudioChunk: Event<string>; + /** * Fired after the diagnostic window closes (~1s after `pttUp`) with * per-press telemetry. Always fires AFTER `onPttEnd` for normal @@ -119,6 +123,21 @@ export interface IMicCaptureService { */ pttUp(): void; + /** + * Abort the current PTT segment WITHOUT firing ``onPttEnd`` and WITHOUT + * tearing down the warm mic. Used when the backend ends the turn itself + * (server VAD silence / stop phrase): streaming stops immediately for this + * press so no further audio is shipped, but no client ``ptt_end`` is + * emitted for the turn. Safe to call when no press is active. + */ + abortPtt(): void; + + /** Stream mic audio for barge-in detection (no PTT turn, no AEC gating). */ + startMonitor(window: Window & typeof globalThis): Promise<void>; + + /** Stop barge-in monitoring. Releases the mic only if no PTT press is active. */ + stopMonitor(): void; + // --- Mute / AEC suppression --- isMuted: boolean; @@ -148,7 +167,16 @@ export class MicCaptureService extends Disposable implements IMicCaptureService private _isMuted = false; private _suppressUntilTs = 0; private _pttAcquiring = false; + private _capturePromise: Promise<void> | undefined; private _pttReleasedDuringAcquire = false; + private _monitoring = false; + + // --- Hardware mute detection. --- + // A hardware microphone kill switch (e.g. on Framework laptops) leaves + // `getUserMedia` succeeding with a track whose `muted` flag is set, so no + // acquisition error surfaces. Track the mute state to warn the user. + private readonly _micTrackListeners = this._register(new DisposableStore()); + private _micMutedNotified = false; // --- Drain state (post-release continued streaming). --- // Drain length is enforced primarily by counting samples shipped @@ -191,6 +219,9 @@ export class MicCaptureService extends Disposable implements IMicCaptureService private readonly _onPttEnd = this._register(new Emitter<void>()); readonly onPttEnd: Event<void> = this._onPttEnd.event; + private readonly _onMonitorAudioChunk = this._register(new Emitter<string>()); + readonly onMonitorAudioChunk: Event<string> = this._onMonitorAudioChunk.event; + private readonly _onPttDiagnostic = this._register(new Emitter<IPttDiagnostic>()); readonly onPttDiagnostic: Event<IPttDiagnostic> = this._onPttDiagnostic.event; @@ -293,10 +324,71 @@ export class MicCaptureService extends Disposable implements IMicCaptureService this._scheduleDiagnosticFire(); } + abortPtt(): void { + if (!this._pttHeld && !this._pttStreaming) { return; } + // Cancel any in-flight drain and stop streaming immediately. Unlike + // `pttUp()` this runs NO post-release drain and fires NO `_onPttEnd`: + // the backend already ended the turn, so we must not ship more audio + // for it nor emit our own ptt_end. The mic/AudioContext stays warm for + // the next press. + if (this._pttDrainFallbackTimer) { + clearTimeout(this._pttDrainFallbackTimer); + this._pttDrainFallbackTimer = undefined; + } + this._pttDrainTargetSamples = 0; + this._pttDrainSamplesSent = 0; + this._pttHeld = false; + this._pttStreaming = false; + this._pttReleasedDuringAcquire = false; + // Still emit the per-press diagnostic (keyed by turnId), matching pttUp. + this._diagPttUpTs = Date.now(); + this._scheduleDiagnosticFire(); + } + + async startMonitor(window: Window & typeof globalThis): Promise<void> { + this._window = window; + if (this._monitoring) { return; } + this._monitoring = true; + if (!this._isCapturing) { + try { + await this.startCapture(window); + } catch (err) { + // Rethrow so the controller resets its _bargeInMonitorActive flag. + this._monitoring = false; + this.logService.warn('[mic] barge-in monitor could not acquire microphone', err); + throw err; + } + // Cancelled mid-acquire: release the mic we just opened. + if (!this._monitoring && !this._pttHeld && !this._pttStreaming) { + this.stopCapture(); + } + } + } + + stopMonitor(): void { + if (!this._monitoring) { return; } + this._monitoring = false; + // Keep the mic if a PTT press is using it. + if (!this._pttHeld && !this._pttStreaming) { + this.stopCapture(); + } + } + async startCapture(window: Window & typeof globalThis): Promise<void> { this._window = window; if (this._isCapturing) { return; } + // Serialize concurrent acquisitions (monitor + PTT) into one getUserMedia. + if (this._capturePromise) { return this._capturePromise; } + this._capturePromise = this._acquireCapture(window); + try { + await this._capturePromise; + } finally { + this._capturePromise = undefined; + } + } + private async _acquireCapture(window: Window & typeof globalThis): Promise<void> { + if (this._isCapturing) { return; } const deviceId = this.storageService.get(AgentsVoiceStorageKeys.MicrophoneDevice, StorageScope.APPLICATION); const audioConstraints: MediaTrackConstraints = { channelCount: 1, @@ -336,6 +428,20 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } this._micStream = micStream; + // Detect a hardware-muted microphone (e.g. a physical kill switch). + // `getUserMedia` succeeds in this case but the track produces silence, + // so without this check PTT would appear to work while capturing nothing. + this._micTrackListeners.clear(); + this._micMutedNotified = false; + const audioTrack = micStream.getAudioTracks()[0]; + if (audioTrack) { + if (audioTrack.muted) { + this._notifyMicrophoneMuted(); + } + this._micTrackListeners.add(addDisposableListener(audioTrack, 'mute', () => this._notifyMicrophoneMuted())); + this._micTrackListeners.add(addDisposableListener(audioTrack, 'unmute', () => { this._micMutedNotified = false; })); + } + if (!this._micCtx) { this._micCtx = new window.AudioContext({ sampleRate: 16000 }); } @@ -373,6 +479,15 @@ export class MicCaptureService extends Disposable implements IMicCaptureService if (isPostReleaseCallback) { this._diagPostReleaseSkippedByMute++; } return; } + + // Barge-in monitor: stream raw audio during playback (not a turn, + // not AEC-gated) so the backend can detect the user talking over it. + if (this._monitoring) { + const monitorData = e.inputBuffer.getChannelData(0); + const monitorSamples = new Float32Array(monitorData); + this._onMonitorAudioChunk.fire(encodeRawPcm16Base64(monitorSamples, this._window!)); + } + if (nowTs < this._suppressUntilTs) { if (isDrainCallback) { this._diagDrainSkippedBySuppression++; } if (isPostReleaseCallback) { this._diagPostReleaseSkippedBySuppression++; } @@ -422,6 +537,18 @@ export class MicCaptureService extends Disposable implements IMicCaptureService } } + private _notifyMicrophoneMuted(): void { + if (this._micMutedNotified) { + return; + } + this._micMutedNotified = true; + this.logService.warn('[mic] Microphone track is muted — likely a hardware mute switch is enabled'); + this.notificationService.notify({ + severity: Severity.Warning, + message: localize('mic.hardwareMuted', "Your microphone appears to be muted or disabled, possibly by a hardware switch. Voice Mode won't hear you until it's re-enabled."), + }); + } + stopCapture(): void { // Cancel any in-flight drain; do NOT fire `_onPttEnd` here // because callers (reconnect / disconnect / dispose) have @@ -444,10 +571,13 @@ export class MicCaptureService extends Disposable implements IMicCaptureService this._micStream.getTracks().forEach(t => t.stop()); this._micStream = null; } + this._micTrackListeners.clear(); + this._micMutedNotified = false; this._isCapturing = false; this._pttHeld = false; this._pttStreaming = false; this._pttReleasedDuringAcquire = false; + this._monitoring = false; } override dispose(): void { diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts index b6866055183d87..7acbcd52ff4352 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceClientService.ts @@ -19,6 +19,9 @@ import { IVoiceSpeechStarted, IVoiceSessionInit, IVoiceFeedbackPayload, + IVoiceTurnConfig, + IVoiceTurnAutoEnded, + IVoiceTurnAutoEndReason, } from '../../common/voiceClient/voiceClientService.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; @@ -76,6 +79,9 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic private readonly _onDidChangeConnectionState = this._register(new Emitter<boolean>()); readonly onDidChangeConnectionState: Event<boolean> = this._onDidChangeConnectionState.event; + private readonly _onTurnAutoEnded = this._register(new Emitter<IVoiceTurnAutoEnded>()); + readonly onTurnAutoEnded: Event<IVoiceTurnAutoEnded> = this._onTurnAutoEnded.event; + get isConnected(): boolean { return this._isConnected; } @@ -96,6 +102,70 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic @IProductService private readonly _productService: IProductService, ) { super(); + + // Push turn-endpointing settings to the backend live. Takes effect on + // the next push-to-talk press (the backend never mutates an in-flight + // press). When disconnected this no-ops; the latest config rides along + // on the next ``start_session`` / ``resume_session`` instead. + this._register(this._configurationService.onDidChangeConfiguration(e => { + if ( + e.affectsConfiguration('agents.voice.turn.autoEndMode') || + e.affectsConfiguration('agents.voice.turn.silenceMs') || + e.affectsConfiguration('agents.voice.turn.stopPhrases') || + e.affectsConfiguration('agents.voice.turn.vadGateAsr') + ) { + this._sendSetTurnConfig(); + } + if (e.affectsConfiguration('agents.voice.voice')) { + this._sendSetVoice(); + } + })); + } + + /** + * Resolve the configured voice key (e.g. ``maya_neutral``) sent to the + * backend on ``start_session`` and via ``set_voice`` when changed live. + */ + private _getVoice(): string { + const raw = this._configurationService.getValue<string>('agents.voice.voice'); + return typeof raw === 'string' && raw.trim().length > 0 ? raw.trim() : 'maya_neutral'; + } + + private _sendSetVoice(): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify({ type: 'set_voice', voice: this._getVoice() })); + } + } + + /** + * Assemble the ``turn_config`` wire object from the ``agents.voice.turn.*`` + * settings, normalizing each into the shape the backend expects. + */ + private _getTurnConfig(): IVoiceTurnConfig { + const cfg = this._configurationService; + + const modeRaw = cfg.getValue<string>('agents.voice.turn.autoEndMode'); + const auto_end_mode: IVoiceTurnConfig['auto_end_mode'] = + modeRaw === 'vad' || modeRaw === 'phrase' || modeRaw === 'both' ? modeRaw : 'off'; + + const silenceRaw = cfg.getValue<number>('agents.voice.turn.silenceMs'); + const silence_ms = typeof silenceRaw === 'number' && silenceRaw > 0 ? Math.round(silenceRaw) : 800; + + const phrasesRaw = cfg.getValue<string[]>('agents.voice.turn.stopPhrases'); + const stop_phrases = Array.isArray(phrasesRaw) + ? phrasesRaw.map(p => String(p).trim()).filter(p => p.length > 0) + : []; + + const gateRaw = cfg.getValue<string>('agents.voice.turn.vadGateAsr'); + const vad_gate_asr = gateRaw === 'on' ? true : gateRaw === 'off' ? false : null; + + return { auto_end_mode, silence_ms, stop_phrases, vad_gate_asr }; + } + + private _sendSetTurnConfig(): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify({ type: 'set_turn_config', turn_config: this._getTurnConfig() })); + } } private _getWsUrl(): string { @@ -156,6 +226,8 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic args?: Record<string, string>; status?: string; committed?: string; + reason?: string; + turn_id?: string; }; try { msg = JSON.parse(evt.data as string); @@ -203,6 +275,15 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic args: msg.args ?? {}, }); break; + case 'turn_auto_ended': { + // Backend ended the held turn itself (server VAD silence or a + // matched stop phrase). Normalize the reason and let the + // consumer stop capture for that turn; it must not send its + // own ptt_end. + const reason: IVoiceTurnAutoEndReason = msg.reason === 'stop_phrase' ? 'stop_phrase' : 'vad_silence'; + this._onTurnAutoEnded.fire({ reason, turnId: msg.turn_id ?? '' }); + break; + } case 'error': this._onError.fire(msg.detail ?? 'Unknown error'); break; @@ -335,6 +416,24 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic } } + sendBargeInStart(): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify({ type: 'barge_in_start' })); + } + } + + sendBargeInAudioChunk(audio: string): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify({ type: 'barge_in_audio_chunk', audio })); + } + } + + sendBargeInStop(): void { + if (this._ws?.readyState === WebSocket.OPEN) { + this._ws.send(JSON.stringify({ type: 'barge_in_stop' })); + } + } + sendPttDiagnostic(turnId: string, metrics: Record<string, unknown>): void { if (this._ws?.readyState === WebSocket.OPEN) { this._ws.send(JSON.stringify({ type: 'ptt_diagnostic', turn_id: turnId, metrics })); @@ -502,7 +601,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic sendStartSession(context: IVoiceSessionContext, machineId: string, priorTimeline?: readonly IVoicePriorTimelineEntry[]): void { if (this._ws?.readyState === WebSocket.OPEN) { this._seedTracking(context); - const payload: Record<string, unknown> = { type: 'start_session', session_context: context, machine_id: machineId }; + const payload: Record<string, unknown> = { type: 'start_session', session_context: context, machine_id: machineId, turn_config: this._getTurnConfig(), voice: this._getVoice() }; if (priorTimeline && priorTimeline.length > 0) { payload.prior_timeline = priorTimeline; } @@ -513,7 +612,7 @@ export class VoiceClientService extends Disposable implements IVoiceClientServic sendResumeSession(context: IVoiceSessionContext, machineId: string): void { if (this._ws?.readyState === WebSocket.OPEN && this._lastSessionId) { this._seedTracking(context); - this._ws.send(JSON.stringify({ type: 'resume_session', session_id: this._lastSessionId, session_context: context, machine_id: machineId })); + this._ws.send(JSON.stringify({ type: 'resume_session', session_id: this._lastSessionId, session_context: context, machine_id: machineId, turn_config: this._getTurnConfig(), voice: this._getVoice() })); } } diff --git a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts index a46cf05d161900..b461cbdd1b9cc2 100644 --- a/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts +++ b/src/vs/workbench/contrib/chat/browser/voiceClient/voiceSessionController.ts @@ -82,6 +82,16 @@ export interface IVoiceSessionController { pttDown(): void; pttUp(): void; + /** + * Stop the current recording / auto-listen loop without disconnecting. + * Any in-flight push-to-talk press is finished through the normal + * `ptt_end` path (the backend finalizes the turn) and the auto-listen + * re-arm loop is suppressed until the user talks again. The WebSocket + * stays connected so the user can resume via the Voice Mode button + * without a new handshake. Use `disconnect()` to fully end the session. + */ + stopListening(): void; + /** * Mark a session as having been cancelled by the user from VS Code UI. The * next state-change detected for this session (typically the chat model @@ -149,6 +159,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // --- Internal state --- private _pttHeld = false; private _pttToggleMode = false; + /** When true, the auto-listen loop is suppressed (user pressed Stop + * Recording). Cleared on the next explicit `pttDown` or on connect. */ + private _autoListenSuppressed = false; + /** Armed on a fresh connect (hands-free); consumed on `session_init` to + * enter listening once the backend acks the session. */ + private _enterListenOnSessionInit = false; private _pttCurrentTurnId = ''; private _window: (Window & typeof globalThis) | undefined; private readonly _voiceEventDisposables = this._register(new DisposableStore()); @@ -164,7 +180,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** Debounce before re-entering listening after assistant stops speaking. */ private static readonly _AUTO_LISTEN_QUIET_MS = 1200; private _delayedMicStopTimer: ReturnType<typeof setTimeout> | undefined; - private _autoSendSilenceTimer: ReturnType<typeof setTimeout> | undefined; private _autoListenTimer: ReturnType<typeof setTimeout> | undefined; private _pttWaitingForPlayback = false; /** Guards auto re-listen: only re-arm after a reply has actually played. */ @@ -172,11 +187,12 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC /** Set after send_to_chat; blocks auto-listen until the reply TTS starts. */ private _awaitingReplyAudio = false; private _awaitingReplyWatchdog: ReturnType<typeof setTimeout> | undefined; - /** Enter listening immediately after greeting finishes (no debounce). */ - private _autoListenAfterGreeting = false; /** Tracks whether the initial listen cue has been played after connecting. */ private _hasPlayedInitialListenCue = false; + /** True while streaming mic audio to the backend during playback (barge-in). */ + private _bargeInMonitorActive = false; + // --- Audio FIFO queue --- private readonly _audioQueue: { sessionId: string | undefined; chunks: { audio: string; isFirstChunk: boolean; isFinal: boolean; transcript: string | undefined }[] }[] = []; private _currentPlaybackSessionId: string | undefined | null = null; // null = nothing playing @@ -515,6 +531,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._voiceEventDisposables.add(this.micCaptureService.onPttEnd(() => { this.voiceClientService.sendPttEnd(); })); + // Barge-in: stream mic audio to the backend during assistant playback. + this._voiceEventDisposables.add(this.micCaptureService.onMonitorAudioChunk(b64 => { + this.voiceClientService.sendBargeInAudioChunk(b64); + })); this._voiceEventDisposables.add(this.micCaptureService.onPttDiagnostic((diag: IPttDiagnostic) => { // Local log so the same correlation key surfaces in the // VS Code log files even if the WS is closed mid-flight. @@ -572,6 +592,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._audioQueue.length > 0) { setTimeout(() => this._processQueue(), 500); } else { + this._stopBargeInMonitor(); if (this._pttHeld) { this._voiceState.set('listening', undefined); this._statusText.set('Listening...', undefined); @@ -581,14 +602,10 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC if (this._pttWaitingForPlayback) { this._scheduleDelayedMicStop(); } - // Hands-free: enter listening after audio finishes. - if (this._isAutoSendEnabled() && !this._awaitingReplyAudio) { - if (this._autoListenAfterGreeting) { - this._autoListenAfterGreeting = false; - this._enterAutoListen(); - } else if (this._replyPlayedSinceSend) { - this._scheduleAutoListen(); - } + // Hands-free: re-enter listening after the assistant's reply + // audio finishes. + if (this._isHandsFreeEnabled() && !this._awaitingReplyAudio && this._replyPlayedSinceSend) { + this._scheduleAutoListen(); } } } @@ -891,8 +908,25 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._statusText.set('Hold to speak...', undefined); this._voiceState.set('idle', undefined); - if (!isResuming && this._isAutoSendEnabled()) { - this._autoListenAfterGreeting = true; + // Enter listening as soon as a fresh session is ready. Starting + // voice mode always begins the first turn listening, regardless + // of `handsFree` (which only controls whether we RE-listen after + // the assistant speaks). We wait for the backend `session_init` + // ack (see onSessionInit below) rather than acting here, because + // the mic/handshake isn't settled yet at connection time. + // Previously this was deferred until a welcome greeting finished + // playing, but the greeting was removed. A short fallback timer + // covers backends that don't emit `session_init`. + this._enterListenOnSessionInit = !isResuming; + this.logService.info(`[voice] connected: isResuming=${isResuming} handsFree=${this._isHandsFreeEnabled()} armListen=${this._enterListenOnSessionInit}`); + if (this._enterListenOnSessionInit) { + this._voiceEventDisposables.add(disposableTimeout(() => { + if (this._enterListenOnSessionInit && this._isConnected.get()) { + this.logService.info('[voice] session_init not seen within 750ms; entering listening via fallback'); + this._enterListenOnSessionInit = false; + this._enterAutoListen(); + } + }, 750)); } } else if (this._isConnected.get()) { this._onConnectionLost(); @@ -911,17 +945,48 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } })); + // Session ready: the backend has acked start_session. This is the + // point at which the mic/handshake is settled and a turn will stick, + // so enter hands-free listening here (armed in the connect handler). + this._voiceEventDisposables.add(this.voiceClientService.onSessionInit(() => { + this.logService.info(`[voice] session_init received; armListen=${this._enterListenOnSessionInit}`); + if (this._enterListenOnSessionInit) { + this._enterListenOnSessionInit = false; + this._enterAutoListen(); + } + })); + // Speech started → stop TTS, suppress late chunks from the previous turn // (same flow as pttDown, but for server-VAD path). this._voiceEventDisposables.add(this.voiceClientService.onSpeechStarted(() => { - this._clearAutoSendSilenceTimer(); + const wasMonitoring = this._bargeInMonitorActive; this._clearAutoListenTimer(); - this.ttsPlaybackService.stopPlayback(); - this._audioQueue.length = 0; - this._currentPlaybackSessionId = null; - this._isProcessingQueue = false; - this._suppressIncomingAudio = true; - this._startUserTurn(); + if (wasMonitoring && !this._pttHeld) { + // Promote the monitor into a real turn (mic stays warm via pttDown). + this.pttDown(); + this.pttUp(); + // Clear the playback AEC suppression so the turn start isn't gated. + this.micCaptureService.suppressUntil(0); + } else { + this.ttsPlaybackService.stopPlayback(); + this._audioQueue.length = 0; + this._currentPlaybackSessionId = null; + this._isProcessingQueue = false; + this._suppressIncomingAudio = true; + this._stopBargeInMonitor(); + this._startUserTurn(); + } + })); + + // Backend ended the held turn itself (server VAD silence / stop phrase). + // Treat it like a local ptt_end — stop capture, move to processing — but + // do NOT send our own ptt_end. Guard against double-ending: ignore if we + // already released locally, or if the id is for a different turn. + this._voiceEventDisposables.add(this.voiceClientService.onTurnAutoEnded(e => { + if (!this._pttHeld) { return; } + if (e.turnId && e.turnId !== this._pttCurrentTurnId) { return; } + this._pttToggleMode = false; + this._finishPtt('auto'); })); // Transcription — mutate the current user turn at the tail of the buffer. @@ -937,20 +1002,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._telemetryFirstTranscriptionMs = Date.now(); } - let text = e.text; - - // Check for send keyword trigger in toggle mode. - if (this._pttToggleMode && this._pttHeld) { - const strippedText = this._checkSendKeyword(text); - if (strippedText !== undefined) { - text = strippedText; - this._updateUserTurn(text, e.committed ?? '', false); - this._persistTurn('user', text); - this._pttToggleMode = false; - this._finishPtt(); - return; - } - } + const text = e.text; this._updateUserTurn(text, e.committed ?? '', e.status === 'partial'); if (e.status !== 'partial') { @@ -961,10 +1013,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC // Persist the user's final transcript (local-only, no backend coordination). this._persistTurn('user', text); } - // Restart silence countdown for auto-send in toggle mode. - if (this._pttToggleMode && this._pttHeld) { - this._scheduleAutoSendOnSilence(); - } })); // Audio response → fade transcript, queue for sequential playback @@ -1003,12 +1051,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC 'focus_session', ]; if (e.name === 'send_to_chat') { - let text = typeof e.args?.['text'] === 'string' ? (e.args['text'] as string) : ''; - // Strip send keyword if present (backend includes full transcript) - const stripped = this._checkSendKeyword(text); - if (stripped !== undefined) { - text = stripped; - } + const text = typeof e.args?.['text'] === 'string' ? (e.args['text'] as string) : ''; this._statusText.set(VoiceToolDispatchService.getActionLabel(e.name), undefined); this._persistEntry('agent_tool_call', this._renderToolCallSummary(e.name, e.args), { toolName: e.name, @@ -1102,12 +1145,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._voiceState.set('idle', undefined); this._statusText.set('Tap to start', undefined); this._transcriptTurns.set([], undefined); - this._clearAutoSendSilenceTimer(); this._clearAutoListenTimer(); this._clearAwaitingReply(); - this._autoListenAfterGreeting = false; + this._autoListenSuppressed = false; + this._enterListenOnSessionInit = false; this._hasPlayedInitialListenCue = false; this._replyPlayedSinceSend = false; + this._bargeInMonitorActive = false; this._audioQueue.length = 0; this._currentPlaybackSessionId = null; this._isProcessingQueue = false; @@ -1181,20 +1225,19 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } pttDown(): void { - if (!this._isConnected.get()) { return; } - - this._clearAutoSendSilenceTimer(); + if (!this._isConnected.get()) { this.logService.info('[voice] pttDown ignored: not connected'); return; } // Toggle mode: second tap finishes recording if (this._pttToggleMode) { + this.logService.info('[voice] pttDown: toggle-mode second tap -> finishing turn'); this._pttToggleMode = false; this._finishPtt(); return; } - if (this._pttHeld) { return; } + if (this._pttHeld) { this.logService.info('[voice] pttDown ignored: already held'); return; } this._pttHeld = true; - this._autoListenAfterGreeting = false; + this._autoListenSuppressed = false; this._clearAutoListenTimer(); this._pttCurrentTurnId = generateUuid(); this._pttWaitingForPlayback = false; @@ -1228,7 +1271,8 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this.micCaptureService.isMuted = false; // Lazily acquire the mic — fire-and-forget. The mic service handles // the case where the user releases before acquisition completes. - this.micCaptureService.pttDown(this._pttCurrentTurnId).catch(() => { + this.micCaptureService.pttDown(this._pttCurrentTurnId).catch((err) => { + this.logService.warn('[voice] mic acquisition failed on pttDown; disconnecting', err); this._pttHeld = false; this._statusText.set('Microphone denied', undefined); this._voiceState.set('error', undefined); @@ -1241,12 +1285,15 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } this.disconnect(); }); + // Stop the monitor after mic pttDown so its _pttStreaming flag is set + // first, letting stopMonitor keep the mic warm instead of re-acquiring. + this._stopBargeInMonitor(); this.ttsPlaybackService.stopPlayback(); this._voiceState.set('listening', undefined); this._statusText.set('Listening...', undefined); // Audible cue: for non-screen-reader users, only play on the first // listen after connecting. For screen reader users, play every time. - if (this._isAutoSendEnabled()) { + if (this._isHandsFreeEnabled()) { if (!this._hasPlayedInitialListenCue) { this._hasPlayedInitialListenCue = true; this.accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStarted); @@ -1276,9 +1323,37 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._finishPtt(); } - private _finishPtt(): void { + stopListening(): void { + // Stop the current recording / auto-listen loop WITHOUT tearing down + // the WebSocket. Any in-flight press is finished through the normal + // `ptt_end` path so the backend finalizes the turn; the auto-listen + // re-arm loop (auto-send mode) is suppressed until the user talks + // again. The connection stays open so the user can resume via the + // Voice Mode button without a new handshake. + if (!this._isConnected.get()) { return; } + this._autoListenSuppressed = true; + this._pttToggleMode = false; + this._clearAutoListenTimer(); + this._stopBargeInMonitor(); + if (this._pttHeld) { + this._finishPtt('local'); + } else { + this._voiceState.set('idle', undefined); + this._statusText.set('Tap to start', undefined); + } + } + + /** + * Finish the current push-to-talk press. + * + * ``reason`` is ``'local'`` for a user-driven end (button release / toggle + * tap / keyword) — the mic drains its tail and the ``onPttEnd`` → ``ptt_end`` + * path fires. It is ``'auto'`` when the backend ended the turn itself + * (``turn_auto_ended``): the mic is aborted with no drain and NO ``ptt_end`` + * is sent for the turn. + */ + private _finishPtt(reason: 'local' | 'auto' = 'local'): void { if (!this._pttHeld) { return; } - this._clearAutoSendSilenceTimer(); this._clearAutoListenTimer(); this._pttHeld = false; this._telemetryPttUpMs = Date.now(); @@ -1293,7 +1368,13 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._replyPlayedSinceSend = false; this._clearAwaitingReply(); this._suppressIncomingAudio = false; - this.micCaptureService.pttUp(); + if (reason === 'auto') { + // Backend already ended the turn — stop capturing without draining + // more audio and without emitting our own ptt_end. + this.micCaptureService.abortPtt(); + } else { + this.micCaptureService.pttUp(); + } if (this.accessibilityService.isScreenReaderOptimized()) { this.accessibilitySignalService.playSignal(AccessibilitySignal.voiceRecordingStopped); } @@ -1331,40 +1412,26 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC }, 1000); } - private _isAutoSendEnabled(): boolean { - const delayMs = this.configurationService.getValue<number>('agents.voice.autoSendDelay'); - return typeof delayMs === 'number' && delayMs >= 0; - } - - /** - * Check if the transcript text ends with the configured send keyword. - * Returns the text with the keyword stripped if found, or undefined if not. - */ - private _checkSendKeyword(text: string): string | undefined { - const keyword = this.configurationService.getValue<string>('agents.voice.sendKeyword')?.trim(); - if (!keyword) { - return undefined; - } - // Strip trailing punctuation that speech recognizers often append - const trimmed = text.trimEnd().replace(/[.,!?;:]+$/, '').trimEnd(); - const keywordLower = keyword.toLowerCase(); - if (trimmed.toLowerCase().endsWith(keywordLower)) { - const stripped = trimmed.slice(0, trimmed.length - keyword.length).trimEnd(); - return stripped || undefined; // return undefined if nothing left (don't send empty) - } - return undefined; + private _isHandsFreeEnabled(): boolean { + // Default-on: treat only an explicit `false` as disabled so an + // unresolved/undefined value still enables hands-free (matches the + // `handsFree` default and the window-service `!== false` check). + return this.configurationService.getValue<boolean>('agents.voice.handsFree') !== false; } /** Re-enter listening via synthetic short tap. */ private _enterAutoListen(): void { this._clearAutoListenTimer(); - if (!this._isConnected.get() || this._pttHeld) { + if (this._autoListenSuppressed || !this._isConnected.get() || this._pttHeld) { + this.logService.info(`[voice] _enterAutoListen skipped: suppressed=${this._autoListenSuppressed} connected=${this._isConnected.get()} pttHeld=${this._pttHeld}`); return; } // Don't enter listening if audio is still playing or queued. if (this.ttsPlaybackService.isPlaying || this._audioQueue.length > 0 || this._currentPlaybackSessionId !== null) { + this.logService.info(`[voice] _enterAutoListen skipped: audio busy (playing=${this.ttsPlaybackService.isPlaying} queue=${this._audioQueue.length} pbSession=${this._currentPlaybackSessionId !== null})`); return; } + this.logService.info('[voice] _enterAutoListen entering listening'); this.pttDown(); this.pttUp(); } @@ -1387,29 +1454,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } - /** Auto-finish recording after configured silence in toggle mode. */ - private _scheduleAutoSendOnSilence(): void { - this._clearAutoSendSilenceTimer(); - const delayMs = this.configurationService.getValue<number>('agents.voice.autoSendDelay'); - if (typeof delayMs !== 'number' || delayMs < 0) { - return; - } - this._autoSendSilenceTimer = setTimeout(() => { - this._autoSendSilenceTimer = undefined; - if (this._pttToggleMode && this._pttHeld) { - this._pttToggleMode = false; - this._finishPtt(); - } - }, delayMs); - } - - private _clearAutoSendSilenceTimer(): void { - if (this._autoSendSilenceTimer) { - clearTimeout(this._autoSendSilenceTimer); - this._autoSendSilenceTimer = undefined; - } - } - /** Block auto-listen until reply audio arrives (with 30s watchdog). */ private _setAwaitingReply(): void { this._awaitingReplyAudio = true; @@ -1421,7 +1465,7 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC this._awaitingReplyWatchdog = undefined; this._awaitingReplyAudio = false; // No reply came — re-enter listening if eligible. - if (this._isAutoSendEnabled() && !this._pttHeld) { + if (this._isHandsFreeEnabled() && !this._pttHeld) { this._enterAutoListen(); } }, 30_000); @@ -1435,6 +1479,37 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } } + /** + * Start barge-in monitoring: stream mic audio to the backend during + * playback so the user can talk over the assistant. Hands-free only; + * the backend emits `speech_started`, which `onSpeechStarted` promotes + * into a real turn. Inert until the backend consumes `barge_in_*`. + */ + private _startBargeInMonitor(): void { + if (this._bargeInMonitorActive || !this._isConnected.get() || this._pttHeld || !this._window) { + return; + } + if (!this._isHandsFreeEnabled()) { + return; + } + this._bargeInMonitorActive = true; + this.voiceClientService.sendBargeInStart(); + this.micCaptureService.startMonitor(this._window).catch(err => { + this.logService.warn('[voice] barge-in monitor failed to start', err); + this._bargeInMonitorActive = false; + }); + } + + /** Stop barge-in monitoring and tell the backend to stop listening for it. */ + private _stopBargeInMonitor(): void { + if (!this._bargeInMonitorActive) { + return; + } + this._bargeInMonitorActive = false; + this.micCaptureService.stopMonitor(); + this.voiceClientService.sendBargeInStop(); + } + /** * Send transcription text to the target session or active chat. * If a target session is selected, sends directly via chatService. @@ -1898,8 +1973,6 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC } private _playChunk(sessionId: string | undefined, audio: string, isFirstChunk: boolean, isFinal: boolean, transcript: string | undefined): void { - this._currentPlaybackSessionId = sessionId; - // Streaming pipeline sends a monotonically-growing transcript on every // chunk. On the FIRST chunk of a response we push a fresh assistant // turn into the rolling buffer; on subsequent chunks we REPLACE that @@ -1918,39 +1991,53 @@ export class VoiceSessionController extends Disposable implements IVoiceSessionC const ttsEnabled = this.configurationService.getValue<boolean>('agents.voice.textToSpeech') !== false; if (ttsEnabled && audio) { + // Claim the playback slot only when we actually have audio to play. + // A transcript-only frame (empty audio) must NOT claim it, or the + // slot would stay pinned to a session that never starts playback + // (onPlaybackStopped never fires), deadlocking every other + // session's queued audio. + this._currentPlaybackSessionId = sessionId; this._clearAutoListenTimer(); this._replyPlayedSinceSend = true; this.micCaptureService.suppressUntil(Date.now() + 800); this._voiceState.set('speaking', undefined); this._statusText.set('Speaking...', undefined); + // Hands-free: keep the mic open so the user can barge in. + this._startBargeInMonitor(); this.ttsPlaybackService.playAudioChunk(audio, isFinal, this._window!); } else if (!ttsEnabled) { this._replyPlayedSinceSend = true; if (isFinal) { this._currentPlaybackSessionId = null; - this._processQueue(); - if (this._isAutoSendEnabled()) { + // Avoid re-entering _processQueue if we're already inside its + // drain loop; that loop will continue on its own. + if (!this._isProcessingQueue) { + this._processQueue(); + } + if (this._isHandsFreeEnabled()) { this._scheduleAutoListen(); } } } else { + // TTS enabled but no audio in this frame. Forward it so a final + // frame can flush/stop an in-flight playback turn; a non-final + // empty frame is a no-op and leaves the slot untouched. this.ttsPlaybackService.playAudioChunk(audio, isFinal, this._window!); } } private _processQueue(): void { - if (this._audioQueue.length === 0 || this._currentPlaybackSessionId !== null) { - this._isProcessingQueue = false; - return; - } - + // Drain entries until one actually claims the playback slot (starts + // audio) or the queue empties. Entries that produce no audio (e.g. + // transcript-only frames) would otherwise stall the chain, since + // nothing fires onPlaybackStopped to pump the next entry. this._isProcessingQueue = true; - const next = this._audioQueue.shift()!; - - for (const chunk of next.chunks) { - this._playChunk(next.sessionId, chunk.audio, chunk.isFirstChunk, chunk.isFinal, chunk.transcript); + while (this._currentPlaybackSessionId === null && this._audioQueue.length > 0) { + const next = this._audioQueue.shift()!; + for (const chunk of next.chunks) { + this._playChunk(next.sessionId, chunk.audio, chunk.isFirstChunk, chunk.isFinal, chunk.transcript); + } } - this._isProcessingQueue = false; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts index 9331a82aa9d62e..d424eb6d49beb2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAttachmentsContentPart.ts @@ -13,7 +13,7 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { ResourceLabels } from '../../../../../browser/labels.js'; import { getImageAttachmentLimit, IChatRequestVariableEntry, isAgentHostCompletionVariableEntry, isBrowserViewVariableEntry, isElementVariableEntry, isImageVariableEntry, isNotebookOutputVariableEntry, isPasteVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry, isSCMHistoryItemChangeRangeVariableEntry, isSCMHistoryItemChangeVariableEntry, isSCMHistoryItemVariableEntry, isTerminalVariableEntry, isWorkspaceVariableEntry, OmittedState } from '../../../common/attachments/chatVariableEntries.js'; import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; -import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, isAutoLanguageModel } from '../../../common/languageModels.js'; import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentWidgetRegistry.js'; @@ -21,6 +21,7 @@ export interface IChatAttachmentsContentPartOptions { readonly variables: readonly IChatRequestVariableEntry[]; readonly contentReferences?: ReadonlyArray<IChatContentReference>; readonly modelId?: string; + readonly resolvedModelId?: string; readonly domNode?: HTMLElement; readonly limit?: number; } @@ -35,6 +36,7 @@ export class ChatAttachmentsContentPart extends Disposable { private _variables: readonly IChatRequestVariableEntry[]; private readonly contentReferences: ReadonlyArray<IChatContentReference>; private readonly modelId?: string; + private readonly resolvedModelId?: string; private readonly limit?: number; public readonly domNode: HTMLElement | undefined; @@ -50,6 +52,7 @@ export class ChatAttachmentsContentPart extends Disposable { this._variables = options.variables; this.contentReferences = options.contentReferences ?? []; this.modelId = options.modelId; + this.resolvedModelId = options.resolvedModelId; this.limit = options.limit; this.domNode = options.domNode ?? dom.$('.chat-attached-context'); @@ -102,6 +105,11 @@ export class ChatAttachmentsContentPart extends Disposable { return visibleAttachments.slice(0, this.limit); } + private currentModelDoesNotSupportImages(): boolean { + const model = this.getCurrentLanguageModel(); + return !!model && !isAutoLanguageModel(model) && model.metadata.capabilities?.vision === false; + } + /** * When the total number of image attachments exceeds the model-specific * per-request limit, mark the oldest images (those dropped by the backend) @@ -144,11 +152,28 @@ export class ChatAttachmentsContentPart extends Disposable { } private getImageLimitForCurrentModel(): number | undefined { - if (!this.modelId) { - return undefined; + return getImageAttachmentLimit(this.getCurrentLanguageModel()?.metadata); + } + + private getCurrentLanguageModel(): ILanguageModelChatMetadataAndIdentifier | undefined { + const selectedMetadata = this.modelId ? this.languageModelsService.lookupLanguageModel(this.modelId) : undefined; + if (!this.resolvedModelId) { + return this.modelId && selectedMetadata ? { identifier: this.modelId, metadata: selectedMetadata } : undefined; + } + + const directMetadata = this.languageModelsService.lookupLanguageModel(this.resolvedModelId); + if (directMetadata) { + return { identifier: this.resolvedModelId, metadata: directMetadata }; + } + + for (const identifier of this.languageModelsService.getLanguageModelIds()) { + const metadata = this.languageModelsService.lookupLanguageModel(identifier); + if (metadata?.id === this.resolvedModelId && (!selectedMetadata || metadata.vendor === selectedMetadata.vendor)) { + return { identifier, metadata }; + } } - return getImageAttachmentLimit(this.languageModelsService.lookupLanguageModel(this.modelId)); + return undefined; } private renderShowMoreButton(container: HTMLElement, remainingCount: number) { @@ -199,8 +224,10 @@ export class ChatAttachmentsContentPart extends Disposable { } else if (isElementVariableEntry(attachment)) { widget = this.instantiationService.createInstance(ElementChatAttachmentWidget, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels); } else if (isImageVariableEntry(attachment)) { - attachment.omittedState = isAttachmentPartialOrOmitted ? OmittedState.Full : attachment.omittedState; - widget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, attachment, undefined, { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels); + const renderedAttachment = isAttachmentPartialOrOmitted || this.currentModelDoesNotSupportImages() + ? { ...attachment, omittedState: OmittedState.Full } + : attachment; + widget = this.instantiationService.createInstance(ImageAttachmentWidget, resource, renderedAttachment, this.getCurrentLanguageModel(), { shouldFocusClearButton: false, supportsDeletion: false }, container, this._contextResourceLabels); } else if (isPromptFileVariableEntry(attachment)) { if (attachment.automaticallyAdded) { return; // Skip automatically added prompt files diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAutoModeResolutionContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAutoModeResolutionContentPart.ts new file mode 100644 index 00000000000000..8b9c27c941d641 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatAutoModeResolutionContentPart.ts @@ -0,0 +1,80 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../../../../base/browser/dom.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { localize } from '../../../../../../nls.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatAutoModeResolutionPart } from '../../../common/chatService/chatService.js'; +import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; +import { IChatContentPartRenderContext } from './chatContentParts.js'; +import './media/chatAutoModeResolution.css'; + +/** + * A collapsible content part that displays auto-mode model routing resolution. + * Collapsed: "Routed to <model>" + * Expanded: Explanation of auto routing + reasoning label with confidence. + */ +export class ChatAutoModeResolutionContentPart extends ChatCollapsibleContentPart { + + constructor( + private readonly content: IChatAutoModeResolutionPart, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + @IHoverService hoverService: IHoverService, + @IConfigurationService configurationService: IConfigurationService, + ) { + super( + localize('autoModeResolution.title', "Routed to {0}", content.resolvedModelName), + context, + undefined, + hoverService, + configurationService, + ); + } + + protected override initContent(): HTMLElement { + const wrapper = $('.chat-auto-mode-resolution-content.chat-used-context-list'); + + const body = $('.chat-auto-mode-resolution-body'); + + const explanation = $('.chat-auto-mode-resolution-explanation'); + const explanationMd = new MarkdownString(ILanguageModelChatMetadata.getAutoModelDescription()); + const rendered = this._register(this.chatContentMarkdownRenderer.render(explanationMd)); + explanation.appendChild(rendered.element); + body.appendChild(explanation); + + const detailLine = $('.chat-auto-mode-resolution-detail'); + let detailText: string; + if (this.content.predictedLabel === 'fallback') { + detailText = localize('autoModeResolution.fallback', "Unable to resolve"); + } else { + const label = this.content.predictedLabel === 'needs_reasoning' + ? localize('autoModeResolution.reasoning', "Reasoning") + : localize('autoModeResolution.nonReasoning', "Non-reasoning"); + const confidencePercent = (this.content.confidence * 100).toFixed(0); + detailText = localize('autoModeResolution.detail', "{0} - Confidence {1}%", label, confidencePercent); + } + const detailRendered = this._register(this.chatContentMarkdownRenderer.render(new MarkdownString(detailText))); + detailLine.appendChild(detailRendered.element); + body.appendChild(detailLine); + + wrapper.appendChild(body); + return wrapper; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return other.kind === 'autoModeResolution' + && other.resolvedModel === this.content.resolvedModel + && other.resolvedModelName === this.content.resolvedModelName + && other.confidence === this.content.confidence + && other.predictedLabel === this.content.predictedLabel; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts index 27901fddcaad28..78a0b961a37810 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatChangesSummaryPart.ts @@ -5,7 +5,9 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $ } from '../../../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; import { IListRenderer, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; +import { IAction } from '../../../../../../base/common/actions.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; import { combinedDisposable, Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -35,17 +37,92 @@ import { ChatTreeItem } from '../../chat.js'; import { ResourcePool } from './chatCollections.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +const CHANGES_SUMMARY_ELEMENT_HEIGHT = 22; +const CHANGES_SUMMARY_MAX_ITEMS_SHOWN = 6; + +/** Options controlling how {@link renderChangesSummaryFileList} renders each row. */ +export interface IChangesSummaryFileListOptions { + /** + * Provides the actions shown in a per-row action bar (right-aligned). Return + * an empty array for rows that should have no actions. When omitted, no action + * bar is rendered. + */ + readonly getRowActions?: (diff: IEditSessionEntryDiff) => IAction[]; +} + +/** + * Renders the collapsible list of changed files (one row per {@link IEditSessionEntryDiff}, + * showing the file's resource label and its +added/-removed counts) into `container`, + * keeping it in sync with `diffs`. Rows open the file or its diff on activation. + * Shared by the checkpoint file changes summary and the agent turn changes summary + * so both render an identical list. + */ +export function renderChangesSummaryFileList( + container: HTMLElement, + diffs: IObservable<readonly IEditSessionEntryDiff[]>, + instantiationService: IInstantiationService, + editorService: IEditorService, + configurationService: IConfigurationService, + options?: IChangesSummaryFileListOptions, +): IDisposable { + const store = new DisposableStore(); + const list = store.add(instantiationService.createInstance(CollapsibleChangesSummaryListPool, options)).get(); + const listNode = list.getHTMLElement(); + container.appendChild(listNode.parentElement!); + + store.add(list.onDidOpen((item) => { + const diff = item.element; + if (!diff) { + return; + } + + const altKey = (dom.isMouseEvent(item.browserEvent) || dom.isKeyboardEvent(item.browserEvent)) && item.browserEvent.altKey; + const openInDiffEditorByDefault = configurationService.getValue<boolean>(ChatConfiguration.OpenChangedFileInDiffEditor); + const openInDiffEditor = altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault; + + if (!openInDiffEditor) { + const fileURI = ChatEditingSnapshotTextModelContentProvider.getOriginalFileURI(diff.modifiedURI); + if (fileURI) { + editorService.openEditor({ resource: fileURI, options: { preserveFocus: true } }); + return; + } + // The file's origin cannot be recovered (e.g. legacy snapshot URIs): + // fall back to the diff editor. + } + + editorService.openEditor({ + original: { resource: diff.originalURI }, + modified: { resource: diff.modifiedURI }, + options: { preserveFocus: true } + }); + })); + + store.add(list.onContextMenu(e => { + dom.EventHelper.stop(e.browserEvent, true); + })); + + store.add(autorun((r) => { + const currentDiffs = diffs.read(r); + + const itemsShown = Math.min(currentDiffs.length, CHANGES_SUMMARY_MAX_ITEMS_SHOWN); + const height = itemsShown * CHANGES_SUMMARY_ELEMENT_HEIGHT; + list.layout(height); + listNode.style.height = height + 'px'; + + list.splice(0, list.length, currentDiffs); + })); + + return store; +} + + export class ChatCheckpointFileChangesSummaryContentPart extends Disposable implements IChatContentPart { public readonly domNode: HTMLElement; - public readonly ELEMENT_HEIGHT = 22; - public readonly MAX_ITEMS_SHOWN = 6; - private readonly diffsBetweenRequests = new Map<string, IObservable<IEditSessionEntryDiff | undefined>>(); private fileChangesDiffsObservable: IObservable<readonly IEditSessionEntryDiff[]>; - private list!: WorkbenchList<IEditSessionEntryDiff>; private readonly detailsElement: HTMLDetailsElement; constructor( @@ -182,54 +259,7 @@ export class ChatCheckpointFileChangesSummaryContentPart extends Disposable impl } private renderFilesList(container: HTMLElement): IDisposable { - const store = new DisposableStore(); - this.list = store.add(this.instantiationService.createInstance(CollapsibleChangesSummaryListPool)).get(); - const listNode = this.list.getHTMLElement(); - container.appendChild(listNode.parentElement!); - - store.add(this.list.onDidOpen((item) => { - const diff = item.element; - if (!diff) { - return; - } - - const altKey = (dom.isMouseEvent(item.browserEvent) || dom.isKeyboardEvent(item.browserEvent)) && item.browserEvent.altKey; - const openInDiffEditorByDefault = this.configurationService.getValue<boolean>(ChatConfiguration.OpenChangedFileInDiffEditor); - const openInDiffEditor = altKey ? !openInDiffEditorByDefault : openInDiffEditorByDefault; - - if (!openInDiffEditor) { - const fileURI = ChatEditingSnapshotTextModelContentProvider.getOriginalFileURI(diff.modifiedURI); - if (fileURI) { - this.editorService.openEditor({ resource: fileURI, options: { preserveFocus: true } }); - return; - } - // The file's origin cannot be recovered (e.g. legacy snapshot URIs): - // fall back to the diff editor. - } - - this.editorService.openEditor({ - original: { resource: diff.originalURI }, - modified: { resource: diff.modifiedURI }, - options: { preserveFocus: true } - }); - })); - - store.add(this.list.onContextMenu(e => { - dom.EventHelper.stop(e.browserEvent, true); - })); - - store.add(autorun((r) => { - const diffs = this.fileChangesDiffsObservable.read(r); - - const itemsShown = Math.min(diffs.length, this.MAX_ITEMS_SHOWN); - const height = itemsShown * this.ELEMENT_HEIGHT; - this.list.layout(height); - listNode.style.height = height + 'px'; - - this.list.splice(0, this.list.length, diffs); - })); - - return store; + return renderChangesSummaryFileList(container, this.fileChangesDiffsObservable, this.instantiationService, this.editorService, this.configurationService); } hasSameContent(other: IChatRendererContent, followingContent: IChatRendererContent[], element: ChatTreeItem): boolean { @@ -250,6 +280,7 @@ class CollapsibleChangesSummaryListPool extends Disposable { private _resourcePool: ResourcePool<IChatFileChangesSummaryListWrapper>; constructor( + private readonly options: IChangesSummaryFileListOptions | undefined, @IInstantiationService private readonly instantiationService: IInstantiationService, @IThemeService private readonly themeService: IThemeService ) { @@ -267,7 +298,7 @@ class CollapsibleChangesSummaryListPool extends Disposable { 'ChatListRenderer', container, new CollapsibleChangesSummaryListDelegate(), - [this.instantiationService.createInstance(CollapsibleChangesSummaryListRenderer, resourceLabels)], + [new CollapsibleChangesSummaryListRenderer(resourceLabels, this.options)], { alwaysConsumeMouseWheel: false } @@ -287,13 +318,14 @@ class CollapsibleChangesSummaryListPool extends Disposable { interface ICollapsibleChangesSummaryListTemplate extends IDisposable { readonly label: IResourceLabel; + readonly actionBar?: ActionBar; changesElement?: HTMLElement; } class CollapsibleChangesSummaryListDelegate implements IListVirtualDelegate<IEditSessionEntryDiff> { getHeight(element: IEditSessionEntryDiff): number { - return 22; + return CHANGES_SUMMARY_ELEMENT_HEIGHT; } getTemplateId(element: IEditSessionEntryDiff): string { @@ -308,11 +340,30 @@ class CollapsibleChangesSummaryListRenderer implements IListRenderer<IEditSessio readonly templateId: string = CollapsibleChangesSummaryListRenderer.TEMPLATE_ID; - constructor(private labels: ResourceLabels) { } + constructor( + private labels: ResourceLabels, + private readonly options?: IChangesSummaryFileListOptions, + ) { } renderTemplate(container: HTMLElement): ICollapsibleChangesSummaryListTemplate { const label = this.labels.create(container, { supportHighlights: true, supportIcons: true }); - return { label, dispose: () => label.dispose() }; + // Only when a row-action provider is supplied do we add a right-aligned + // action bar; the row becomes a flex row so the label fills the remaining + // width and the actions hug the right edge. + let actionBar: ActionBar | undefined; + if (this.options?.getRowActions) { + container.classList.add('chat-summary-list-row-with-actions'); + const actionsContainer = container.appendChild($('.chat-summary-list-actions')); + actionBar = new ActionBar(actionsContainer); + } + return { + label, + actionBar, + dispose: () => { + label.dispose(); + actionBar?.dispose(); + } + }; } renderElement(data: IEditSessionEntryDiff, index: number, templateData: ICollapsibleChangesSummaryListTemplate): void { @@ -336,6 +387,11 @@ class CollapsibleChangesSummaryListRenderer implements IListRenderer<IEditSessio templateData.changesElement = changesSummary; } + + if (templateData.actionBar && this.options?.getRowActions) { + templateData.actionBar.clear(); + templateData.actionBar.push(this.options.getRowActions(data), { icon: false, label: true }); + } } disposeTemplate(templateData: ICollapsibleChangesSummaryListTemplate): void { 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 508ce94fad2200..a9ddd487ae6cfe 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMarkdownContentPart.ts @@ -47,6 +47,7 @@ import { extractCodeblockUrisFromText, extractVulnerabilitiesFromText } from '.. import { IEditSessionDiffStats, IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; import { IChatContentInlineReference, IChatMarkdownContent, IChatService, IChatUndoStop } from '../../../common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { isRequestVM, isResponseVM } from '../../../common/model/chatViewModel.js'; import { ChatConfiguration } from '../../../common/constants.js'; import { IChatCodeBlockInfo } from '../../chat.js'; @@ -131,6 +132,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP @IInstantiationService private readonly instantiationService: IInstantiationService, @IAiEditTelemetryService private readonly aiEditTelemetryService: IAiEditTelemetryService, @IChatOutputRendererService private readonly chatOutputRendererService: IChatOutputRendererService, + @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, ) { super(); @@ -223,6 +225,10 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP breaks: true, }; + const configuredUriTransformer = markdownRenderOptions?.transformUri; + const transformUri = isResponseVM(element) + ? (href: string, kind: 'link' | 'image') => this.chatSessionsService.resolveChatResponseUri(element.sessionResource, configuredUriTransformer?.(href, kind) ?? href, kind) + : configuredUriTransformer; const result = store.add(renderer.render(this.markdown.content, { sanitizerConfig: MarkedKatexSupport.getSanitizerOptions({ allowedTags: allowedChatMarkdownHtmlTags, @@ -356,6 +362,7 @@ export class ChatMarkdownContentPart extends Disposable implements IChatContentP markedOptions: markedOpts, markedExtensions, ...markdownRenderOptions, + transformUri, }, this.domNode)); // Ideally this would happen earlier, but we need to parse the markdown. diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts new file mode 100644 index 00000000000000..bfbdde60c06f73 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpAuthenticationContentPart.ts @@ -0,0 +1,165 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, observableValue } from '../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { McpServerStatus } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; +import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IAgentHostCustomizationService } from '../../agentSessions/agentHost/agentHostCustomizationService.js'; +import { IChatMcpAuthenticationRequired, IChatMcpAuthenticationRequiredServer } from '../../../common/chatService/chatService.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart } from './chatContentParts.js'; +import './media/chatMcpServersInteractionContent.css'; + +export class ChatMcpAuthenticationContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly rendered = this._register(new MutableDisposable<IRenderedMarkdown>()); + + /** + * Whether this part was ever shown. Used to distinguish the initial empty + * state (the part is emitted with an empty `servers` observable that is + * populated immediately after) from the terminal state where every server + * has been authenticated — only the latter marks the part + * {@link IChatMcpAuthenticationRequired.isUsed used}. + */ + private _hasBeenVisible = false; + + /** + * The MCP server currently being authenticated, or `undefined` when idle. + * While set, the part shows an "Authenticating …" progress message for that + * server and stays visible regardless of the underlying auth-required state. + */ + private readonly _authenticating = observableValue<IChatMcpAuthenticationRequiredServer | undefined>(this, undefined); + + constructor( + private readonly data: IChatMcpAuthenticationRequired, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IAgentHostCustomizationService private readonly agentHostCustomizationService: IAgentHostCustomizationService, + ) { + super(); + this.domNode = dom.$('.chat-mcp-servers-interaction'); + // Re-render whenever the set of servers requiring auth changes — e.g. a + // server whose auth requirement surfaced after this part was first shown + // is pushed into the same observable by the session handler — or while a + // server is actively being authenticated. + this._register(autorun(reader => { + const servers = this.data.servers.read(reader); + const authenticating = this._authenticating.read(reader); + this.render(servers, authenticating); + this.updateVisibility(servers, authenticating); + })); + this._register(this.agentHostCustomizationService.onDidChangeCustomizations(() => this.updateVisibility(this.data.servers.get(), this._authenticating.get()))); + } + + private render(servers: readonly IChatMcpAuthenticationRequiredServer[], authenticating: IChatMcpAuthenticationRequiredServer | undefined): void { + dom.clearNode(this.domNode); + this.rendered.clear(); + + if (authenticating) { + this._renderMessage( + ThemeIcon.modify(Codicon.loading, 'spin'), + localize('mcp.auth.authenticating', 'Authenticating {0}...', '`' + escapeMarkdownSyntaxTokens(authenticating.name) + '`'), + ); + return; + } + + if (!servers.length) { + return; + } + + const links = servers + .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .join(', '); + const content = servers.length === 1 + ? localize('mcp.auth.single', 'The MCP server {0} requires authentication. [Authenticate](#authenticate)?', links) + : localize('mcp.auth.multiple', 'The MCP servers {0} require authentication. [Authenticate](#authenticate)?', links); + this._renderMessage(Codicon.mcp, content, { href: '#authenticate', run: () => void this.authenticate() }); + } + + private _renderMessage(icon: ThemeIcon, content: string, action?: { href: string; run: () => void }): void { + const container = dom.$('.chat-mcp-servers-interaction-hint'); + const messageContainer = dom.$('.chat-mcp-servers-message'); + const iconElement = dom.$('.chat-mcp-servers-icon'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + + const rendered = this.rendered.value = this.markdownRendererService.render(new MarkdownString(content, { isTrusted: true }), action ? { + actionHandler: (href: string) => { + // Only the dedicated authenticate link triggers auth; ignore any + // other link target so the handler stays scoped to this control. + if (href !== action.href) { + return Promise.resolve(false); + } + action.run(); + return Promise.resolve(true); + }, + } : undefined); + + messageContainer.appendChild(iconElement); + messageContainer.appendChild(rendered.element); + container.appendChild(messageContainer); + this.domNode.appendChild(container); + + if (action) { + // Present the authenticate link as a button for assistive technology + // and clear its href so it doesn't behave like a navigable link. + // eslint-disable-next-line no-restricted-syntax + const actionLink = rendered.element.querySelector<HTMLAnchorElement>(`a[data-href="${action.href}"]`); + if (actionLink) { + actionLink.setAttribute('role', 'button'); + actionLink.href = ''; + } + } + } + + private async authenticate(): Promise<void> { + const sessionResource = URI.revive(this.data.sessionResource); + try { + for (const server of this.data.servers.get()) { + this._authenticating.set(server, undefined); + await this.agentHostCustomizationService.authenticateMcpServer(sessionResource, server.id); + } + } finally { + this._authenticating.set(undefined, undefined); + } + } + + private updateVisibility(dataServers: readonly IChatMcpAuthenticationRequiredServer[], authenticating: IChatMcpAuthenticationRequiredServer | undefined): void { + // Stay visible while actively authenticating so the progress message is shown. + if (authenticating) { + this.domNode.style.display = ''; + this._hasBeenVisible = true; + return; + } + const sessionResource = URI.revive(this.data.sessionResource); + const servers = this.agentHostCustomizationService.getMcpServers(sessionResource); + const visible = dataServers.some(server => servers.some(current => current.id === server.id && current.status === McpServerStatus.AuthRequired)); + this.domNode.style.display = visible ? '' : 'none'; + if (visible) { + this._hasBeenVisible = true; + } else if (this._hasBeenVisible) { + // Every server has been authenticated. Mark this part used so a + // subsequent auth requirement surfaces as a fresh prompt rather than + // silently reusing this now-hidden one. + this.data.isUsed = true; + } + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return other.kind === 'mcpAuthenticationRequired'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts new file mode 100644 index 00000000000000..36a7fb477bc533 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatMcpServersStartingContentPart.ts @@ -0,0 +1,83 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { IMarkdownRendererService } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatMcpServersStartingSlow, IChatMcpStartingServer } from '../../../common/chatService/chatService.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart } from './chatContentParts.js'; +import './media/chatMcpServersInteractionContent.css'; + +/** + * Renders a lightweight "Starting MCP servers …" progress hint for agent-host + * sessions. The set of servers still starting is driven by the observable on + * {@link IChatMcpServersStartingSlow.servers}; when it empties (all servers + * started, content began arriving, or the turn ended) the part hides itself. + * There is no interactive affordance — this is a progress indicator only. + */ +export class ChatMcpServersStartingContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + private readonly rendered = this._register(new MutableDisposable<IRenderedMarkdown>()); + + constructor( + private readonly data: IChatMcpServersStartingSlow, + @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + ) { + super(); + this.domNode = dom.$('.chat-mcp-servers-interaction'); + this._register(autorun(reader => { + this.render(this.data.servers.read(reader)); + })); + } + + private render(servers: readonly IChatMcpStartingServer[]): void { + dom.clearNode(this.domNode); + this.rendered.clear(); + + if (!servers.length) { + this.domNode.style.display = 'none'; + return; + } + this.domNode.style.display = ''; + + const links = servers + .map(server => '`' + escapeMarkdownSyntaxTokens(server.name) + '`') + .join(', '); + this._renderMessage( + ThemeIcon.modify(Codicon.loading, 'spin'), + localize('mcp.starting.servers', 'Starting MCP servers {0}...', links), + ); + } + + private _renderMessage(icon: ThemeIcon, content: string): void { + const container = dom.$('.chat-mcp-servers-interaction-hint'); + const messageContainer = dom.$('.chat-mcp-servers-message'); + const iconElement = dom.$('.chat-mcp-servers-icon'); + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); + + const rendered = this.rendered.value = this.markdownRendererService.render(new MarkdownString(content)); + messageContainer.appendChild(iconElement); + messageContainer.appendChild(rendered.element); + container.appendChild(messageContainer); + this.domNode.appendChild(container); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return other.kind === 'mcpServersStartingSlow'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 7f479c48940b89..b3dd86890bda23 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../../../../base/browser/dom.js'; +import { Gesture, EventType as TouchEventType } from '../../../../../../base/browser/touch.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; @@ -13,6 +14,8 @@ import { DisposableStore, IDisposable, MutableDisposable } from '../../../../../ import { autorun } from '../../../../../../base/common/observable.js'; import { rcut } from '../../../../../../base/common/strings.js'; import { localize } from '../../../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -83,7 +86,8 @@ type ILazyItem = ILazyToolItem | ILazyMarkdownItem | ILazyHookItem; */ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implements IChatContentPart { private wrapper!: HTMLElement; - private isActive: boolean = true; + private isActive: boolean; + private isExternallyActive: boolean; private hasToolItems: boolean = false; private readonly isInitiallyComplete: boolean; private promptContainer: HTMLElement | undefined; @@ -110,6 +114,17 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private _isDefaultDescription: boolean; private readonly _hoverDisposable = this._register(new MutableDisposable()); + // The subagent tool invocation, kept so the "Open Subagent" action can re-read + // the subagent chat resource as it arrives/changes. + private readonly _subagentToolInvocation: IChatToolInvocation | IChatToolInvocationSerialized; + /** + * Toolbar hosting the `MenuId.ChatSubagentContent` menu in the subagent + * header. The Agents window contributes an "Open Subagent" action (rendered + * as a pill) into this menu; elsewhere the menu is empty and nothing shows. + */ + private _openChatToolbar: MenuWorkbenchToolBar | undefined; + private _openChatToolbarContainer: HTMLElement | undefined; + // Confirmation auto-expand tracking private toolsWaitingForConfirmation: number = 0; private userManuallyExpanded: boolean = false; @@ -125,6 +140,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen private _useCarouselForConfirmations: boolean = false; private toolsWaitingForCarouselConfirmation: number = 0; + /** Per-tool-invocation autoruns observing tool state; each is disposed once its tool reaches a terminal state so listeners don't accumulate for the widget's lifetime. */ + private readonly _toolStateTracking = this._register(new DisposableStore()); + // Working spinner elements for expanded state private workingSpinnerElement: HTMLElement | undefined; private workingSpinnerLabel: HTMLElement | undefined; @@ -187,6 +205,61 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return { description: defaultDescription, isDefaultDescription: true, agentName: undefined, prompt: undefined, modelName: undefined, credits: undefined }; } + /** The subagent's own chat resource (URI string), when it runs as a distinct chat. */ + private _getChatResource(): string | undefined { + const data = this._subagentToolInvocation.toolSpecificData; + return data?.kind === 'subagent' ? data.chatResource : undefined; + } + + /** + * Creates (once) and toggles the subagent header toolbar that hosts the + * `MenuId.ChatSubagentContent` menu. The Agents window contributes an "Open + * Subagent" pill into that menu to reveal the subagent's own (read-only) + * chat; in the regular chat view the menu is empty and nothing renders. The + * subagent chat resource can arrive after the part is first constructed, so + * this is also called from the tool-completion autorun. + */ + private _updateOpenChatLink(): void { + const resource = this._getChatResource(); + if (!this._collapseButton) { + return; + } + // When the subagent has its own openable chat, keep the inline block + // collapsed to just the header + "Open Subagent" pill — the full transcript + // lives in the dedicated read-only chat. Toggle a class the CSS uses to + // suppress the collapsed streaming peek. + this.domNode.classList.toggle('chat-subagent-has-chat', !!resource); + if (!resource) { + this._openChatToolbarContainer?.classList.add('hidden'); + return; + } + if (!this._openChatToolbar) { + const container = $('.chat-subagent-open-chat-toolbar'); + // Before the title label so the pill keeps a fixed position as the title streams. + this._collapseButton.element.insertBefore(container, this._collapseButton.labelElement); + this._openChatToolbarContainer = container; + this._openChatToolbar = this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, container, MenuId.ChatSubagentContent, { + hiddenItemStrategy: HiddenItemStrategy.Ignore, + menuOptions: { shouldForwardArgs: true }, + toolbarOptions: { primaryGroup: () => true }, + })); + // Stop propagation on the pill (the toolbar element) so activating it + // opens the subagent chat without also toggling the section; clicks on + // the sibling prefix / empty row space still fall through to the collapse + // button and toggle. Gesture target covers touch tap. + const pill = this._openChatToolbar.getElement(); + this._register(Gesture.addTarget(pill)); + this._register(dom.addDisposableListener(pill, dom.EventType.MOUSE_DOWN, e => e.stopPropagation())); + this._register(dom.addDisposableListener(pill, dom.EventType.CLICK, e => e.stopPropagation())); + this._register(dom.addDisposableListener(pill, TouchEventType.Tap, e => e.stopPropagation())); + } + // The contributed action reads the subagent chat resource (and the agent + // name, shown as the pill prefix in the Agents window) from the forwarded + // toolbar context. + this._openChatToolbar.context = { chatResource: resource, agentName: this.agentName }; + this._openChatToolbarContainer!.classList.remove('hidden'); + } + constructor( public readonly subAgentInvocationId: string, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, @@ -216,17 +289,27 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.prompt = prompt; this.modelName = modelName; this.credits = credits; - this.isInitiallyComplete = this.element.isComplete; + this.isInitiallyComplete = IChatToolInvocation.isComplete(toolInvocation); + this.isExternallyActive = toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.isActive === true; + this.isActive = toolInvocation.toolSpecificData?.kind === 'subagent' + ? toolInvocation.toolSpecificData.isActive ?? !this.isInitiallyComplete + : !this.isInitiallyComplete; + this._subagentToolInvocation = toolInvocation; const node = this.domNode; node.classList.add('chat-thinking-box', 'chat-thinking-fixed-mode', 'chat-subagent-part'); - if (!this.element.isComplete) { + // Anchor the `MenuId.ChatSubagentContent` menu in the subagent header so + // the Agents window can contribute an "Open Subagent" pill to reveal the + // subagent's own (read-only) chat when it runs as a distinct chat. + this._updateOpenChatLink(); + + if (this.isActive) { node.classList.add('chat-thinking-active'); } // Apply shimmer to the initial title when still active - if (!this.element.isComplete && this._collapseButton) { + if (this.isActive && this._collapseButton) { const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; this.titleShimmerSpan = $('span.chat-thinking-title-shimmer'); @@ -236,14 +319,14 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Note: wrapper is created lazily in initContent(), so we can't set its style here - if (this._collapseButton && !this.element.isComplete) { + if (this._collapseButton && this.isActive) { this._collapseButton.icon = Codicon.circleFilled; } this._register(autorun(r => { this.expanded.read(r); if (this._collapseButton) { - if (!this.element.isComplete && this.isActive) { + if (this.isActive) { this._collapseButton.icon = Codicon.circleFilled; } else { this._collapseButton.icon = Codicon.check; @@ -429,6 +512,10 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return this.isActive; } + public shouldRemainActive(): boolean { + return this.isExternallyActive; + } + public get hasToolsWaitingForConfirmation(): boolean { return this.toolsWaitingForConfirmation > 0; } @@ -473,6 +560,33 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.setExpanded(false); } + private markAsActive(): void { + if (this.isActive) { + return; + } + this.isActive = true; + this.domNode.classList.add('chat-thinking-active'); + if (this._collapseButton) { + this._collapseButton.icon = Codicon.circleFilled; + } + if (this.wrapper && !this.hasToolsWaitingForConfirmation) { + this.showWorkingSpinner(); + } + this.updateTitle(); + } + + private refreshActiveStateFromToolData(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolSpecificData?.kind !== 'subagent' || toolInvocation.toolSpecificData.isActive === undefined) { + return; + } + this.isExternallyActive = toolInvocation.toolSpecificData.isActive; + if (toolInvocation.toolSpecificData.isActive) { + this.markAsActive(); + } else { + this.markAsInactive(); + } + } + public finalizeTitle(): void { this.updateTitle(); if (this._collapseButton) { @@ -631,7 +745,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen let wasWaitingForConfirmation = false; let wasWaitingForCarouselConfirmation = false; - this._register(autorun(r => { + const toolStateAutorun = autorun(r => { const state = toolInvocation.state.read(r); const isWaitingForConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || @@ -674,7 +788,13 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen wasWaitingForConfirmation = isWaitingForConfirmation; wasWaitingForCarouselConfirmation = isWaitingForCarouselConfirmation; - })); + + // On terminal state, dispose this autorun (deferred so we don't dispose it mid-run) to avoid leaking a listener per tool invocation. + if (state.type === IChatToolInvocation.StateKind.Completed || state.type === IChatToolInvocation.StateKind.Cancelled) { + queueMicrotask(() => this._toolStateTracking.delete(toolStateAutorun)); + } + }); + this._toolStateTracking.add(toolStateAutorun); } private getConfirmationPlaceholderText(): string { @@ -763,6 +883,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen let wasStreaming = toolInvocation.state.get().type === IChatToolInvocation.StateKind.Streaming; this._register(autorun(r => { const state = toolInvocation.state.read(r); + this.refreshActiveStateFromToolData(toolInvocation); if (state.type === IChatToolInvocation.StateKind.Completed) { wasStreaming = false; // Extract text from result @@ -789,8 +910,12 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // subagent's child turns report their final usage. this.refreshCreditsFromToolData(toolInvocation); - // Mark as inactive when the tool completes - this.markAsInactive(); + // The subagent chat resource may have arrived with completion. + this._updateOpenChatLink(); + + if (!this.isExternallyActive) { + this.markAsInactive(); + } } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { wasStreaming = false; // Update things that change when tool is done streaming @@ -827,6 +952,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } this.refreshCreditsFromToolData(toolInvocation); this.refreshModelFromToolData(toolInvocation); + this._updateOpenChatLink(); } })); } else if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.result) { @@ -1111,7 +1237,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Dynamically add/remove icon based on confirmation state if (toolInvocation.kind === 'toolInvocation') { const shouldUseCarouselForTool = this._shouldUseCarouselForTool; - this._register(autorun(r => { + const iconAutorun = autorun(r => { const state = toolInvocation.state.read(r); const hasConfirmation = state.type === IChatToolInvocation.StateKind.WaitingForConfirmation || state.type === IChatToolInvocation.StateKind.WaitingForPostApproval; @@ -1133,7 +1259,13 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.ensurePlaceholderAtBottom(); } } - })); + + // Terminal state is final and settles into the non-confirmation branch above, so dispose (deferred so we don't dispose it mid-run) to avoid leaking a listener per tool invocation. + if (state.type === IChatToolInvocation.StateKind.Completed || state.type === IChatToolInvocation.StateKind.Cancelled) { + queueMicrotask(() => this._toolStateTracking.delete(iconAutorun)); + } + }); + this._toolStateTracking.add(iconAutorun); } else { // For serialized invocations, always show icon (already completed) itemWrapper.insertBefore(iconElement, itemWrapper.firstChild); @@ -1227,26 +1359,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - if (other.kind === 'markdownContent') { - return true; - } - - // Match hook parts with the same subAgentInvocationId to keep them grouped in the subagent dropdown - if (other.kind === 'hook' && other.subAgentInvocationId) { - return this.subAgentInvocationId === other.subAgentInvocationId; - } - - // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped - if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || ChatSubagentContentPart.isParentSubagentTool(other))) { - // For parent subagent tool, use toolCallId as the effective ID - const otherEffectiveId = other.subAgentInvocationId ?? other.toolCallId; - // If both have IDs, they must match - if (this.subAgentInvocationId && otherEffectiveId) { - return this.subAgentInvocationId === otherEffectiveId; - } - // Fallback for tools without IDs - group if this part has no ID and tool has no ID - return !this.subAgentInvocationId && !otherEffectiveId; - } - return false; + return (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && ChatSubagentContentPart.isParentSubagentTool(other) + && this.subAgentInvocationId === other.toolCallId; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts new file mode 100644 index 00000000000000..92f4e85b25d962 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSystemNotificationContentPart.ts @@ -0,0 +1,32 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatSystemNotificationPart } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart } from './chatContentParts.js'; +import { ChatProgressSubPart } from './chatProgressContentPart.js'; + +export class ChatSystemNotificationContentPart extends Disposable implements IChatContentPart { + readonly domNode: HTMLElement; + + constructor( + private readonly notification: IChatSystemNotificationPart, + renderer: IMarkdownRenderer, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const rendered = this._register(renderer.render(notification.content)); + this.domNode = this._register(instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined)).domNode; + } + + hasSameContent(other: IChatRendererContent): boolean { + return other.kind === 'systemNotification' && other.content.value === this.notification.content.value; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 444be7161109c2..bb72e0eb14248a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -90,13 +90,36 @@ function isGenericEditToolId(toolId: string): boolean { lowerToolId.includes('editfile'); } -export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon): ThemeIcon { +function isProblemsToolId(toolId: string | undefined): boolean { + switch (toolId?.toLowerCase()) { + case 'problems': + case 'get_errors': + case 'copilot_geterrors': + return true; + default: + return false; + } +} + +function isNoProblemsFoundResult(toolId: string | undefined, resultText: string | undefined): boolean { + return isProblemsToolId(toolId) && resultText?.toLowerCase().includes('no problems found') === true; +} + +export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon, resultText?: string): ThemeIcon { + if (isNoProblemsFoundResult(toolId, resultText)) { + return Codicon.search; + } + if (registeredIcon) { return registeredIcon; } const lowerToolId = toolId.toLowerCase(); + if (lowerToolId.includes('comment')) { + return Codicon.comment; + } + if ( lowerToolId.includes('search') || lowerToolId.includes('grep') || @@ -138,6 +161,11 @@ export function createThinkingIcon(icon: ThemeIcon): HTMLElement { return iconElement; } +function setThinkingIcon(iconElement: HTMLElement, icon: ThemeIcon): void { + iconElement.className = 'chat-thinking-icon'; + iconElement.classList.add(...ThemeIcon.asClassNameArray(icon)); +} + function extractTitleFromThinkingContent(content: string): string | undefined { const headerMatch = content.match(/^\*\*([^*]+)\*\*/); return headerMatch ? headerMatch[1] : undefined; @@ -1880,8 +1908,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const iconEl = this.toolIconsByCallId.get(toolCallId); if (iconEl) { const newIcon = termData.commandLine?.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal; - iconEl.className = 'chat-thinking-icon'; - iconEl.classList.add(...ThemeIcon.asClassNameArray(newIcon)); + setThinkingIcon(iconEl, newIcon); } } @@ -1904,6 +1931,12 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): // Render image pills outside the collapsible area for completed tools if (currentState.type === IChatToolInvocation.StateKind.Completed) { this.updateExternalResourceParts(toolInvocationOrMarkdown); + const completedMessage = toolInvocationOrMarkdown.pastTenseMessage ?? toolInvocationOrMarkdown.invocationMessage; + const completedText = typeof completedMessage === 'string' ? completedMessage : completedMessage.value; + const iconElement = this.toolIconsByCallId.get(toolCallId); + if (iconElement && isNoProblemsFoundResult(toolInvocationOrMarkdown.toolId, completedText)) { + setThinkingIcon(iconElement, Codicon.search); + } } isComplete = true; @@ -2039,7 +2072,9 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; let icon: ThemeIcon; - if (isMarkdownEdit || isExternalEdit) { + if (isNoProblemsFoundResult(toolInvocationId, content.textContent ?? undefined)) { + icon = Codicon.search; + } else if (isMarkdownEdit || isExternalEdit) { icon = Codicon.pencil; } else if (isSearchTool) { icon = Codicon.search; @@ -2059,7 +2094,7 @@ ${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): } else if (content.classList.contains('chat-hook-outcome-warning')) { icon = Codicon.warning; } else { - icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon) : Codicon.tools; + icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon, content.textContent ?? undefined) : Codicon.tools; } const iconElement = createThinkingIcon(icon); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts new file mode 100644 index 00000000000000..a2e0e839c3b0c5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.ts @@ -0,0 +1,285 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { $ } from '../../../../../../base/browser/dom.js'; +import { IAction, toAction } from '../../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { combinedDisposable, Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, constObservable, derived, derivedOpts, IObservable } from '../../../../../../base/common/observable.js'; +import { basename, isEqual } from '../../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../../nls.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { FileKind } from '../../../../../../platform/files/common/files.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../../browser/labels.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js'; +import { MultiDiffEditorInput } from '../../../../multiDiffEditor/browser/multiDiffEditorInput.js'; +import { MultiDiffEditorItem } from '../../../../multiDiffEditor/browser/multiDiffSourceResolverService.js'; +import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; +import { IChatRendererContent, IChatTurnPillsPart } from '../../../common/model/chatViewModel.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IChatResponseFileChangesService } from '../../chatResponseFileChangesService.js'; +import { diffStatsEqual, EMPTY_DIFF_STATS, IDiffStats, IPreviewFile, observeTurnStatusPillsConfig, openChatPreviewFile, previewFilesEqual, previewKind } from '../chatTurnPills.js'; +import { renderChangesSummaryFileList } from './chatChangesSummaryPart.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; + +/** + * Renders a single agent turn's changes as a checkpoint-style summary: a + * `N files changed +ins -del` header with a "View All File Changes" action, an + * optional inline "preview <file>" action for the first previewable file the turn + * produced, and a disclosure that expands to the list of changed files. Fed by + * the per-request diffs from {@link IChatResponseFileChangesService} (agent host + * sessions), so it reflects the same authoritative per-turn changes as the + * checkpoint "N files changed" summary. Which pieces show is controlled per-turn + * by the {@link observeTurnStatusPillsConfig} setting; the part self-hides when + * nothing is shown. + */ +export class ChatTurnPillsContentPart extends Disposable implements IChatContentPart { + + readonly domNode: HTMLElement; + + private readonly _diffs: IObservable<readonly IEditSessionEntryDiff[]>; + + constructor( + private readonly _content: IChatTurnPillsPart, + _context: IChatContentPartRenderContext, + @IChatResponseFileChangesService chatResponseFileChangesService: IChatResponseFileChangesService, + @ICommandService private readonly _commandService: ICommandService, + @IOpenerService private readonly _openerService: IOpenerService, + @ILogService private readonly _logService: ILogService, + @IHoverService private readonly _hoverService: IHoverService, + @IEditorService private readonly _editorService: IEditorService, + @IConfigurationService configurationService: IConfigurationService, + @IThemeService themeService: IThemeService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + this.domNode = $('.chat-turn-pills-part'); + + this._diffs = chatResponseFileChangesService.getChangesForRequest(_content.sessionResource, _content.requestId) ?? constObservable([]); + + const stats = derivedOpts<IDiffStats>({ owner: this, equalsFn: diffStatsEqual }, reader => { + const diffs = this._diffs.read(reader); + if (diffs.length === 0) { + return EMPTY_DIFF_STATS; + } + let insertions = 0, deletions = 0; + for (const diff of diffs) { + insertions += diff.added; + deletions += diff.removed; + } + return { files: diffs.length, insertions, deletions }; + }); + + const previewFiles = derivedOpts<readonly IPreviewFile[]>({ owner: this, equalsFn: previewFilesEqual }, reader => { + const created: IPreviewFile[] = []; + const edited: IPreviewFile[] = []; + for (const diff of this._diffs.read(reader)) { + const kind = previewKind(diff.modifiedURI); + if (!kind) { + continue; + } + // The agent host provider maps a created file's `originalURI` to its + // `modifiedURI` (there is no before-content), so equal URIs mark a + // creation. Created files are listed first so the primary preview is + // the first created file, else the first edited one. + const isCreated = isEqual(diff.originalURI, diff.modifiedURI); + (isCreated ? created : edited).push({ uri: diff.modifiedURI, kind, created: isCreated }); + } + return [...created, ...edited]; + }); + + const pillsConfig = observeTurnStatusPillsConfig(configurationService); + const changesEnabled = derived(this, reader => pillsConfig.read(reader).changes); + const previewEnabled = derived(this, reader => pillsConfig.read(reader).preview); + const showChanges = derived(this, reader => changesEnabled.read(reader) && stats.read(reader).files > 0); + const showPreview = derived(this, reader => previewEnabled.read(reader) && previewFiles.read(reader).length > 0); + + // Reuse the checkpoint summary's structure and classes so the two look + // identical. `show-file-icons` (added by the themable tree scope below) + // lets the preview action's resource label render the file's themed icon. + const root = this.domNode.appendChild($('.checkpoint-file-changes-summary.checkpoint-file-changes-compact')); + this._register(createFileIconThemableTreeContainerScope(root, themeService)); + + const details = root.appendChild(document.createElement('details')); + details.classList.add('checkpoint-file-changes-disclosure'); + const header = details.appendChild(document.createElement('summary')); + header.classList.add('checkpoint-file-changes-summary-header'); + + const resourceLabels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._register(this._renderChangesHeader(header, stats, showChanges)); + this._register(this._renderPreviewAction(header, previewFiles, showPreview, resourceLabels)); + this._register(this._renderChevron(header, details, showChanges)); + + // Only feed diffs into the list when the changes summary is shown, so the + // disclosure stays empty when just the preview action is enabled. Each + // previewable row gets a "Preview" action that opens the file's preview. + const listDiffs = derived(this, reader => showChanges.read(reader) ? this._diffs.read(reader) : []); + this._register(renderChangesSummaryFileList(details, listDiffs, this._instantiationService, this._editorService, configurationService, { + getRowActions: diff => this._getRowActions(diff), + })); + + this._register(autorun(reader => { + this.domNode.style.display = (showChanges.read(reader) || showPreview.read(reader)) ? '' : 'none'; + })); + } + + private _renderChangesHeader(header: HTMLElement, stats: IObservable<IDiffStats>, showChanges: IObservable<boolean>): IDisposable { + const filesLabel = header.appendChild($('span.chat-file-changes-label')); + const counts = header.appendChild($('span.chat-file-changes-counts', { 'aria-hidden': 'true' })); + const addedLabel = counts.appendChild($('span.insertions')); + const removedLabel = counts.appendChild($('span.deletions')); + const viewAll = this._renderViewAllFileChangesButton(header); + + return combinedDisposable(viewAll, autorun(reader => { + const { files, insertions, deletions } = stats.read(reader); + const fileCountLabel = files === 1 + ? localize('chat.turnChanges.oneFile', '1 file changed') + : localize('chat.turnChanges.manyFiles', '{0} files changed', files); + filesLabel.textContent = fileCountLabel; + addedLabel.textContent = `+${insertions}`; + removedLabel.textContent = `-${deletions}`; + header.setAttribute('aria-label', localize( + 'chat.turnChanges.accessibleSummary', + '{0}, {1} lines added, {2} lines deleted', + fileCountLabel, + insertions, + deletions + )); + + const show = showChanges.read(reader); + filesLabel.classList.toggle('hidden', !show); + counts.classList.toggle('hidden', !show); + viewAll.element.classList.toggle('hidden', !show); + })); + } + + private _renderViewAllFileChangesButton(header: HTMLElement): { readonly element: HTMLElement } & IDisposable { + const button = header.appendChild(document.createElement('button')); + button.classList.add('chat-view-changes-icon'); + button.type = 'button'; + button.classList.add(...ThemeIcon.asClassNameArray(Codicon.diffMultiple)); + button.setAttribute('aria-label', localize('chat.viewTurnFileChangesSummary', 'View All File Changes')); + const hoverDisposable = this._hoverService.setupDelayedHover(button, () => ({ + content: localize2('chat.viewTurnFileChangesSummary', 'View All File Changes') + })); + + const clickDisposable = dom.addDisposableListener(button, 'click', (e) => { + this._openChanges(); + dom.EventHelper.stop(e, true); + }); + + return { element: button, dispose: () => combinedDisposable(hoverDisposable, clickDisposable).dispose() }; + } + + private _renderPreviewAction(header: HTMLElement, previewFiles: IObservable<readonly IPreviewFile[]>, showPreview: IObservable<boolean>, resourceLabels: ResourceLabels): IDisposable { + const container = header.appendChild($('.chat-turn-preview')); + container.appendChild($('span.chat-turn-preview-separator', { 'aria-hidden': 'true' })); + + const button = container.appendChild(document.createElement('button')); + button.classList.add('chat-turn-preview-action'); + button.type = 'button'; + const verb = button.appendChild($('span.chat-turn-preview-verb')); + verb.textContent = localize('chat.turnPreview.verb', 'preview'); + const label = this._register(resourceLabels.create(button, { supportIcons: true })); + + const clickDisposable = dom.addDisposableListener(button, 'click', (e) => { + this._openPrimaryPreview(previewFiles.get()); + dom.EventHelper.stop(e, true); + }); + + return combinedDisposable(clickDisposable, autorun(reader => { + const files = previewFiles.read(reader); + const primaryFile = files.at(0); + if (primaryFile) { + label.setResource( + { resource: primaryFile.uri, name: basename(primaryFile.uri) }, + { fileKind: FileKind.FILE }, + ); + const tooltip = localize('chat.turnPreview.tooltip', 'Open Preview: {0}', basename(primaryFile.uri)); + button.setAttribute('aria-label', tooltip); + button.title = tooltip; + } + container.classList.toggle('hidden', !showPreview.read(reader)); + })); + } + + private _renderChevron(header: HTMLElement, details: HTMLDetailsElement, showChanges: IObservable<boolean>): IDisposable { + const chevron = header.appendChild($('span.chat-file-changes-chevron.chat-collapsible-hover-chevron', { 'aria-hidden': 'true' })); + chevron.classList.add(...ThemeIcon.asClassNameArray(Codicon.chevronRight)); + + const setExpansionState = () => { + header.setAttribute('aria-expanded', String(details.open)); + chevron.classList.toggle('codicon-chevron-right', !details.open); + chevron.classList.toggle('codicon-chevron-down', details.open); + }; + setExpansionState(); + + return combinedDisposable( + dom.addDisposableListener(details, 'toggle', setExpansionState), + autorun(reader => { + chevron.classList.toggle('hidden', !showChanges.read(reader)); + }), + ); + } + + private _openChanges(): void { + const diffs = this._diffs.get(); + if (diffs.length === 0) { + return; + } + const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); + const input = this._instantiationService.createInstance( + MultiDiffEditorInput, + source, + localize('chatTurnPills.changes.title', "Turn File Changes"), + diffs.map(diff => new MultiDiffEditorItem(diff.originalURI, diff.modifiedURI, undefined)), + false, + ); + this._editorService.openEditor(input); + } + + private _openPrimaryPreview(files: readonly IPreviewFile[]): void { + const primaryFile = files.at(0); + if (primaryFile) { + openChatPreviewFile(primaryFile, this._commandService, this._openerService, this._logService); + } + } + + /** + * Row actions for the changed-files list: markdown and HTML files get a + * labelless-icon-free "Preview" action that opens the file as a markdown + * preview or in the integrated browser. + */ + private _getRowActions(diff: IEditSessionEntryDiff): IAction[] { + const kind = previewKind(diff.modifiedURI); + if (!kind) { + return []; + } + const file: IPreviewFile = { uri: diff.modifiedURI, kind, created: isEqual(diff.originalURI, diff.modifiedURI) }; + return [toAction({ + id: 'chat.turnChanges.previewFile', + label: localize('chat.turnChanges.preview', "Preview"), + run: () => openChatPreviewFile(file, this._commandService, this._openerService, this._logService), + })]; + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + return other.kind === 'turnPills' + && other.requestId === this._content.requestId + && isEqual(other.sessionResource, this._content.sessionResource); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAutoModeResolution.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAutoModeResolution.css new file mode 100644 index 00000000000000..cf50003db715f7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatAutoModeResolution.css @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-auto-mode-resolution-content { + border: none; + border-radius: 0; + padding-top: 0; +} + +.chat-auto-mode-resolution-body { + border-left: var(--vscode-strokeThickness) solid var(--vscode-chat-requestBorder); + margin-left: 4px; + padding-left: 12px; + padding-top: 2px; + padding-bottom: 2px; +} + +.chat-auto-mode-resolution-body p { + margin: 0; +} + +.chat-auto-mode-resolution-explanation { + color: var(--vscode-descriptionForeground); +} + +.chat-auto-mode-resolution-detail { + margin-top: 4px; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css index c81201545fc41b..83c91b6588e0ef 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatCodeBlockPill.css @@ -7,6 +7,10 @@ margin-bottom: 16px; } +.interactive-item-container.editing-session.interactive-response > .value > .chat-codeblock-pill-container { + margin-bottom: 14px; +} + .chat-codeblock-pill-container { display: flex; align-items: center; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css index 1db44f94d05b30..94e4d8a62f9470 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatSubagentContent.css @@ -13,6 +13,13 @@ display: block; } + /* When the subagent has its own openable chat, suppress the collapsed inline + trace entirely (including the streaming peek): the header + "Open chat" pill + stand in for it, and the full transcript lives in the dedicated chat. */ + &.chat-subagent-has-chat.chat-used-context-collapsed .chat-used-context-list.chat-thinking-collapsible { + display: none; + } + /* Expanded: show all content, no max-height, no scrolling */ .chat-used-context-list.chat-thinking-collapsible { max-height: none; @@ -91,3 +98,19 @@ } } } + +/* Toolbar hosting the contributed "Open Subagent" pill (via + `MenuId.ChatSubagentContent`). Sits at the start of the subagent header row, + before the streaming title, so it keeps a fixed position; the pill itself is + rendered and styled by the Agents-window contribution. */ +.chat-subagent-part .chat-subagent-open-chat-toolbar { + display: inline-flex; + align-items: center; + margin-right: 2px; + flex-shrink: 0; +} + +.chat-subagent-part .chat-subagent-open-chat-toolbar.hidden, +.chat-subagent-part .chat-subagent-open-chat-toolbar.has-no-actions { + display: none; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css index 5bd08d1c907cb2..e39285f05ccc5a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTerminalToolProgressPart.css @@ -8,6 +8,58 @@ min-width: 0; } +/* + * Intention label layout: when the model supplies an intention, the collapsed + * terminal-row header shows "<intention> <command>" as two flex cells on a + * single non-wrapping line. Each cell ellipsis-truncates and they split the + * available width equally when the content overflows the row. + */ +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .chat-used-context-label { + width: 100%; + max-width: 100%; +} + +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .monaco-button.monaco-icon-button { + width: fit-content; + max-width: 100%; + justify-content: flex-start; +} + +.chat-terminal-thinking-collapsible.chat-terminal-has-intention .monaco-button-mdlabel { + flex: 1 1 auto; + min-width: 0; +} + +.chat-terminal-label-flex { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: baseline; + column-gap: 4px; + min-width: 0; + width: 100%; +} + +.chat-terminal-label-flex > .chat-terminal-intention, +.chat-terminal-label-flex > .chat-terminal-command { + /* Natural width so short content stays inline and adjacent; only shrink + * (dividing the available space) once the row would overflow. */ + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-terminal-label-flex > .chat-terminal-command > code { + white-space: nowrap; +} + +.chat-terminal-label-flex > .chat-terminal-label-suffix { + flex: 0 0 auto; + white-space: nowrap; +} + .chat-terminal-progress-row > div:first-child { display: none; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 98bebe70d916c3..3e9356f3943b7d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -564,6 +564,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const wrapper = this._register(this._instantiationService.createInstance( ChatTerminalThinkingCollapsibleWrapper, truncatedCommand, + this._terminalData.intention, this._terminalData.commandLine.isSandboxWrapped === true, contentElement, context, @@ -1657,6 +1658,7 @@ class ChatTerminalToolOutputSection extends Disposable { export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart { private readonly _terminalContentElement: HTMLElement; private readonly _commandText: string; + private readonly _intention: string | undefined; private readonly _isSandboxWrapped: boolean; private _isComplete: boolean; private readonly _isSkipped: boolean; @@ -1667,6 +1669,7 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte constructor( commandText: string, + intention: string | undefined, isSandboxWrapped: boolean, contentElement: HTMLElement, context: IChatContentPartRenderContext, @@ -1678,17 +1681,29 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte @IHoverService hoverService: IHoverService, @IConfigurationService configurationService: IConfigurationService, ) { - const title = isSkipped + // When the model supplied an intention (why it's running the command), + // use it as the descriptive text instead of the generic verb. Skipped + // commands keep the explicit "Skipped" wording since they never ran. + // The intention and command are not localizable, so they are combined + // directly; only the state suffix is externalized. + const intentionText = intention && !isSkipped ? intention : undefined; + const stateTitle = isSkipped ? localize('chat.terminal.skipped.plain', "Skipped {0}", commandText) : isRunningInBackground ? localize('chat.terminal.runningInBackground.plain', "Running {0} in background", commandText) : isComplete ? localize('chat.terminal.ran.plain', "Ran {0}", commandText) : localize('chat.terminal.running.plain', "Running {0}", commandText); + const title = intentionText + ? isRunningInBackground + ? `${intentionText} ${commandText}${localize('chat.terminal.backgroundSuffix', " in background")}` + : `${intentionText} ${commandText}` + : stateTitle; super(title, context, undefined, hoverService, configurationService); this._terminalContentElement = contentElement; this._commandText = commandText; + this._intention = intentionText; this._isSandboxWrapped = isSandboxWrapped; this._isComplete = isComplete; this._isSkipped = isSkipped; @@ -1714,36 +1729,54 @@ export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleConte const labelElement = this._collapseButton.labelElement; labelElement.textContent = ''; - if (this._isSandboxWrapped) { - const prefixText = this._isSkipped - ? localize('chat.terminal.skippedInSandbox.prefix', "Skipped ") - : this._isComplete - ? localize('chat.terminal.ranInSandbox.prefix', "Ran ") - : localize('chat.terminal.runningInSandbox.prefix', "Running "); - const suffixText = this._isRunningInBackground + const suffixText = this._isSandboxWrapped + ? this._isRunningInBackground ? localize('chat.terminal.sandbox.backgroundSuffix', " in sandbox (background)") - : localize('chat.terminal.sandbox.suffix', " in sandbox"); - labelElement.appendChild(document.createTextNode(prefixText)); + : localize('chat.terminal.sandbox.suffix', " in sandbox") + : this._isRunningInBackground + ? localize('chat.terminal.backgroundSuffix', " in background") + : undefined; + + // Intention layout: the intention and the command share the row as two + // flex cells that stay on one line and each ellipsis-truncate, splitting + // the available width equally when the content overflows. + this.domNode.classList.toggle('chat-terminal-has-intention', !!this._intention); + if (this._intention) { + const row = dom.$('span.chat-terminal-label-flex'); + const intentionElement = dom.$('span.chat-terminal-intention'); + intentionElement.textContent = this._intention; + const commandElement = dom.$('span.chat-terminal-command'); const codeElement = document.createElement('code'); codeElement.textContent = this._commandText; - labelElement.appendChild(codeElement); - labelElement.appendChild(document.createTextNode(suffixText)); + commandElement.appendChild(codeElement); + row.appendChild(intentionElement); + row.appendChild(commandElement); + if (suffixText) { + const suffixElement = dom.$('span.chat-terminal-label-suffix'); + suffixElement.textContent = suffixText; + row.appendChild(suffixElement); + } + labelElement.appendChild(row); return; } - const prefixText = this._isSkipped - ? localize('chat.terminal.skipped.prefix', "Skipped ") - : this._isComplete - ? localize('chat.terminal.ran.prefix', "Ran ") - : localize('chat.terminal.running.prefix', "Running "); - const ranText = document.createTextNode(prefixText); + const prefixText = this._isSandboxWrapped + ? this._isSkipped + ? localize('chat.terminal.skippedInSandbox.prefix', "Skipped ") + : this._isComplete + ? localize('chat.terminal.ranInSandbox.prefix', "Ran ") + : localize('chat.terminal.runningInSandbox.prefix', "Running ") + : this._isSkipped + ? localize('chat.terminal.skipped.prefix', "Skipped ") + : this._isComplete + ? localize('chat.terminal.ran.prefix', "Ran ") + : localize('chat.terminal.running.prefix', "Running "); + labelElement.appendChild(document.createTextNode(prefixText)); const codeElement = document.createElement('code'); codeElement.textContent = this._commandText; - - labelElement.appendChild(ranText); labelElement.appendChild(codeElement); - if (this._isRunningInBackground) { - labelElement.appendChild(document.createTextNode(localize('chat.terminal.backgroundSuffix', " in background"))); + if (suffixText) { + labelElement.appendChild(document.createTextNode(suffixText)); } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 66e50b5272a8bd..fb9b68242922fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -58,7 +58,7 @@ import { ChatQuestionCarouselData } from '../../common/model/chatProgressTypes/c import { localChatSessionType, SessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { getExplicitFileOrImageAttachmentSummary, IChatRequestVariableEntry, isExplicitFileOrImageVariableEntry, isPasteVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; +import { getStickyScrollTargetItem, IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatWorkingProgress, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM, IChatTurnPillsPart } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; import { ClickAnimation } from '../../../../../base/browser/ui/animations/animations.js'; @@ -72,7 +72,10 @@ import { ChatContentMarkdownRenderer } from './chatContentMarkdownRenderer.js'; import { ChatAgentCommandContentPart } from './chatContentParts/chatAgentCommandContentPart.js'; import { ChatAnonymousRateLimitedPart } from './chatContentParts/chatAnonymousRateLimitedPart.js'; import { ChatAttachmentsContentPart } from './chatContentParts/chatAttachmentsContentPart.js'; +import { ChatAutoModeResolutionContentPart } from './chatContentParts/chatAutoModeResolutionContentPart.js'; import { ChatCheckpointFileChangesSummaryContentPart } from './chatContentParts/chatChangesSummaryPart.js'; +import { ChatTurnPillsContentPart } from './chatContentParts/chatTurnPillsPart.js'; +import { IChatTurnStatusPillsConfig } from './chatTurnPills.js'; import { ChatCodeCitationContentPart } from './chatContentParts/chatCodeCitationContentPart.js'; import { ChatCommandButtonContentPart } from './chatContentParts/chatCommandContentPart.js'; import { ChatConfirmationContentPart } from './chatContentParts/chatConfirmationContentPart.js'; @@ -86,13 +89,16 @@ import { ChatQuestionCarouselPart } from './chatContentParts/chatQuestionCarouse import { ChatExtensionsContentPart } from './chatContentParts/chatExtensionsContentPart.js'; import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatContentParts/chatMarkdownContentPart.js'; import { ChatMcpServersInteractionContentPart } from './chatContentParts/chatMcpServersInteractionContentPart.js'; +import { ChatMcpAuthenticationContentPart } from './chatContentParts/chatMcpAuthenticationContentPart.js'; +import { ChatMcpServersStartingContentPart } from './chatContentParts/chatMcpServersStartingContentPart.js'; import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js'; import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js'; -import { ChatProgressContentPart, ChatProgressSubPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; +import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js'; import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; import { ChatTaskContentPart } from './chatContentParts/chatTaskContentPart.js'; +import { ChatSystemNotificationContentPart } from './chatContentParts/chatSystemNotificationContentPart.js'; import { ChatTextEditContentPart } from './chatContentParts/chatTextEditContentPart.js'; import { ChatThinkingContentPart, getEffectiveThinkingDisplayMode } from './chatContentParts/chatThinkingContentPart.js'; import { ChatSubagentContentPart } from './chatContentParts/chatSubagentContentPart.js'; @@ -227,6 +233,10 @@ export function shouldScheduleInitialHeightChange(normalizedHeight: number, allo return typeof allocatedHeight !== 'number' || normalizedHeight > allocatedHeight; } +export function shouldRenderInitialProgressiveContentImmediately(isComplete: boolean, hasMarkdownParts: boolean, hasRenderData: boolean): boolean { + return !isComplete && hasMarkdownParts && !hasRenderData; +} + const forceVerboseLayoutTracing = false // || Boolean("TRUE") // causes a linter warning so that it cannot be pushed ; @@ -987,9 +997,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch const rowRoot = templateData.rowContainer.parentElement?.parentElement?.parentElement; rowRoot?.classList.toggle('request', isRequestVM(element)); rowRoot?.classList.toggle('response', isResponseVM(element)); + // `.chat-most-recent-response` marks the actual last row. The reserved-space filler + // (`--chat-current-response-min-height`) is only applied to response rows via CSS, so + // when a queued/steering request row is last it gets no filler and the pending rows + // simply hug the streaming response above them. templateData.rowContainer.classList.toggle(mostRecentResponseClassName, index === this.delegate.getListLength() - 1); templateData.rowContainer.classList.toggle('confirmation-message', isRequestVM(element) && !!element.confirmation); + // The streaming/progressive-rendering target is the last non-pending item, so the active + // response keeps rendering (and the view keeps following it) even when queued or steering + // rows are shown below it. + const isStickyScrollTargetItem = getStickyScrollTargetItem(this.viewModel?.getItems() ?? []) === element; + // TODO: @justschen decide if we want to hide the header for requests or not const shouldShowHeader = (isResponseVM(element) && !this.rendererOptions.noHeader) && !isSystemInitiatedRequest; templateData.header?.classList.toggle('header-disabled', !shouldShowHeader); @@ -999,12 +1018,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } // Do a progressive render if - // - This the last response in the list + // - This is the last non-pending response in the list // - And it has some content // - And the response is not complete // - Or, we previously started a progressive rendering of this element (if the element is complete, we will finish progressive rendering with a very fast rate) const incrementalRendering = this.configService.getValue<boolean>(ChatConfiguration.IncrementalRendering); - if (isResponseVM(element) && index === this.delegate.getListLength() - 1 && (!element.isComplete || element.renderData)) { + if (isResponseVM(element) && isStickyScrollTargetItem && (!element.isComplete || element.renderData)) { this.traceLayout('renderElement', `start progressive render, index=${index}`); if (incrementalRendering && !element.renderData) { @@ -1165,7 +1184,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } if (element.model.response === element.model.entireResponse && !element.isCanceled && element.errorDetails?.message && element.errorDetails.message !== canceledName) { - content.push({ kind: 'errorDetails', errorDetails: element.errorDetails, isLast: index === this.delegate.getListLength() - 1 }); + content.push({ kind: 'errorDetails', errorDetails: element.errorDetails, isLast: getStickyScrollTargetItem(this.viewModel?.getItems() ?? []) === element }); } const fileChangesSummaryPart = this.getChatFileChangesSummaryPart(element); @@ -1173,6 +1192,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch content.push(fileChangesSummaryPart); } + const turnPillsPart = this.getChatTurnPillsPart(element); + if (turnPillsPart) { + content.push(turnPillsPart); + } + const workingProgress = this.shouldShowWorkingProgress(element, content, false, templateData); if (workingProgress) { content.push(workingProgress); @@ -1241,7 +1265,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch }; } - if (this.getPendingToolConfirmationCount(partsToRender, true) > 0 || this.getSubagentPart(templateData.renderedParts)) { + if (this.getPendingToolConfirmationCount(partsToRender, true) > 0) { return undefined; } @@ -1252,8 +1276,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } } - // Find the last meaningful part (skipping empty markdown). - const lastPart = this.findLastMeaningfulPart(partsToRender); + const workingParts = getWorkingProgressRelevantParts(partsToRender); + const lastPart = findLastMeaningfulPart(workingParts); if (showProgressDetails) { // When the thinking section is actively streaming with its own inline @@ -1271,12 +1295,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } // Don't show working if a streaming tool invocation is already present - if (partsToRender.some(part => part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { + if (workingParts.some(part => part.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(part))) { return undefined; } // Don't show working spinner when there's an in-progress MCP tool - MCP tools have their own progress indicator - if (partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { + if (workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part) && isMcpToolInvocation(part))) { return undefined; } @@ -1301,25 +1325,22 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } const hasRenderedThinkingPart = (templateData.renderedParts ?? []).some(part => part instanceof ChatThinkingContentPart); - const hasEditPillMarkdown = partsToRender.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); + const hasEditPillMarkdown = workingParts.some(part => part.kind === 'markdownContent' && this.hasEditCodeblockUri(part)); if (hasRenderedThinkingPart && hasEditPillMarkdown) { return undefined; } - // Don't show working spinner when there's any active subagent - subagents have their own progress indicator - if (this.getSubagentPart(templateData.renderedParts)) { - return undefined; - } - if ( !lastPart || lastPart.kind === 'references' || (lastPart.kind === 'markdownContent' && !moreContentAvailable && this.hasBeenCaughtUpLongEnough(element)) || ((lastPart.kind === 'toolInvocation' || lastPart.kind === 'toolInvocationSerialized') && (IChatToolInvocation.isComplete(lastPart) || IChatToolInvocation.isEffectivelyHidden(lastPart))) || - ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || - (lastPart.kind === 'externalEdit' && !partsToRender.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || + ((lastPart.kind === 'textEditGroup' || lastPart.kind === 'notebookEditGroup') && lastPart.done && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || + (lastPart.kind === 'externalEdit' && !workingParts.some(part => part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || lastPart.kind === 'mcpServersStarting' || + lastPart.kind === 'mcpAuthenticationRequired' || + lastPart.kind === 'mcpServersStartingSlow' || lastPart.kind === 'disabledClaudeHooks' || lastPart.kind === 'hook' ) { @@ -1457,16 +1478,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch * Returns the last part that visually contributes to the response, skipping * empty markdown placeholders. */ - private findLastMeaningfulPart(partsToRender: readonly IChatRendererContent[]): IChatRendererContent | undefined { - for (let i = partsToRender.length - 1; i >= 0; i--) { - const part = partsToRender[i]; - if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { - return part; - } - } - return undefined; - } - /** * True while we have caught up to streamed markdown but are still within the * {@link WORKING_CAUGHT_UP_DEBOUNCE_MS} window before the working indicator @@ -1484,7 +1495,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return false; } // Only the streamed-markdown "caught up" case is gated behind the debounce. - return this.findLastMeaningfulPart(partsToRender)?.kind === 'markdownContent' && !this.hasBeenCaughtUpLongEnough(element); + return findLastMeaningfulPart(getWorkingProgressRelevantParts(partsToRender))?.kind === 'markdownContent' && !this.hasBeenCaughtUpLongEnough(element); } private getChatFileChangesSummaryPart(element: IChatResponseViewModel): IChatChangesSummaryPart | undefined { @@ -1507,6 +1518,25 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return { kind: 'changesSummary', requestId: element.requestId, sessionResource: element.sessionResource }; } + private getChatTurnPillsPart(element: IChatResponseViewModel): IChatTurnPillsPart | undefined { + // The turn status pills mirror the floating pills shown above the input + // while the turn streams. They are opt-in per pill, only apply to agent + // host sessions (which supply authoritative per-turn changes via + // IChatResponseFileChangesService) and, like the pills above the input, + // appear once the turn is complete. + if (!element.isComplete) { + return undefined; + } + const pillsConfig = this.configService.getValue<IChatTurnStatusPillsConfig | undefined>(ChatConfiguration.TurnStatusPills); + if (!pillsConfig?.changes && !pillsConfig?.preview) { + return undefined; + } + if (!isAgentHostTarget(getChatSessionType(element.sessionResource))) { + return undefined; + } + return { kind: 'turnPills', requestId: element.requestId, sessionResource: element.sessionResource }; + } + private renderChatRequest(element: IChatRequestViewModel, index: number, templateData: IChatListItemTemplate) { templateData.rowContainer.classList.toggle('chat-response-loading', false); templateData.rowContainer.classList.toggle('pending-request', !!element.pendingKind); @@ -1574,7 +1604,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch dom.clearNode(templateData.value); const parts: IChatContentPart[] = []; - const explicitImageAttachmentsPart = explicitImageVariables.length ? this.renderAttachments(explicitImageVariables, element.contentReferences, element.modelId, templateData) : undefined; + const explicitImageAttachmentsPart = explicitImageVariables.length ? this.renderAttachments(explicitImageVariables, element.contentReferences, element.modelId, templateData, element.resolvedModelId) : undefined; if (explicitImageAttachmentsPart?.domNode) { explicitImageAttachmentsPart.domNode.classList.add('chat-request-attachment-cards', 'chat-request-image-attachments'); templateData.value.appendChild(explicitImageAttachmentsPart.domNode); @@ -1657,13 +1687,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch templateData.renderedParts = []; const label = element.systemInitiatedLabel ?? element.messageText; - const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(label)); - templateData.elementDisposables.add(rendered); - rendered.element.classList.add('progress-step'); - - const progressPart = this.instantiationService.createInstance(ChatProgressSubPart, rendered.element, Codicon.check, undefined); - templateData.elementDisposables.add(progressPart); - templateData.value.appendChild(progressPart.domNode); + const notificationPart = this.instantiationService.createInstance( + ChatSystemNotificationContentPart, + { kind: 'systemNotification', content: new MarkdownString(label) }, + this.chatContentMarkdownRenderer, + ); + templateData.elementDisposables.add(notificationPart); + templateData.value.appendChild(notificationPart.domNode); } /** @@ -1806,6 +1836,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch templateData.renderedParts = renderedParts; let codeBlockStartIndex = 0; let treeStartIndex = 0; + let displacedWorkingPart: ChatWorkingProgressContentPart | undefined; partsToRender.forEach((partToRender, contentIndex) => { // Accumulate counts from the part that ended up at the previous index if (contentIndex > 0) { @@ -1828,7 +1859,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return; } + if (partToRender.kind === 'working' && displacedWorkingPart?.hasSameContent(partToRender, contentForThisTurn.slice(contentIndex + 1), element)) { + renderedParts[contentIndex] = displacedWorkingPart; + displacedWorkingPart = undefined; + return; + } + // keep existing thinking part instance during streaming and update it in place + const preserveWorkingPart = alreadyRenderedPart instanceof ChatWorkingProgressContentPart + && partToRender.kind !== 'working' + && contentForThisTurn.slice(contentIndex + 1).some(part => part.kind === 'working'); if (alreadyRenderedPart) { if (partToRender.kind === 'thinking' && alreadyRenderedPart instanceof ChatThinkingContentPart) { if (!Array.isArray(partToRender.value)) { @@ -1854,7 +1894,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch } } - alreadyRenderedPart.dispose(); + if (preserveWorkingPart) { + displacedWorkingPart = alreadyRenderedPart; + } else { + alreadyRenderedPart.dispose(); + } // Replace old DOM from thinking wrapper to prevent accumulation // of duplicate entries when re-rendering pinned parts. @@ -1903,9 +1947,15 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch try { if (alreadyRenderedPart?.domNode) { if (newPart.domNode) { - alreadyRenderedPart.domNode.replaceWith(newPart.domNode); + if (preserveWorkingPart) { + alreadyRenderedPart.domNode.before(newPart.domNode); + } else { + alreadyRenderedPart.domNode.replaceWith(newPart.domNode); + } } else { - alreadyRenderedPart.domNode.remove(); + if (!preserveWorkingPart) { + alreadyRenderedPart.domNode.remove(); + } } } else if (newPart.domNode && !newPart.domNode.parentElement) { // Only append if not already attached somewhere else (e.g. inside a thinking wrapper) @@ -1919,6 +1969,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch alreadyRenderedPart?.domNode?.remove(); } }); + displacedWorkingPart?.dispose(); + displacedWorkingPart?.domNode?.remove(); // Delete previously rendered parts that are removed for (let i = partsToRender.length; i < renderedParts.length; i++) { @@ -2011,6 +2063,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch partsToRender.push(fileChangesSummaryPart); } + const turnPillsPart = this.getChatTurnPillsPart(element); + if (turnPillsPart) { + partsToRender.push(turnPillsPart); + } + return { content: partsToRender, moreContentAvailable }; } @@ -2023,9 +2080,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch private getDataForProgressiveRender(element: IChatResponseViewModel) { const hasMarkdownParts = element.response.value.some(part => part.kind === 'markdownContent' && part.content.value.trim().length > 0); - if (!element.isComplete && hasMarkdownParts && (element.contentUpdateTimings ? element.contentUpdateTimings.lastWordCount : 0) === 0) { + if (shouldRenderInitialProgressiveContentImmediately(element.isComplete, hasMarkdownParts, element.renderData !== undefined)) { /** - * None of the content parts in the ongoing response have been rendered yet, + * None of the markdown in the ongoing response has been rendered yet, * so we should render all existing parts without animation. */ return { @@ -2240,13 +2297,13 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch // Finalize all active subagent parts (there can be multiple parallel subagents) // Skip subagents that still have tools waiting for confirmation for (const part of templateData.renderedParts) { - if (part instanceof ChatSubagentContentPart && part.getIsActive() && !part.hasToolsWaitingForConfirmation) { + if (part instanceof ChatSubagentContentPart && part.getIsActive() && !part.shouldRemainActive() && !part.hasToolsWaitingForConfirmation) { part.markAsInactive(); } } } - private handleSubagentToolGrouping(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, subagentId: string, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate, codeBlockStartIndex: number): ChatSubagentContentPart { + private handleSubagentToolGrouping(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized, subagentId: string, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate, codeBlockStartIndex: number): IChatContentPart { // Finalize any active thinking part since subagent tools have their own grouping this.finalizeCurrentThinkingPart(context, templateData); @@ -2259,6 +2316,9 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch // But skip the parent subagent tool itself - we only want child tools if (!isParentSubagentTool(toolInvocation)) { lastSubagent.appendToolInvocation(toolInvocation, codeBlockStartIndex); + return this.renderNoContent(other => + (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && other.toolCallId === toolInvocation.toolCallId); } return lastSubagent; } @@ -2417,6 +2477,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return this.renderMultiDiffData(content, templateData, context); } else if (content.kind === 'progressMessage') { return this.instantiationService.createInstance(ChatProgressContentPart, content, this.chatContentMarkdownRenderer, context, undefined, undefined, undefined, undefined, content.shimmer); + } else if (content.kind === 'systemNotification') { + return this.instantiationService.createInstance(ChatSystemNotificationContentPart, content, this.chatContentMarkdownRenderer); } else if (content.kind === 'working') { return this.instantiationService.createInstance(ChatWorkingProgressContentPart, content, this.chatContentMarkdownRenderer, context); } else if (content.kind === 'progressTask' || content.kind === 'progressTaskSerialized') { @@ -2461,8 +2523,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return this.renderPlanReview(context, content, templateData); } else if (content.kind === 'changesSummary') { return this.renderChangesSummary(content, context, templateData); + } else if (content.kind === 'turnPills') { + return this.renderTurnPills(content, context); } else if (content.kind === 'mcpServersStarting') { return this.renderMcpServersInteractionRequired(content, context, templateData); + } else if (content.kind === 'mcpAuthenticationRequired') { + return this.instantiationService.createInstance(ChatMcpAuthenticationContentPart, content); + } else if (content.kind === 'mcpServersStartingSlow') { + return this.instantiationService.createInstance(ChatMcpServersStartingContentPart, content); } else if (content.kind === 'disabledClaudeHooks') { return this.renderDisabledClaudeHooks(content, context); } else if (content.kind === 'thinking') { @@ -2471,6 +2539,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return this.instantiationService.createInstance(ChatWorkspaceEditContentPart, content, context, this.chatContentMarkdownRenderer); } else if (content.kind === 'externalEdit') { return this.renderExternalEdit(content, context, templateData); + } else if (content.kind === 'autoModeResolution') { + return this.instantiationService.createInstance(ChatAutoModeResolutionContentPart, content, context, this.chatContentMarkdownRenderer); } return this.renderNoContent(other => content.kind === other.kind); @@ -2497,7 +2567,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return this.renderNoContent(other => content.kind === other.kind); } - const isLast = context.elementIndex === this.delegate.getListLength() - 1; + const isLast = content.isLast; if (content.errorDetails.isQuotaExceeded) { const renderedError = this.instantiationService.createInstance(ChatQuotaExceededPart, context.element, content, this.chatContentMarkdownRenderer); return renderedError; @@ -3278,11 +3348,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch return part; } - private renderAttachments(variables: readonly IChatRequestVariableEntry[], contentReferences: ReadonlyArray<IChatContentReference> | undefined, modelId: string | undefined, templateData: IChatListItemTemplate) { + private renderTurnPills(content: IChatTurnPillsPart, context: IChatContentPartRenderContext): IChatContentPart { + return this.instantiationService.createInstance(ChatTurnPillsContentPart, content, context); + } + + private renderAttachments(variables: readonly IChatRequestVariableEntry[], contentReferences: ReadonlyArray<IChatContentReference> | undefined, modelId: string | undefined, templateData: IChatListItemTemplate, resolvedModelId?: string) { return this.instantiationService.createInstance(ChatAttachmentsContentPart, { variables, contentReferences, modelId, + resolvedModelId, domNode: undefined }); } @@ -3421,7 +3496,10 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer<Ch templateData.value, markdownPart, ); - return subagentPart; + return this.renderNoContent(other => + other.kind === 'markdownContent' + && other.content.value === markdown.content.value + && extractSubAgentInvocationIdFromText(other.content.value) === subAgentInvocationId); } } @@ -3592,6 +3670,10 @@ export class ChatListDelegate extends CachedListVirtualDelegate<ChatTreeItem> { hasDynamicHeight(element: ChatTreeItem): boolean { return true; } + + getMeasuredHeight(element: ChatTreeItem): number | undefined { + return this.getCachedHeight(element); + } } /** @@ -3620,3 +3702,25 @@ function getSubagentId(invocation: IChatToolInvocation | IChatToolInvocationSeri function isSubagentToolInvocation(invocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { return !!getSubagentId(invocation); } + +export function getWorkingProgressRelevantParts(parts: readonly IChatRendererContent[]): IChatRendererContent[] { + return parts.filter(part => { + if (part.kind === 'toolInvocation' || part.kind === 'toolInvocationSerialized') { + return !isSubagentToolInvocation(part); + } + if (part.kind === 'hook') { + return !part.subAgentInvocationId; + } + return part.kind !== 'markdownContent' || !extractSubAgentInvocationIdFromText(part.content.value); + }); +} + +function findLastMeaningfulPart(parts: readonly IChatRendererContent[]): IChatRendererContent | undefined { + for (let i = parts.length - 1; i >= 0; i--) { + const part = parts[i]; + if (part.kind !== 'markdownContent' || part.content.value.trim().length > 0) { + return part; + } + } + return undefined; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index ca65a77adc157b..e8f008abb8fc53 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -176,6 +176,7 @@ export class ChatListWidget extends Disposable { //#region Private fields private readonly _tree: WorkbenchObjectTree<ChatTreeItem, FuzzyScore>; + private readonly _delegate: ChatListDelegate; private readonly _renderer: ChatListItemRenderer; private _viewModel: IChatViewModel | undefined; @@ -297,7 +298,7 @@ export class ChatListWidget extends Disposable { )); // Create delegate - const delegate = scopedInstantiationService.createInstance( + this._delegate = scopedInstantiationService.createInstance( ChatListDelegate, options.defaultElementHeight ?? 200 ); @@ -364,7 +365,7 @@ export class ChatListWidget extends Disposable { WorkbenchObjectTree<ChatTreeItem, FuzzyScore>, 'ChatList', this._container, - delegate, + this._delegate, [this._renderer], { identityProvider: { getId: (e: ChatTreeItem) => e.id }, @@ -531,6 +532,8 @@ export class ChatListWidget extends Disposable { const items = this._viewModel.getItems(); this._lastItem = items.at(-1); this._lastItemIdContextKey.set(this._lastItem ? [this._lastItem.id] : []); + const previousItem = items.at(-2); + const needsInitialPreviousItemHeight = (isRequestVM(previousItem) || isResponseVM(previousItem)) && previousItem.currentRenderedHeight === undefined; const treeItems: ITreeElement<ChatTreeItem>[] = items.map(item => ({ element: item, @@ -572,6 +575,10 @@ export class ChatListWidget extends Disposable { } }); }); + + if (needsInitialPreviousItemHeight) { + this.updateLastItemMinHeight(); + } } /** @@ -667,6 +674,18 @@ export class ChatListWidget extends Disposable { this._tree.reveal(element, relativeTop); } + /** + * The top offset of an element in transcript content space (same space as + * `scrollTop`/`scrollHeight`), or `undefined` if it is not in the list. Reads + * the layout height model, so it also resolves off-screen elements. + */ + getElementTop(element: ChatTreeItem): number | undefined { + if (!this._tree.hasElement(element)) { + return undefined; + } + return this._tree.getElementTop(element); + } + /** * Get the focused elements. */ @@ -715,11 +734,12 @@ export class ChatListWidget extends Disposable { * Scroll the list to reveal the last item. */ scrollToEnd(): void { - if (this._lastItem) { - const offset = Math.max(this._lastItem.currentRenderedHeight ?? 0, 1e6); - if (this._tree.hasElement(this._lastItem)) { - this._tree.reveal(this._lastItem, offset); - } + // Reveal the tree's actual last node rather than the held `_lastItem`. `reveal` reliably + // scrolls all the way down even while item heights are still settling (see #234089) + const lastElement = this._tree.getNode(null).children.at(-1)?.element; + if (lastElement) { + const offset = Math.max(lastElement.currentRenderedHeight ?? 0, 1e6); + this._tree.reveal(lastElement, offset); } } @@ -876,7 +896,7 @@ export class ChatListWidget extends Disposable { const maxRequestShownHeight = 200; const secondToLastItemHeight = Math.min( (isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? - secondToLastItem.currentRenderedHeight ?? 150 : 150, + secondToLastItem.currentRenderedHeight ?? this._delegate.getMeasuredHeight(secondToLastItem) ?? 150 : 150, maxRequestShownHeight); const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts new file mode 100644 index 00000000000000..5c2f804194e47f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatTurnPills.ts @@ -0,0 +1,406 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append, reset } from '../../../../../base/browser/dom.js'; +import { ActionsOrientation } from '../../../../../base/browser/ui/actionbar/actionbar.js'; +import { BaseActionViewItem, IActionViewItemOptions } from '../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { Button, IButtonStyles } from '../../../../../base/browser/ui/button/button.js'; +import { ToolBar } from '../../../../../base/browser/ui/toolbar/toolbar.js'; +import { Action, IAction, toAction } from '../../../../../base/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, derived, IObservable, IReader } from '../../../../../base/common/observable.js'; +import { basename, isEqual } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { BrowserViewCommandId } from '../../../../../platform/browserView/common/browserView.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IContextMenuService } from '../../../../../platform/contextview/browser/contextView.js'; +import { FileKind } from '../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IOpenerService } from '../../../../../platform/opener/common/opener.js'; +import { observableConfigValue } from '../../../../../platform/observable/common/platformObservableUtils.js'; +import { defaultButtonStyles } from '../../../../../platform/theme/browser/defaultStyles.js'; +import { AnimatedCounterWidget } from '../../../../browser/animatedCounterWidget.js'; +import { DEFAULT_LABELS_CONTAINER, ResourceLabels } from '../../../../browser/labels.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import '../media/chatTurnPills.css'; + +const CHANGES_PILL_ACTION_ID = 'chat.turnPills.changes'; +const PREVIEW_PILL_ACTION_ID = 'chat.turnPills.preview'; + +/** + * All-transparent button styles so the inner preview-pill buttons inherit the + * pill container's own background/border and read as a single control. + */ +const TRANSPARENT_BUTTON_STYLES: IButtonStyles = { + buttonBackground: undefined, + buttonHoverBackground: undefined, + buttonForeground: undefined, + buttonSeparator: undefined, + buttonSecondaryBackground: undefined, + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + buttonBorder: undefined, +}; + +/** Aggregate diff counts shown in the changes pill (scoped to a single turn). */ +export interface IDiffStats { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + +export const EMPTY_DIFF_STATS: IDiffStats = { files: 0, insertions: 0, deletions: 0 }; + +/** A markdown/HTML file the preview pill can open. */ +export interface IPreviewFile { + readonly uri: URI; + readonly kind: 'markdown' | 'html'; + /** Whether the file was created (vs. edited) during the turn. */ + readonly created: boolean; +} + +/** Classify a resource as a previewable markdown or HTML file, if applicable. */ +export function previewKind(uri: URI): 'markdown' | 'html' | undefined { + const path = uri.path.toLowerCase(); + if (path.endsWith('.md') || path.endsWith('.markdown')) { + return 'markdown'; + } + if (path.endsWith('.html') || path.endsWith('.htm')) { + return 'html'; + } + return undefined; +} + +export function diffStatsEqual(a: IDiffStats, b: IDiffStats): boolean { + return a.files === b.files && a.insertions === b.insertions && a.deletions === b.deletions; +} + +export function previewFilesEqual(a: readonly IPreviewFile[], b: readonly IPreviewFile[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (a[i].kind !== b[i].kind || a[i].created !== b[i].created || !isEqual(a[i].uri, b[i].uri)) { + return false; + } + } + return true; +} + +/** + * Open a previewable file: markdown files open as a markdown preview, HTML files + * open in the integrated browser (desktop only), falling back to the default + * opener when neither is available (e.g. web). + */ +export async function openChatPreviewFile(file: IPreviewFile, commandService: ICommandService, openerService: IOpenerService, logService: ILogService): Promise<void> { + try { + if (file.kind === 'markdown') { + await commandService.executeCommand('markdown.showPreview', file.uri); + } else { + await commandService.executeCommand(BrowserViewCommandId.Open, file.uri.toString()); + } + } catch (err) { + logService.trace('[ChatTurnPills] Falling back to default opener for preview', err); + await openerService.open(file.uri); + } +} + +/** The data and interactions a {@link ChatTurnPillsWidget} reflects. */ +export interface IChatTurnPillsModel { + readonly stats: IObservable<IDiffStats>; + readonly previewFiles: IObservable<readonly IPreviewFile[]>; + /** When `false` the changes pill stays hidden regardless of the data. */ + readonly changesEnabled: IObservable<boolean>; + /** When `false` the preview pill stays hidden regardless of the data. */ + readonly previewEnabled: IObservable<boolean>; + openChanges(): void; + openPreviewFile(file: IPreviewFile): void; +} + +/** Per-pill visibility for the agent turn status pills ({@link ChatConfiguration.TurnStatusPills}). */ +export interface IChatTurnStatusPillsConfig { + readonly changes: boolean; + readonly preview: boolean; +} + +const TURN_STATUS_PILLS_DEFAULT: IChatTurnStatusPillsConfig = { changes: false, preview: false }; + +/** Observe the per-pill turn status pills visibility setting. */ +export function observeTurnStatusPillsConfig(configurationService: IConfigurationService): IObservable<IChatTurnStatusPillsConfig> { + return observableConfigValue(ChatConfiguration.TurnStatusPills, TURN_STATUS_PILLS_DEFAULT, configurationService); +} + +/** + * The changes pill: `<diff-icon> <n> Files +insertions -deletions`, updating + * live as {@link _statsObs} changes. + */ +class ChangesPillActionViewItem extends BaseActionViewItem { + + private _button: Button | undefined; + private _filesLabel: HTMLElement | undefined; + + constructor( + action: IAction, + options: IActionViewItemOptions, + private readonly _statsObs: IObservable<IDiffStats>, + private readonly _instantiationService: IInstantiationService, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + this.element = container; + container.classList.add('chat-turn-pill-changes'); + + const button = this._button = this._register(new Button(container, { secondary: true, small: true, ...defaultButtonStyles })); + button.element.classList.add('monaco-text-button', 'chat-turn-pill-changes-button'); + this._register(button.onDidClick(() => { + if (this._action.enabled) { + this.actionRunner.run(this._action, this._context); + } + })); + + // Build the label structure once so the animated counters persist across + // updates and can transition smoothly between values instead of being + // torn down and rebuilt on every stats change. + this._filesLabel = $('span.chat-turn-pill-meta-label'); + reset( + button.element, + $(`span.chat-turn-pill-meta-icon${ThemeIcon.asCSSSelector(Codicon.diffMultiple)}`), + this._filesLabel, + ); + + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, button.element, { + prefix: '+', + direction: 'topToBottom', + cssClassName: 'chat-turn-pill-meta-added', + count: derived(this, reader => this._statsObs.read(reader).insertions), + })); + this._register(this._instantiationService.createInstance(AnimatedCounterWidget, button.element, { + prefix: '-', + direction: 'bottomToTop', + cssClassName: 'chat-turn-pill-meta-removed', + count: derived(this, reader => this._statsObs.read(reader).deletions), + })); + + this._register(autorun(reader => { + this._updateLabel(this._statsObs.read(reader)); + })); + } + + private _updateLabel(stats: IDiffStats): void { + if (!this._button || !this._filesLabel) { + return; + } + const { files, insertions, deletions } = stats; + const filesLabel = files === 1 + ? localize('chatTurnPills.changes.file', "{0} File", files) + : localize('chatTurnPills.changes.files', "{0} Files", files); + this._filesLabel.textContent = filesLabel; + this._button.setTitle(localize('chatTurnPills.changes.tooltip', "View Changes")); + this._button.element.setAttribute('aria-label', localize('chatTurnPills.changes.ariaLabel', "View Changes: {0}, +{1}, -{2}", filesLabel, insertions, deletions)); + } + + override focus(): void { + this._button?.focus(); + } +} + +/** + * The preview pill: renders the primary previewable file as a resource label + * (file icon + name) with a "preview" description. When more than one previewable + * file exists, a separator and a dropdown chevron are shown; the chevron lists + * every previewable file. Activating the label opens the primary file's preview. + */ +class PreviewPillActionViewItem extends BaseActionViewItem { + + private _primary: Button | undefined; + + constructor( + action: IAction, + options: IActionViewItemOptions, + private readonly _previewFilesObs: IObservable<readonly IPreviewFile[]>, + private readonly _resourceLabels: ResourceLabels, + private readonly _openFile: (file: IPreviewFile) => void, + private readonly _showAll: (anchor: HTMLElement) => void, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + this.element = container; + container.classList.add('chat-turn-pill-preview'); + + // The whole pill is one unified control: a primary resource label (file + // icon + name + "preview") on the left, an in-pill separator, and a dropdown + // chevron on the right. The inner buttons are transparent so the pill reads + // as a single element; each is an independent, tab-focusable button. + const primary = this._primary = this._register(new Button(container, { ...TRANSPARENT_BUTTON_STYLES })); + primary.element.classList.add('chat-turn-pill-preview-primary'); + const label = this._register(this._resourceLabels.create(primary.element)); + this._register(primary.onDidClick(() => { + const primaryFile = this._previewFilesObs.get().at(0); + if (primaryFile) { + this._openFile(primaryFile); + } + })); + + const separator = append(container, $('.chat-turn-pill-preview-separator')); + + const chevron = this._register(new Button(container, { ...TRANSPARENT_BUTTON_STYLES })); + chevron.element.classList.add('chat-turn-pill-preview-chevron'); + // Render the chevron as a child element (not via `chevron.icon`, which puts a + // fixed-height codicon class on the button itself) so the button can stretch + // to the pill's full height and its hover background spans top to bottom. + append(chevron.element, $(`span${ThemeIcon.asCSSSelector(Codicon.chevronDown)}`)); + const moreLabel = localize('chatTurnPills.preview.more', "Show All Previewable Files"); + chevron.setTitle(moreLabel); + chevron.setAriaLabel(moreLabel); + this._register(chevron.onDidClick(() => this._showAll(chevron.element))); + + this._register(autorun(reader => { + const files = this._previewFilesObs.read(reader); + const primaryFile = files.at(0); + if (primaryFile) { + label.setResource( + { resource: primaryFile.uri, name: basename(primaryFile.uri), description: localize('chatTurnPills.preview.description', "preview") }, + { fileKind: FileKind.FILE }, + ); + const tooltip = localize('chatTurnPills.preview.tooltipOne', "Open Preview: {0}", basename(primaryFile.uri)); + primary.setTitle(tooltip); + primary.setAriaLabel(tooltip); + } + const hasMultiple = files.length > 1; + separator.classList.toggle('hidden', !hasMultiple); + chevron.element.classList.toggle('hidden', !hasMultiple); + })); + } + + override focus(): void { + this._primary?.focus(); + } +} + +/** + * A toolbar of clickable pills reflecting a single turn's status. Used both as a + * floating widget above the chat input (live, active turn) and inside a completed + * chat response. The pills are actions inside a {@link ToolBar}: + * + * - **Changes** — `<n> Files +ins -del` for the turn. Activating it opens the + * changes. + * - **Preview** — shown when the turn created or edited a markdown or HTML file. + * Rendered as a resource label (file icon + name) with a "preview" description + * for the primary file. Activating it opens that file as a markdown preview or + * in the integrated browser; when several exist, a dropdown lists them all. + * + * The data and the open actions are supplied by the {@link IChatTurnPillsModel} + * so the same widget serves surfaces with different data sources. + */ +export class ChatTurnPillsWidget extends Disposable { + + readonly element: HTMLElement; + + /** Whether the widget currently has any pill to show. */ + readonly isVisible: IObservable<boolean>; + + private readonly _toolbar: ToolBar; + private readonly _changesAction: Action; + private readonly _previewAction: Action; + private readonly _resourceLabels: ResourceLabels; + + /** Ids of the currently mounted pills, so we only rebuild the toolbar when the set changes. */ + private _visibleSignature: string | undefined; + + constructor( + private readonly _model: IChatTurnPillsModel, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + ) { + super(); + + // `show-file-icons` lets the preview pill's resource label render the file's + // themed icon — the label always computes the file-icon classes, but they + // only paint when an ancestor opts in. + this.element = $('.chat-turn-pills.show-file-icons.hidden'); + this._resourceLabels = this._register(this._instantiationService.createInstance(ResourceLabels, DEFAULT_LABELS_CONTAINER)); + + this._changesAction = this._register(new Action(CHANGES_PILL_ACTION_ID, localize('chatTurnPills.changes.tooltip', "View Changes"), undefined, true, async () => this._model.openChanges())); + this._previewAction = this._register(new Action(PREVIEW_PILL_ACTION_ID, localize('chatTurnPills.preview.label', "Open Preview"), undefined, true, async () => this._openPrimaryPreview())); + + this._toolbar = this._register(new ToolBar(this.element, this._contextMenuService, { + orientation: ActionsOrientation.HORIZONTAL, + ariaLabel: localize('chatTurnPills.ariaLabel', "Turn status"), + actionViewItemProvider: (action, options) => { + if (action.id === CHANGES_PILL_ACTION_ID) { + return new ChangesPillActionViewItem(action, options, this._model.stats, this._instantiationService); + } + if (action.id === PREVIEW_PILL_ACTION_ID) { + return new PreviewPillActionViewItem(action, options, this._model.previewFiles, this._resourceLabels, file => this._model.openPreviewFile(file), anchor => this._showAllPreviews(anchor)); + } + return undefined; + }, + })); + + this.isVisible = derived(this, reader => this._showChanges(reader) || this._showPreview(reader)); + + this._register(autorun(reader => { + this._updateVisibleActions(this._showChanges(reader), this._showPreview(reader)); + })); + } + + private _showChanges(reader: IReader): boolean { + return this._model.changesEnabled.read(reader) && this._model.stats.read(reader).files > 0; + } + + private _showPreview(reader: IReader): boolean { + return this._model.previewEnabled.read(reader) && this._model.previewFiles.read(reader).length > 0; + } + + private _updateVisibleActions(showChanges: boolean, showPreview: boolean): void { + const actions: IAction[] = []; + if (showChanges) { + actions.push(this._changesAction); + } + if (showPreview) { + actions.push(this._previewAction); + } + + const signature = actions.map(a => a.id).join(','); + if (signature !== this._visibleSignature) { + this._visibleSignature = signature; + this._toolbar.setActions(actions); + } + this.element.classList.toggle('hidden', actions.length === 0); + } + + private _openPrimaryPreview(): void { + const primaryFile = this._model.previewFiles.get().at(0); + if (primaryFile) { + this._model.openPreviewFile(primaryFile); + } + } + + private _showAllPreviews(anchor: HTMLElement): void { + const files = this._model.previewFiles.get(); + if (files.length === 0) { + return; + } + this._contextMenuService.showContextMenu({ + getAnchor: () => anchor, + getActions: () => files.map(file => toAction({ + id: `${PREVIEW_PILL_ACTION_ID}.${file.uri.toString()}`, + label: basename(file.uri), + class: ThemeIcon.asClassName(file.kind === 'html' ? Codicon.globe : Codicon.openPreview), + run: () => this._model.openPreviewFile(file), + })), + }); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index d8efe19aefeb0a..bb5199375c5541 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -299,6 +299,8 @@ export class ChatWidget extends Disposable implements IChatWidget { private _visible = false; get visible() { return this._visible; } + private _inputVisible = true; + private _instructionFilesCheckPromise: Promise<boolean> | undefined; private _instructionFilesExist: boolean | undefined; @@ -314,6 +316,7 @@ export class ChatWidget extends Disposable implements IChatWidget { }; private readonly _lockedToCodingAgentContextKey: IContextKey<boolean>; private readonly _lockedCodingAgentIdContextKey: IContextKey<string>; + private readonly _readOnlyContextKey: IContextKey<boolean>; private readonly _chatIsAgentHostSessionContextKey: IContextKey<boolean>; private readonly _chatAgentHostProviderIdContextKey: IContextKey<string>; private readonly _chatSessionSupportsForkContextKey: IContextKey<boolean>; @@ -441,6 +444,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); this._lockedCodingAgentIdContextKey = ChatContextKeys.lockedCodingAgentId.bindTo(this.contextKeyService); + this._readOnlyContextKey = ChatContextKeys.readOnly.bindTo(this.contextKeyService); this._chatIsAgentHostSessionContextKey = ChatContextKeys.chatIsAgentHostSession.bindTo(this.contextKeyService); this._chatAgentHostProviderIdContextKey = ChatContextKeys.chatAgentHostProviderId.bindTo(this.contextKeyService); this._chatSessionSupportsForkContextKey = ChatContextKeys.chatSessionSupportsFork.bindTo(this.contextKeyService); @@ -733,6 +737,10 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.scrollTop = value; } + get scrollHeight(): number { + return this.listWidget.scrollHeight; + } + get attachmentModel(): ChatAttachmentModel { return this.input.attachmentModel; } @@ -864,6 +872,13 @@ export class ChatWidget extends Disposable implements IChatWidget { } focusInput(): void { + // Read-only chats hide the input; focus the message list instead. + if (!this._inputVisible) { + this.listWidget.focusLastItem(true); + this._onDidFocus.fire(); + return; + } + this.input.focus(); // Sometimes focusing the input part is not possible, @@ -1556,6 +1571,42 @@ export class ChatWidget extends Disposable implements IChatWidget { } } + /** + * Mark the chat shown in this widget as read-only (non-interactive) or not. + * Read-only chats hide the composer and expose a context key so mutating + * actions (e.g. Start Over, Restore Checkpoint) are not offered. + */ + setReadOnly(readOnly: boolean): void { + this._readOnlyContextKey.set(readOnly); + this.setInputVisible(!readOnly); + } + + /** + * Show or hide the input part. Hidden inputs are removed from the DOM flow + * and not reserved during layout so the message list takes the full height. + * Used to render read-only (non-interactive) chats without a composer. + */ + setInputVisible(visible: boolean): void { + const changed = this._inputVisible !== visible; + this._inputVisible = visible; + // Hide the composer directly via an inline style rather than a CSS class: + // inline styles win over the stylesheet's `.interactive-input-part` + // display rule without a specificity battle, and this does not depend on + // any CSS file being (re)loaded. Re-applied in `createInput` so a rebuilt + // input part keeps the correct visibility. + this._applyInputVisibility(); + if (changed && this.bodyDimension) { + this._layoutListForInputHeight(); + } + } + + private _applyInputVisibility(): void { + const inputElement = this.inputPartDisposable.value?.element; + if (inputElement) { + inputElement.style.display = this._inputVisible ? '' : 'none'; + } + } + setVisible(visible: boolean): void { const wasVisible = this._visible; this._visible = visible; @@ -1945,6 +1996,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.input.render(container, '', this); + // Keep read-only chats' composer hidden if the input part was rebuilt. + this._applyInputVisibility(); if (this.bodyDimension?.width) { this.input.layout(this.bodyDimension.width); } @@ -2280,6 +2333,15 @@ export class ChatWidget extends Disposable implements IChatWidget { this.listWidget.reveal(item, relativeTop); } + /** + * The top offset of an item in transcript content space (same space as + * `scrollTop`/`scrollHeight`), or `undefined` if it is not in the list. + * Virtualization-safe for off-screen items (reads the layout height model). + */ + getElementTop(item: ChatTreeItem): number | undefined { + return this.listWidget.getElementTop(item); + } + focus(item: ChatTreeItem): void { if (!this.listWidget.hasElement(item)) { return; @@ -2572,6 +2634,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const isUserQuery = !query; const isEditing = this.viewModel?.editing; + let cancelledCurrentRequest = false; if (isEditing) { // Clear the carousel since the existing request is being replaced this.inputPart?.clearToolConfirmationCarousel(); @@ -2580,9 +2643,12 @@ export class ChatWidget extends Disposable implements IChatWidget { if (editingPendingRequest !== undefined) { const editingRequestId = this.viewModel.editing!.id; this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId); - options.queue ??= editingPendingRequest; + if (!options.cancelCurrentRequest) { + options.queue ??= editingPendingRequest; + } } else { await this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-editing'); + cancelledCurrentRequest = true; options.queue = undefined; } @@ -2600,6 +2666,11 @@ export class ChatWidget extends Disposable implements IChatWidget { } const model = this.viewModel.model; + if (options.cancelCurrentRequest && model.requestInProgress.get() && !cancelledCurrentRequest) { + await this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-stopAndSend'); + cancelledCurrentRequest = true; + options.queue = undefined; + } const requestInProgress = model.requestInProgress.get(); // Cancel the request if the user chooses to take a different path. // This is a bit of a heuristic for the common case of tool confirmation+reroute. @@ -2607,11 +2678,11 @@ export class ChatWidget extends Disposable implements IChatWidget { // discard them or need a prompt (as in `confirmPendingRequestsBeforeSend`) // which could be a surprising behavior if the user finishes typing a steering // request just as confirmation is triggered. - if (model.requestNeedsInput.get() && !model.getPendingRequests().length) { + if (!options.cancelCurrentRequest && model.requestNeedsInput.get() && !model.getPendingRequests().length) { await this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource, 'acceptInput-needsInput'); options.queue ??= ChatRequestQueueKind.Queued; } - if (requestInProgress) { + if (requestInProgress && !options.cancelCurrentRequest) { options.queue ??= ChatRequestQueueKind.Queued; } if (!requestInProgress && !isEditing && !(await this.confirmPendingRequestsBeforeSend(model, options))) { @@ -2901,7 +2972,7 @@ export class ChatWidget extends Disposable implements IChatWidget { const { height, width } = this.bodyDimension; const chatSuggestNextWidgetHeight = this.chatSuggestNextWidget.height; - const inputHeight = this.inputPart.height.get(); + const inputHeight = this._inputVisible ? this.inputPart.height.get() : 0; const lastElementVisible = this.listWidget.isScrolledToBottom; const lastItem = this.listWidget.lastItem; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts index 998f6ef06c3edb..fc680aa9a89e75 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidgetService.ts @@ -11,14 +11,11 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ACTIVE_GROUP, IEditorService, type PreferredGroup } from '../../../../services/editor/common/editorService.js'; import { IEditorGroup, IEditorGroupsService, isEditorGroup } from '../../../../services/editor/common/editorGroupsService.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { localChatSessionType } from '../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatLastUsedEditorSessionTypeStorageKey } from '../../common/constants.js'; -import { getChatSessionType } from '../../common/model/chatUri.js'; +import { ChatAgentLocation } from '../../common/constants.js'; import { ChatViewId, ChatViewPaneTarget, IChatWidget, IChatWidgetService, IQuickChatService, isIChatViewViewContext } from '../chat.js'; import { ChatEditor, IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; @@ -51,7 +48,6 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService @IEditorService private readonly editorService: IEditorService, @IChatService private readonly chatService: IChatService, @ILogService private readonly logService: ILogService, - @IStorageService private readonly storageService: IStorageService, ) { super(); } @@ -237,30 +233,10 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService } this._lastFocusedWidget = widget; - this.recordLastUsedSessionType(widget); this._onDidChangeFocusedWidget.fire(widget); this._onDidChangeFocusedSession.fire(); } - /** - * Stores the session type (agent) of the last-focused user-facing chat so new chat editors can reuse it (excluding Quick Chat). - */ - private recordLastUsedSessionType(widget: IChatWidget | undefined): void { - const sessionResource = widget?.viewModel?.sessionResource; - if (!sessionResource) { - return; - } - if (this.quickChatService.sessionResource && isEqual(sessionResource, this.quickChatService.sessionResource)) { - return; - } - const sessionType = getChatSessionType(sessionResource); - // Only remember non-local agents to avoid clobbering the user's last-used agent. - if (sessionType === localChatSessionType) { - return; - } - this.storageService.store(ChatLastUsedEditorSessionTypeStorageKey, sessionType, StorageScope.PROFILE, StorageTarget.USER); - } - register(newWidget: IChatWidget): IDisposable { if (this._widgets.some(widget => widget === newWidget)) { throw new Error('Cannot register the same widget multiple times'); @@ -277,7 +253,6 @@ export class ChatWidgetService extends Disposable implements IChatWidgetService newWidget.onDidFocus(() => this.setLastFocusedWidget(newWidget)), newWidget.onDidChangeViewModel(({ previousSessionResource, currentSessionResource }) => { if (this._lastFocusedWidget === newWidget && !isEqual(previousSessionResource, currentSessionResource)) { - this.recordLastUsedSessionType(newWidget); this._onDidChangeFocusedSession.fire(); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 406702c3478ef2..9c891484c95367 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -8,6 +8,7 @@ import { addDisposableListener } from '../../../../../../base/browser/dom.js'; import { DEFAULT_FONT_FAMILY } from '../../../../../../base/browser/fonts.js'; import { IHistoryNavigationWidget } from '../../../../../../base/browser/history.js'; import { hasModifierKeys, StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { IActionViewItem } from '../../../../../../base/browser/ui/actionbar/actionbar.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; import * as aria from '../../../../../../base/browser/ui/aria/aria.js'; import { ButtonWithIcon } from '../../../../../../base/browser/ui/button/button.js'; @@ -66,6 +67,7 @@ import { WorkbenchList } from '../../../../../../platform/list/browser/listServi import { canLog, ILogService, LogLevel } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; import { ISharedWebContentExtractorService } from '../../../../../../platform/webContentExtractor/common/webContentExtractor.js'; @@ -89,14 +91,15 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatModelConfigurationStore } from './chatModelConfigurationStore.js'; +import { deserializeUntitledInputAttachments, deserializeUntitledInputState, serializeUntitledInputAttachments, serializeUntitledInputState } from './chatInputStatePersistence.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel, logChangesToStateModel } from '../../../common/model/chatModel.js'; -import { filterModelsForSession, findBestMatchingModel, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, mergeModelsWithCache, resolveConfiguredModel, resolveModelFromSyncState, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel } from './chatModelSelectionLogic.js'; -import { chatSessionResourceToId, getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; +import { filterModelsForSession, findBestMatchingModel, findDefaultModel, hasModelsTargetingSession, isModelValidForSession, mergeModelsWithCache, resolveConfiguredModel, resolveModelFromSyncState, shouldDropAgnosticDraftModel, shouldPersistModelSelection, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel, shouldRestorePerTypeModelOnSessionSwitch, shouldSuppressModelPersistenceOnSessionSwitch, shouldWaitForSessionModel } from './chatModelSelectionLogic.js'; +import { getChatSessionType, LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { IChatResponseViewModel, isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { ChatHistoryNavigator } from '../../../common/widget/chatWidgetHistoryService.js'; -import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; +import { ChatSessionPrimaryPickerAction, ChatSubmitAction, IChatExecuteActionContext, OpenAutomationsWorkspacePickerAction, OpenDelegationPickerAction, OpenModelPickerAction, OpenModePickerAction, OpenPermissionPickerAction, OpenSessionTargetPickerAction, OpenWorkspacePickerAction } from '../../actions/chatExecuteActions.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../../agentSessions/agentSessionsService.js'; import { ChatAttachmentModel } from '../../attachments/chatAttachmentModel.js'; @@ -104,7 +107,7 @@ import { IChatAttachmentWidgetRegistry } from '../../attachments/chatAttachmentW import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmentWidget, ImageAttachmentWidget, BrowserViewAttachmentWidget, NotebookCellOutputChatAttachmentWidget, PasteAttachmentWidget, PromptFileAttachmentWidget, PromptTextAttachmentWidget, SCMHistoryItemAttachmentWidget, SCMHistoryItemChangeAttachmentWidget, SCMHistoryItemChangeRangeAttachmentWidget, TerminalCommandAttachmentWidget, ToolSetOrToolItemAttachmentWidget } from '../../attachments/chatAttachmentWidgets.js'; import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; -import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; +import { IChatWidget, IChatWidgetViewModelChangeEvent, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate, IChatInputWorkspacePicker } from '../../chat.js'; import { ChatEditingShowChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; @@ -134,15 +137,17 @@ import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ChatSelectedTools } from './chatSelectedTools.js'; import { DelegationSessionPickerActionItem } from './delegationSessionPickerActionItem.js'; import { ModelPickerActionItem, IModelPickerDelegate } from './modelPickerActionItem.js'; -import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionItem.js'; +import { IModePickerDelegate, isModeConsideredBuiltIn, ModePickerActionItem } from './modePickerActionItem.js'; import { IPermissionPickerDelegate, PermissionPickerActionItem } from './permissionPickerActionItem.js'; import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; +import { WorkspacePickerInputActionItem } from './workspacePickerInputActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { findLast } from '../../../../../../base/common/arraysFind.js'; import { ConfigureToolsAction } from '../../actions/chatToolActions.js'; import { InlineCompletionsController } from '../../../../../../editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController.js'; +import { PlaceholderTextContribution } from '../../../../../../editor/contrib/placeholderText/browser/placeholderTextContribution.js'; const $ = dom.$; @@ -150,7 +155,7 @@ const INPUT_EDITOR_MAX_HEIGHT = 250; const INPUT_EDITOR_LINE_HEIGHT = 20; const INPUT_EDITOR_PADDING = { compact: { top: 2, bottom: 2 }, default: { top: 12, bottom: 12 } }; const CachedLanguageModelsKey = 'chat.cachedLanguageModels.v2'; -const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 480; +const CHAT_INPUT_PICKER_COLLAPSE_WIDTH = 280; const PERMISSION_LEVEL_OPTION_ID = 'permissionLevel'; export interface IChatInputStyles { @@ -187,11 +192,62 @@ export interface IChatInputPartOptions { * for their chat request. This is useful for empty window contexts. */ workspacePickerDelegate?: IWorkspacePickerDelegate; + /** + * Optional workspace picker rendered as a chip in the PRIMARY toolbar + * (between the mode picker and the model picker). Used by the automations + * dialog so its existing `WorkspacePicker` instance can drive both the + * form-row trigger and a toolbar chip from a single source of truth. + * Gated by {@link ChatContextKeys.inAutomationsDialog} on the menu side + * and a defensive null-check in `actionViewItemProvider`. + */ + workspacePickerInput?: IChatInputWorkspacePicker; + /** + * Optional action view item provider for host-owned secondary toolbar + * chips registered on {@link MenuId.ChatInputSecondary}. Used by the + * automations dialog so per-instance state can stay outside the shared + * chat input part while still using menu-driven rendering. + */ + secondaryToolbarActionViewItemProvider?: (action: IAction, options?: IActionViewItemOptions) => IActionViewItem | undefined; + /** + * When true, the mode picker hides custom agents and only offers the + * built-in modes (Agent / Ask / Edit / Plan, gated by their normal + * visibility rules). Custom-agent discovery is workspace-scoped and + * doesn't follow the dialog's folder selection, so surfacing custom + * agents tied to the workbench's open folders would mislead the user + * when scheduling against a different folder. + */ + hideCustomChatModes?: boolean; + /** + * When true, suppress the autorun that switches the current language + * model to a mode's declared preferred model (`IChatMode.model`). + * Used by the automations dialog so opening "New Automation" always + * defaults to the picker's default (auto) regardless of which mode + * the dialog opens with, and so the dialog never overwrites the + * regular chat input's persisted model selection via the shared + * APPLICATION-scope storage key. + * + * User-initiated model picks (clicking the model picker, cycle + * keybindings, etc.) are unaffected. + */ + suppressModePreferredModel?: boolean; + /** + * When true, model picks via the picker do not write to global storage. + * Note: `switchToNextModel` keybindings still persist globally. + */ + suppressModelPersistence?: boolean; /** * Whether we are running in the sessions window. * When true, the secondary toolbar (permissions picker) is hidden. */ isSessionsWindow?: boolean; + /** + * Total horizontal gutter (in pixels) reserved outside the input box when + * computing the editor width. Defaults account for the `.interactive-input-part` + * margin used by the panel/sessions chat. Hosts that override that margin (e.g. + * the automations dialog, which renders the composer flush with its form column) + * can pass `0` so the editor fills the box and its scrollbar sits at the edge. + */ + inputPartHorizontalPadding?: number; } export interface IWorkingSetEntry { @@ -210,22 +266,54 @@ export interface IChatWidgetLocationInfo { readonly isMaximized: boolean; } -const emptyInputState = observableMemento<IChatModelInputState | undefined>({ - defaultValue: undefined, - key: 'chat.untitledInputState', - toStorage: JSON.stringify, - fromStorage(value) { - const obj = JSON.parse(value) as IChatModelInputState; - if (obj.selectedModel && !obj.selectedModel.metadata.isDefaultForLocation) { - // Migrate old `isDefault` to `isDefaultForLocation` - type OldILanguageModelChatMetadata = ILanguageModelChatMetadata & { isDefault?: boolean }; - const oldIsDefault = (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; - const isDefaultForLocation = { [ChatAgentLocation.Chat]: Boolean(oldIsDefault) }; - mixin(obj.selectedModel.metadata, { isDefaultForLocation: isDefaultForLocation } satisfies Partial<ILanguageModelChatMetadata>); - delete (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; - } - return obj; - }, +export interface IChatModeChangeEvent { + readonly isUserInitiated: boolean; +} + +interface IModelSelectionSessionSwitch { + readonly suppressPersistence: boolean; + readonly restorePerTypeModel: boolean; +} + +const LEGACY_SHARED_INPUT_STATE_TAGS = new Set(['view', 'editor', 'quick']); + +function getInputStateStorageKey(widgetViewKindTag: string): string { + // Legacy tags (the original chat composer surfaces) historically shared + // a single storage key. Keep them on that key so we don't invalidate + // existing user drafts. New surfaces (e.g. the automations dialog) get + // a per-tag key so their input state does not bleed into or out of the + // chat composer. + if (LEGACY_SHARED_INPUT_STATE_TAGS.has(widgetViewKindTag)) { + return 'chat.untitledInputState'; + } + return `chat.untitledInputState.${widgetViewKindTag}`; +} + +function createEmptyInputStateMemento(widgetViewKindTag: string) { + return observableMemento<IChatModelInputState | undefined>({ + defaultValue: undefined, + key: getInputStateStorageKey(widgetViewKindTag), + toStorage: serializeUntitledInputState, + fromStorage(value) { + const obj = deserializeUntitledInputState(value); + if (obj.selectedModel && !obj.selectedModel.metadata.isDefaultForLocation) { + // Migrate old `isDefault` to `isDefaultForLocation` + type OldILanguageModelChatMetadata = ILanguageModelChatMetadata & { isDefault?: boolean }; + const oldIsDefault = (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; + const isDefaultForLocation = { [ChatAgentLocation.Chat]: Boolean(oldIsDefault) }; + mixin(obj.selectedModel.metadata, { isDefaultForLocation: isDefaultForLocation } satisfies Partial<ILanguageModelChatMetadata>); + delete (obj.selectedModel.metadata as OldILanguageModelChatMetadata).isDefault; + } + return obj; + }, + }); +} + +const emptyInputAttachments = observableMemento<readonly IChatRequestVariableEntry[]>({ + defaultValue: [], + key: 'chat.untitledInputAttachments', + toStorage: serializeUntitledInputAttachments, + fromStorage: deserializeUntitledInputAttachments, }); export class ChatInputPart extends Disposable implements IHistoryNavigationWidget { @@ -309,6 +397,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private readonly inputEditorMaxHeight: number; private readonly inputEditorMinHeight: number | undefined; + private readonly singleLineInputEditorHeight: number; private inputEditorHeight: number = 0; private _maxHeight: number | undefined; private container!: HTMLElement; @@ -428,6 +517,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private chatSessionPickerContainer: HTMLElement | undefined; private _lastSessionPickerAction: MenuItemAction | undefined; private _lastSessionPickerOptions: IChatInputPickerOptions | undefined; + /** + * One pending wait for a model choice that should beat temporary fallback models. + * Examples are `chat.defaultModel`, a stored picker selection, or a restored session model; history-based preselection uses `_waitForSessionHistoryLanguageModel`. + */ private readonly _waitForPersistedLanguageModel: MutableDisposable<IDisposable> = this._register(new MutableDisposable<IDisposable>()); private readonly _waitForSessionHistoryLanguageModel: MutableDisposable<IDisposable> = this._register(new MutableDisposable<IDisposable>()); private readonly _chatSessionOptionEmitters = this._register(new DisposableMap<string, Emitter<IChatSessionProviderOptionItem>>()); @@ -462,8 +555,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return this._currentLanguageModel; } - private _onDidChangeCurrentChatMode: Emitter<void> = this._register(new Emitter<void>()); - readonly onDidChangeCurrentChatMode: Event<void> = this._onDidChangeCurrentChatMode.event; + private _onDidChangeCurrentChatMode: Emitter<IChatModeChangeEvent> = this._register(new Emitter<IChatModeChangeEvent>()); + readonly onDidChangeCurrentChatMode: Event<IChatModeChangeEvent> = this._onDidChangeCurrentChatMode.event; private readonly _currentModeObservable: ISettableObservable<IChatMode>; private readonly _currentChatModesObservable: ISettableObservable<IChatModes>; @@ -564,6 +657,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _generating?: { rc: number; defer: DeferredPromise<void> }; private _emptyInputState: ObservableMemento<IChatModelInputState | undefined>; + private _emptyInputAttachments: ObservableMemento<readonly IChatRequestVariableEntry[]>; private _chatSessionIsEmpty = false; /** * Whether the user has explicitly picked a model for the current session @@ -578,6 +672,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _userExplicitlySelectedModel = false; private _pendingDelegationTarget: AgentSessionTarget | undefined = undefined; private _currentSessionType: string | undefined = undefined; + /** + * Decisions for the session switch currently being wired through the input. + * The object's presence does not mean persistence is suppressed or a restore is required; read its fields for those decisions. + */ + private _modelSelectionSessionSwitch: IModelSelectionSessionSwitch | undefined; constructor( // private readonly editorOptions: ChatEditorOptions, // TODO this should be used @@ -613,6 +712,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IChatAttachmentWidgetRegistry private readonly _chatAttachmentWidgetRegistry: IChatAttachmentWidgetRegistry, @IChatInputNotificationService private readonly chatInputNotificationService: IChatInputNotificationService, @IChatPhoneInputPresenter private readonly chatPhoneInputPresenter: IChatPhoneInputPresenter, + @IProductService private readonly productService: IProductService, ) { super(); @@ -627,7 +727,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge logChangesToStateModel(this._inputModel, `[DEBOUNCE] _syncTextDebounced fired -> _syncInputStateToModel in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); this._syncInputStateToModel(); }, 150)); - this._emptyInputState = this._register(emptyInputState(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); + this._emptyInputState = this._register(createEmptyInputStateMemento(this.options.widgetViewKindTag)(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); + this._emptyInputAttachments = this._register(emptyInputAttachments(StorageScope.WORKSPACE, StorageTarget.USER, this.storageService)); this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); this._currentModeObservable = observableValue<IChatMode>('currentMode', this.options.defaultMode ?? ChatMode.Agent); @@ -679,7 +780,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } this._attachmentModel = this._register(this.instantiationService.createInstance(ChatAttachmentModel)); - this._register(this._attachmentModel.onDidChange(() => this._syncInputStateToModel())); + this._register(this._attachmentModel.onDidChange(() => { + if (this._chatSessionIsEmpty) { + this._emptyInputAttachments.set(this._attachmentModel.attachments, undefined); + } + this._syncInputStateToModel(); + })); // Capture model-configuration changes into the draft input state immediately, // mirroring how a model selection is synced in `setCurrentLanguageModel`. Without // this, a config-only change would not reach the draft state until some other @@ -692,6 +798,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditorMaxHeight = this.options.renderStyle === 'compact' ? INPUT_EDITOR_MAX_HEIGHT / 3 : INPUT_EDITOR_MAX_HEIGHT; const padding = this.options.renderStyle === 'compact' ? INPUT_EDITOR_PADDING.compact : INPUT_EDITOR_PADDING.default; + this.singleLineInputEditorHeight = INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom; this.inputEditorMinHeight = this.options.inputEditorMinLines ? this.options.inputEditorMinLines * INPUT_EDITOR_LINE_HEIGHT + padding.top + padding.bottom : undefined; this.inputEditorHasText = ChatContextKeys.inputHasText.bindTo(contextKeyService); @@ -819,12 +926,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } if (shouldResetOnModelListChange(modelIdentifier, models)) { + // Keep the picker usable with a best match/default while an expected model is still loading, + // but do not store that temporary choice over the expected model. + const storeSelection = !this.hasPendingAuthoritativeModelWait(); // Carry the previous selection across pools when targeted models arrive late. const match = findBestMatchingModel(this._currentLanguageModel.get(), models); if (match) { - this.setCurrentLanguageModel(match); + this.setCurrentLanguageModel(match, false, storeSelection); } else { - this.setCurrentLanguageModelToDefault(); + this.setCurrentLanguageModelToDefault(undefined, storeSelection); } } // The available-model set changed: re-evaluate whether sending is @@ -855,12 +965,16 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const modes = this._currentChatModesObservable.read(reader); reader.store.add(modes.onDidChange(() => { this.validateCurrentChatMode(); + this._restorePersistedCustomModeIfAvailable(); })); })); this._register(autorun(r => { const mode = this._currentModeObservable.read(r); this.chatModeKindKey.set(mode.kind); this.chatModeNameKey.set(mode.name.read(r)); + if (this.options.suppressModePreferredModel) { + return; + } const models = mode.model?.read(r); if (models) { this.switchModelByQualifiedName(models); @@ -907,21 +1021,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge || hasModelsTargetingSession(this.getAllMergedModels(), sessionType); } - /** - * Returns the persisted model for the current session type, if it exists in the current model pool. - */ - private _getRememberedSessionTypeModel(): ILanguageModelChatMetadataAndIdentifier | undefined { - const sessionType = this._currentSessionType; - if (!sessionType || !this.sessionTypeHasOwnModelPool(sessionType)) { - return undefined; - } - const persisted = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); - if (!persisted) { - return undefined; - } - return this.getModels().find(m => m.identifier === persisted); - } - private initSelectedModel() { // initSelectedModel is scoped to the current storage key/session type. // Do not let a delayed restore from a previous session type apply later. @@ -990,6 +1089,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Whether we are waiting for a specific model that should win once it appears. + * Callers use this to avoid storing or validating temporary defaults while `chat.defaultModel`, a remembered picker selection, or a restored session model is still loading. + */ + private hasPendingAuthoritativeModelWait(): boolean { + return !!this._waitForPersistedLanguageModel.value; + } + public setEditing(enabled: boolean, editingSentRequest: ChatContextKeys.EditingRequestType | undefined) { this.currentlyEditingInputKey?.set(enabled); this.editingSentRequestKey?.set(editingSentRequest); @@ -1006,12 +1113,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge /** * Switch to a model by its identifier. Returns true if a matching model * was found and applied. + * + * `storeSelection` mirrors the same opt-out on {@link setChatMode}: when + * `false`, the choice is applied to the input only and is NOT persisted + * to the workbench-global "last used model" key. Surfaces that pre-seed + * a model on behalf of the user (e.g. the automations dialog opening an + * existing automation) pass `false` so they don't pollute the regular + * chat input's persisted selection. */ - public switchModelByIdentifier(identifier: string): boolean { + public switchModelByIdentifier(identifier: string, storeSelection: boolean = true): boolean { const models = this.getModels(); const model = models.find(m => m.identifier === identifier); if (model) { - this.setCurrentLanguageModel(model); + this.setCurrentLanguageModel(model, false, storeSelection); return true; } return false; @@ -1090,10 +1204,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge setModel: (model: ILanguageModelChatMetadataAndIdentifier) => { this._waitForPersistedLanguageModel.clear(); this._waitForSessionHistoryLanguageModel.clear(); - this.setCurrentLanguageModel(model, true); + this.setCurrentLanguageModel(model, true, !this.options.suppressModelPersistence); this.renderAttachedContext(); }, getModels: () => this.getModels(), + isCacheWarm: () => (this._widget?.viewModel?.model.getRequests().length ?? 0) > 0, useGroupedModelPicker: () => { // Agent-host session types (local and remote) reuse the same // grouped/featured model picker as the default chat session, so @@ -1102,8 +1217,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showManageModelsAction: () => { + // Agent-host session types (local and remote) also surface the + // "Manage Models" action so users can add/configure BYOK models + // directly from the picker, matching the agents window experience. const sessionType = this.getCurrentSessionType(); - return !sessionType || sessionType === localChatSessionType; + return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, showUnavailableFeatured: () => { // Agent-host session types also surface unavailable featured @@ -1117,15 +1235,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionType = this.getCurrentSessionType(); return !sessionType || sessionType === localChatSessionType || isAgentHostTarget(sessionType); }, + useGenericModelIcon: () => !this.options.isSessionsWindow && this._usesHarnessProviderIcon(), showAutoModel: () => this._showAutoModel(), - getChatSessionId: () => { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - return sessionResource ? chatSessionResourceToId(sessionResource) : undefined; - }, modelConfiguration: this._modelConfigStore, }; } + private _usesHarnessProviderIcon(): boolean { + const sessionType = this.getCurrentSessionType(); + return sessionType === SessionType.ClaudeCode + || sessionType === SessionType.Codex + || sessionType === SessionType.AgentHostClaude + || sessionType === SessionType.AgentHostCodex; + } + /** * Returns this editor's snapshot of the given model's configuration (e.g. * context size, thinking effort), scoped to this editor rather than the @@ -1157,10 +1280,41 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _createModePickerDelegate(): IModePickerDelegate { + // When `hideCustomChatModes` is set (e.g. the automations dialog), + // strip genuinely user-defined custom agents from the picker + // while preserving extension-contributed modes (Plan / new-Ask / + // new-Edit) that the picker categorises as built-in via + // `isModeConsideredBuiltIn`. Those live in `IChatModes.custom` but + // are part of the built-in product surface, not the + // folder-scoped agent files we want to hide. The underlying + // observable is untouched so mode validation, model picking and + // persistence continue to see the real list. + const productService = this.productService; + const currentChatModes: IObservable<IChatModes> = this.options.hideCustomChatModes + ? derived(reader => { + const inner = this._currentChatModesObservable.read(reader); + const filteredCustom = inner.custom.filter(m => isModeConsideredBuiltIn(m, productService)); + const wrapped: IChatModes = { + onDidChange: inner.onDidChange, + builtin: inner.builtin, + custom: filteredCustom, + findModeById: (id: string) => inner.builtin.find(m => m.id === id) ?? filteredCustom.find(m => m.id === id), + findModeByName: (name: string) => inner.builtin.find(m => m.name.read(undefined) === name) ?? filteredCustom.find(m => m.name.read(undefined) === name), + waitForPendingUpdates: () => inner.waitForPendingUpdates(), + }; + return wrapped; + }) + : this._currentChatModesObservable; + return { currentMode: this._currentModeObservable, - currentChatModes: this._currentChatModesObservable, + currentChatModes, sessionResource: () => this._widget?.viewModel?.sessionResource, + // Direct setter for hosts that embed `ChatInputPart` without + // registering an `IChatWidget` (e.g. the automations dialog). + // The picker only calls this when `sessionResource()` is + // `undefined`; real chat widgets keep the command path. + setMode: (mode: IChatMode) => this.setChatMode2(mode, true), customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; return (sessionResource && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource))) ?? Target.Undefined; @@ -1303,12 +1457,29 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._currentChatModesObservable.set(chatModes, undefined); this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; - // A freshly bound session starts with no explicit in-conversation model + // A session that was just opened starts with no explicit in-conversation model // pick, so the configured default (e.g. enterprise policy) is again allowed // to win for a new empty conversation. this._userExplicitlySelectedModel = false; + // Compute the model-selection decisions for this session switch. They are applied while the + // input and view model finish wiring together, then cleared in the view-model-change finally. + const ownsPool = !!this._currentSessionType && this.sessionTypeHasOwnModelPool(this._currentSessionType); + const hadIncomingModel = !!model.state.get()?.selectedModel; + this._modelSelectionSessionSwitch = { + suppressPersistence: shouldSuppressModelPersistenceOnSessionSwitch(chatSessionIsEmpty, ownsPool), + restorePerTypeModel: shouldRestorePerTypeModelOnSessionSwitch(chatSessionIsEmpty, ownsPool, hadIncomingModel), + }; + // Drop any pending persisted-model wait from a PREVIOUS session so it can't later fire + // into this (or a subsequent) session's storage key. + this._waitForPersistedLanguageModel.clear(); + if (chatSessionIsEmpty) { + const persistedState = model.state.get() ? undefined : this._getPersistedEmptyInputState(); + if (persistedState) { + model.setState(persistedState); + this._syncFromModel(persistedState, forSessionResource); + } logChangesToStateModel(this._inputModel, `(1) setting empty model state for ${forSessionResource.toString()}`, undefined, undefined, this.logService); this._setEmptyModelState(); @@ -1341,21 +1512,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge let state = model.state.read(reader); let message = `syncing from model for ${forSessionResource.toString()} in ${this._currentSessionKey}`; if (!state && this._chatSessionIsEmpty) { - state = this._emptyInputState.read(undefined); + state = this._getPersistedEmptyInputState(); message = `syncing from empty input state for ${forSessionResource.toString()}`; - // Seed model/config from the last-used selection for this session type (configured default still wins). - const rememberedSessionTypeModel = this._getRememberedSessionTypeModel(); - if (rememberedSessionTypeModel) { - const base = state ?? this.getCurrentInputState(); - const rememberedModelConfiguration = this._modelConfigStore.getModelConfiguration(rememberedSessionTypeModel.identifier); - state = { ...base, selectedModel: rememberedSessionTypeModel, modelConfiguration: rememberedModelConfiguration }; - } // A configured default model (e.g. set by enterprise policy via // `chat.defaultModel`) starts every NEW conversation and // must win over the remembered empty-input draft model. `initSelectedModel` // only re-runs at construction and on session-type changes, so plain local // sessions (whose type never changes) would otherwise keep the draft model. - // This override only applies while seeding from the draft — once the user + // This override only applies while seeding from the draft. Once the user // switches the model, `model.state` is set and this branch is skipped, so // the in-conversation selection is preserved. if (state && this.getConfiguredModelValue()) { @@ -1401,6 +1565,38 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge })); } + private _getPersistedEmptyInputState(): IChatModelInputState | undefined { + let state = this._emptyInputState.read(undefined); + if (!state) { + return undefined; + } + + const persistedAttachments = this._emptyInputAttachments.read(undefined); + state = { + ...state, + attachments: persistedAttachments.length > 0 ? persistedAttachments : state.attachments, + }; + + if (shouldDropAgnosticDraftModel(state.selectedModel, this.getAllMergedModels(), this._currentSessionType)) { + state = { ...state, selectedModel: undefined, modelConfiguration: undefined }; + } + + // A configured default model starts every new conversation and wins over the remembered draft model. + if (this.getConfiguredModelValue()) { + const configuredModel = this.getConfiguredDefaultModel(this.getModels()); + if (configuredModel) { + if (configuredModel.identifier !== state.selectedModel?.identifier) { + state = { ...state, selectedModel: configuredModel, modelConfiguration: undefined }; + } + } else if (state.selectedModel) { + // Keep the configured-default wait active until its model is registered. + state = { ...state, selectedModel: undefined, modelConfiguration: undefined }; + } + } + + return state; + } + private _setEmptyModelState() { logChangesToStateModel(this._inputModel, `setting empty model state for ${this._widget?.viewModel?.sessionResource.toString()} in ${this._currentSessionKey}`, undefined, undefined, this.logService); const currentLevel = this._inputModel?.state?.get()?.permissionLevel; @@ -1486,6 +1682,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const match = findBestMatchingModel(state.selectedModel, pool) ?? findBestMatchingModel(currentLm, pool); if (match) { this.setCurrentLanguageModel(match); + } else if (shouldWaitForSessionModel(state.selectedModel, sessionType, allModels)) { + // The session's remembered model belongs to this pool but has not been + // contributed yet (cold/partial pool at restore). Wait for it instead of + // persisting a transient pool default over it; while this wait is pending, + // the model-list-change reset also skips persistence. + this._waitForSessionModel(state.selectedModel, state.modelConfiguration, forSessionResource); } else { this.setCurrentLanguageModelToDefault(sessionType); } @@ -1532,6 +1734,25 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Wait for a restored session's saved model when that model belongs to this session's pool but + * has not been contributed yet. This uses the same authoritative wait slot so fallback/default + * selections do not get stored while we wait, and it checks the session resource before applying. + */ + private _waitForSessionModel(desiredModel: ILanguageModelChatMetadataAndIdentifier, modelConfiguration: IStringDictionary<unknown> | undefined, forSessionResource: URI): void { + this._waitForPersistedLanguageModel.value = this.languageModelsService.onDidChangeLanguageModels(() => { + if (this._inputModelSessionResource?.toString() !== forSessionResource.toString()) { + return; + } + const liveModel = this.getAllMergedModels().find(m => m.identifier === desiredModel.identifier); + if (liveModel) { + this._waitForPersistedLanguageModel.clear(); + this.restoreModelConfiguration(desiredModel.identifier, modelConfiguration); + this.setCurrentLanguageModel(liveModel); + } + }); + } + /** * Sync current input state to the input model */ @@ -1567,7 +1788,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier, isUserAction = false) { + public setCurrentLanguageModel(model: ILanguageModelChatMetadataAndIdentifier, isUserAction = false, storeSelection: boolean = true) { if (isUserAction) { // An explicit user pick must survive subsequent automatic re-evaluations // (model-list changes, late configured-default arrival) so the configured @@ -1577,7 +1798,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const modelDetails = this.getModels().map(m => `${m.identifier} (${m.metadata.id})`).join(', '); const selectedModelStorageKey = this.getSelectedModelStorageKey(); const selectedModelIsDefaultStorageKey = this.getSelectedModelIsDefaultStorageKey(); - logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, modelDetials = ${modelDetails}`, undefined, undefined, this.logService); + logChangesToStateModel(this._inputModel, `setCurrentLanguageModel to ${model.identifier} in ${this._currentSessionKey}, storageKey=${selectedModelStorageKey}, isDefaultKey=${selectedModelIsDefaultStorageKey}, currentSessionType=${this._currentSessionType}, getCurrentSessionType=${this.getCurrentSessionType()}, boundInputModelSession=${this._inputModelSessionResource?.toString()}, modelDetials = ${modelDetails}, storeSelection=${storeSelection}`, undefined, undefined, this.logService); this._currentLanguageModel.set(model, undefined); if (this.cachedWidth) { @@ -1585,9 +1806,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.layout(this.cachedWidth); } - // Store as global user preference (session-specific state is in the model's inputModel) - this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); - this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefaultForLocation[this.location], StorageScope.APPLICATION, StorageTarget.USER); + // Store as global user preference (session-specific state is in the model's inputModel). + // `storeSelection: false` lets transient surfaces (e.g. the automations dialog applying an + // automation's saved model on open) apply the model to the input only, without overwriting + // the regular chat input's persisted selection. The session-switch guard is documented on + // `_modelSelectionSessionSwitch` / `shouldPersistModelSelection`. + if (shouldPersistModelSelection(storeSelection, !!this._modelSelectionSessionSwitch?.suppressPersistence)) { + this.storageService.store(this.getSelectedModelStorageKey(), model.identifier, StorageScope.APPLICATION, StorageTarget.USER); + this.storageService.store(this.getSelectedModelIsDefaultStorageKey(), !!model.metadata.isDefaultForLocation[this.location], StorageScope.APPLICATION, StorageTarget.USER); + } // Sync to model this._syncInputStateToModel(); @@ -1607,14 +1834,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge currentModeKind: this.currentModeKind, sessionType: this.getCurrentSessionType(), }, allModels)) { - this.setCurrentLanguageModelToDefault(); + this.setCurrentLanguageModelToDefault(undefined, !this.options.suppressModelPersistence); } } /** * By ID- prefer this method */ - setChatMode(mode: ChatModeKind | string, storeSelection = true): void { + setChatMode(mode: ChatModeKind | string, storeSelection = true, isUserInitiated = false): void { if (!this.options.supportsChangingModes) { return; } @@ -1624,22 +1851,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge modes.findModeByName(mode) ?? modes.findModeById(ChatModeKind.Agent) ?? ChatMode.Ask; - this.setChatMode2(mode2, storeSelection); + this.setChatMode2(mode2, storeSelection, isUserInitiated); } - private setChatMode2(mode: IChatMode, storeSelection = true): void { + private setChatMode2(mode: IChatMode, storeSelection = true, isUserInitiated = false): void { if (!this.options.supportsChangingModes) { return; } this._currentModeObservable.set(mode, undefined); - this._onDidChangeCurrentChatMode.fire(); + this._onDidChangeCurrentChatMode.fire({ isUserInitiated }); - // Sync to model (mode is now persisted in the model's input state) - // Log first so the upcoming _syncInputStateToModel write can be attributed - // to a mode change. - logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); - this._syncInputStateToModel(); + if (storeSelection) { + // Sync to model (mode is now persisted in the model's input state) + // Log first so the upcoming _syncInputStateToModel write can be attributed + // to a mode change. + logChangesToStateModel(this._inputModel, `setChatMode2 -> _syncInputStateToModel (mode=${mode.id}, storeSelection=${storeSelection}, isUserInitiated=${isUserInitiated}, currentLanguageModel=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, undefined, this.logService); + this._syncInputStateToModel(); + } } /** @@ -1875,7 +2104,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }); } - private setCurrentLanguageModelToDefault(forSessionType?: string) { + private setCurrentLanguageModelToDefault(forSessionType?: string, storeSelection: boolean = true) { // Defer when the session owns a pool but no targeted models have loaded yet; otherwise the general default would contaminate the session-targeted storage key. `resetCurrentLanguageModelIfUnavailable` retries on arrival. const sessionType = forSessionType ?? this.getCurrentSessionType(); if (sessionType @@ -1886,9 +2115,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const allModels = this.getModelsForSessionType(sessionType); const configuredModel = this.getConfiguredDefaultModel(allModels); const defaultModel = configuredModel ?? findDefaultModel(allModels, this.location); - logChangesToStateModel(this._inputModel, `[DEFAULT] setCurrentLanguageModelToDefault called (defaultModel=${defaultModel?.identifier}, configuredModel=${configuredModel?.identifier}, currentLm=${this._currentLanguageModel.get()?.identifier}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); + logChangesToStateModel(this._inputModel, `[DEFAULT] setCurrentLanguageModelToDefault called (defaultModel=${defaultModel?.identifier}, configuredModel=${configuredModel?.identifier}, currentLm=${this._currentLanguageModel.get()?.identifier}, storeSelection=${storeSelection}) in ${this._currentSessionKey}`, undefined, this._inputModel?.state.get(), this.logService); if (defaultModel) { - this.setCurrentLanguageModel(defaultModel); + // Apply the default in memory while an expected model is still loading, but do not store it. + const persist = storeSelection && !this.hasPendingAuthoritativeModelWait(); + this.setCurrentLanguageModel(defaultModel, false, persist); } } @@ -1947,6 +2178,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return true; } + /** Resets the language model to the location default and cancels any pending persisted-model waiter. */ + public resetLanguageModelToDefault(storeSelection: boolean = true): void { + this._waitForPersistedLanguageModel.clear(); + this.setCurrentLanguageModelToDefault(undefined, storeSelection); + } + /** * Get the current input state for history */ @@ -2027,6 +2264,26 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Re-apply the session's own persisted custom agent once its mode becomes available. + * + * A restored agent-host session persists its selected custom agent in `mode`, but the agent + * host's custom modes only register after the backend connects. Until then `setChatMode` falls + * back to the builtin Agent, so when the custom modes arrive (`modes.onDidChange`) re-apply the + * persisted custom agent. Builtin/default modes are handled by {@link validateCurrentChatMode}. + */ + private _restorePersistedCustomModeIfAvailable(): void { + const persistedMode = this._inputModel?.state.get()?.mode; + if (!persistedMode) { + return; + } + const modes = this._currentChatModesObservable.get(); + const found = modes.findModeById(persistedMode.id) ?? modes.findModeByName(persistedMode.id); + if (found && !found.isBuiltin && this._currentModeObservable.get().id !== found.id) { + this.setChatMode(found.id, false); + } + } + logInputHistory(): void { const historyStr = this.history.values.map(entry => JSON.stringify(entry)).join('\n'); this.logService.info(`[${this.location}] Chat input history:`, historyStr); @@ -2193,6 +2450,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (this._chatSessionIsEmpty) { this._chatSessionIsEmpty = false; this._emptyInputState.set(undefined, undefined); + this._emptyInputAttachments.set([], undefined); } // Clear attached context, fire event to clear input state, and clear the input editor @@ -2720,6 +2978,103 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.contextUsageWidget.update(model.lastRequest, model.sessionCost); } + private handleViewModelChange(e: IChatWidgetViewModelChangeEvent): void { + try { + this.resetPendingDelegationForViewModelChange(); + this.refreshViewModelScopedState(); + this.clearQuestionCarouselIfSessionChanged(e); + this.clearPlanReviewIfSessionChanged(e); + // Swap the visible tool confirmation carousel for the new session + this._syncToolConfirmationCarouselForSession(); + this.reconcileSessionTypeForViewModelChange(e); + // For contributed sessions with history, pre-select the model + // from the last request so the user resumes with the same model. + this.preselectModelFromSessionHistory(); + } finally { + // Always finish the session switch, even on an exception before this point, so an + // explicit user model pick after the switch persists normally. + this._modelSelectionSessionSwitch = undefined; + } + } + + private resetPendingDelegationForViewModelChange(): void { + this._pendingDelegationTarget = undefined; + this.chatHasPendingDelegationTargetKey.set(false); + } + + private refreshViewModelScopedState(): void { + // Update agentSessionType when view model changes + this.updateAgentSessionTypeContextKey(); + this.refreshChatSessionPickers(); + this.ensureNotificationWidget(); + this.updateContextUsageWidget(); + } + + private clearQuestionCarouselIfSessionChanged(e: IChatWidgetViewModelChangeEvent): void { + let hasMatchingResource = false; + if (e.currentSessionResource) { + for (const r of this._questionCarouselSessionResources.values()) { + if (isEqual(r, e.currentSessionResource)) { + hasMatchingResource = true; + break; + } + } + } + if (this._questionCarouselSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingResource)) { + this.clearQuestionCarousel(); + } + } + + private clearPlanReviewIfSessionChanged(e: IChatWidgetViewModelChangeEvent): void { + let hasMatchingPlanReviewResource = false; + if (e.currentSessionResource) { + for (const r of this._planReviewSessionResources.values()) { + if (isEqual(r, e.currentSessionResource)) { + hasMatchingPlanReviewResource = true; + break; + } + } + } + if (this._planReviewSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingPlanReviewResource)) { + this.clearPlanReview(); + } + } + + private reconcileSessionTypeForViewModelChange(e: IChatWidgetViewModelChangeEvent): void { + // Track the current session type and re-initialize model selection + // when the session type changes (different session types may have + // different model pools via targetChatSessionType). + const newSessionType = this.getCurrentSessionType(); + if (e.currentSessionResource && this._currentSessionType && newSessionType !== this._currentSessionType) { + logChangesToStateModel(this._inputModel, `[CVVM].1 onDidChangeViewModel -> session change: ${this._currentSessionType} -> ${newSessionType} in ${this._currentSessionKey}, ${e.currentSessionResource.toString()}`, undefined, this._inputModel?.state.get(), this.logService); + this._currentSessionType = newSessionType; + this.initSelectedModel(); + this.checkModelInSessionPool(); + this.checkModeInSessionPool(); + this._notificationWidget.value?.rerender(); + } else if (e.currentSessionResource) { + logChangesToStateModel(this._inputModel, `[CVVM].2 onDidChangeViewModel -> session change: ${this._currentSessionType} -> ${newSessionType} in ${this._currentSessionKey}, ${e.currentSessionResource.toString()}`, undefined, this._inputModel?.state.get(), this.logService); + this._currentSessionType = newSessionType; + this.restorePerTypeModelAfterViewModelAssignment(); + } + } + + private restorePerTypeModelAfterViewModelAssignment(): void { + // Fresh untitled own-pool session: `setInputModel` pre-advanced `_currentSessionType` + // before this event, so the branch above can't detect it. The view model is now + // assigned, so `getCurrentSessionType()` is correct and it is safe to restore the + // remembered per-session-type model. Persistence is still suppressed for this + // switch, so this only updates the picker — the intact per-type key is not rewritten. + // If the remembered model has not loaded yet, skip pool validation so the picker does not + // move away from the model that will be applied when it appears. + if (this._modelSelectionSessionSwitch?.restorePerTypeModel) { + this.initSelectedModel(); + if (!this.hasPendingAuthoritativeModelWait()) { + this.checkModelInSessionPool(); + } + } + } + render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); @@ -2733,63 +3088,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - this._register(widget.onDidChangeViewModel((e: IChatWidgetViewModelChangeEvent) => { - this._pendingDelegationTarget = undefined; - this.chatHasPendingDelegationTargetKey.set(false); - // Update agentSessionType when view model changes - this.updateAgentSessionTypeContextKey(); - this.refreshChatSessionPickers(); - this.ensureNotificationWidget(); - this.updateContextUsageWidget(); - let hasMatchingResource = false; - if (e.currentSessionResource) { - for (const r of this._questionCarouselSessionResources.values()) { - if (isEqual(r, e.currentSessionResource)) { - hasMatchingResource = true; - break; - } - } - } - if (this._questionCarouselSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingResource)) { - this.clearQuestionCarousel(); - } - - let hasMatchingPlanReviewResource = false; - if (e.currentSessionResource) { - for (const r of this._planReviewSessionResources.values()) { - if (isEqual(r, e.currentSessionResource)) { - hasMatchingPlanReviewResource = true; - break; - } - } - } - if (this._planReviewSessionResources.size > 0 && (!e.currentSessionResource || !hasMatchingPlanReviewResource)) { - this.clearPlanReview(); - } - - // Swap the visible tool confirmation carousel for the new session - this._syncToolConfirmationCarouselForSession(); - - // Track the current session type and re-initialize model selection - // when the session type changes (different session types may have - // different model pools via targetChatSessionType). - const newSessionType = this.getCurrentSessionType(); - if (e.currentSessionResource && this._currentSessionType && newSessionType !== this._currentSessionType) { - logChangesToStateModel(this._inputModel, `[CVVM].1 onDidChangeViewModel -> session change: ${this._currentSessionType} -> ${newSessionType} in ${this._currentSessionKey}, ${e.currentSessionResource.toString()}`, undefined, this._inputModel?.state.get(), this.logService); - this._currentSessionType = newSessionType; - this.initSelectedModel(); - this.checkModelInSessionPool(); - this.checkModeInSessionPool(); - this._notificationWidget.value?.rerender(); - } else if (e.currentSessionResource) { - logChangesToStateModel(this._inputModel, `[CVVM].2 onDidChangeViewModel -> session change: ${this._currentSessionType} -> ${newSessionType} in ${this._currentSessionKey}, ${e.currentSessionResource.toString()}`, undefined, this._inputModel?.state.get(), this.logService); - this._currentSessionType = newSessionType; - } - - // For contributed sessions with history, pre-select the model - // from the last request so the user resumes with the same model. - this.preselectModelFromSessionHistory(); - })); + this._register(widget.onDidChangeViewModel(e => this.handleViewModelChange(e))); let elements; if (this.options.renderStyle === 'compact') { @@ -2970,7 +3269,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._inputEditorElement = dom.append(editorContainer, $(chatInputEditorContainerSelector)); const editorOptions = getSimpleCodeEditorWidgetOptions(); - editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID])); + editorOptions.contributions?.push(...EditorExtensionsRegistry.getSomeEditorContributions([ContentHoverController.ID, GlyphHoverController.ID, DropIntoEditorController.ID, CopyPasteController.ID, LinkDetector.ID, InlineCompletionsController.ID, PlaceholderTextContribution.ID])); this._inputEditor = this._register(scopedInstantiationService.createInstance(CodeEditorWidget, this._inputEditorElement, options, editorOptions)); SuggestController.get(this._inputEditor)?.forceRenderingAbove(); @@ -3105,6 +3404,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } else if (action.id === OpenModePickerAction.ID && action instanceof MenuItemAction) { const delegate: IModePickerDelegate = this._createModePickerDelegate(); return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); + } else if (action.id === OpenAutomationsWorkspacePickerAction.ID && action instanceof MenuItemAction) { + // Toolbar chip for an externally-owned workspace picker + // (today: the automations dialog's single picker instance, + // also rendered in the dialog form row). Defensive guard: + // the menu's `when` clause should already gate visibility, + // but if a host registers the menu contribution without + // supplying the picker we hide rather than throw. + if (this.options.workspacePickerInput) { + return this.instantiationService.createInstance(WorkspacePickerInputActionItem, action, this.options.workspacePickerInput, undefined); + } + return new HiddenActionViewItem(action); } else if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { // Use provided delegate if available, otherwise create default delegate const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { @@ -3205,6 +3515,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // no chevron, so it can collapse further to 16px. const agentHostShortPickerMinWidths = new Map<string, number>([ [OpenAgentHostModePickerAction.ID, 22], + ['sessions.agentHost.runningSessionModePicker', 22], [OpenAgentHostAutoApprovePickerAction.ID, 22], [OpenAgentHostPermissionModePickerAction.ID, 22], [OpenAgentHostFolderPickerAction.ID, 22], @@ -3239,6 +3550,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge getActionMinWidth: action => agentHostShortPickerMinWidths.get(action.id), }, actionViewItemProvider: (action, options) => { + const customSecondaryItem = this.options.secondaryToolbarActionViewItemProvider?.(action, options); + if (customSecondaryItem) { + return customSecondaryItem; + } if ((action.id === OpenSessionTargetPickerAction.ID || action.id === OpenDelegationPickerAction.ID) && action instanceof MenuItemAction) { const delegate: ISessionTypePickerDelegate = this.options.sessionTypePickerDelegate ?? { getActiveSessionProvider: () => { @@ -3547,7 +3862,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const shouldFocusClearButton = index === Math.min(this._indexOfLastAttachedContextDeletedWithKeyboard, this.attachmentModel.size - 1) && this._indexOfLastAttachedContextDeletedWithKeyboard > -1; let attachmentWidget; - const options = { shouldFocusClearButton, supportsDeletion: true }; + const options = { shouldFocusClearButton, supportsDeletion: true, isCurrentInput: true }; const lm = this._currentLanguageModel.get(); if (attachment.kind === 'tool' || attachment.kind === 'toolset') { attachmentWidget = this.instantiationService.createInstance(ToolSetOrToolItemAttachmentWidget, attachment, lm, options, container, this._contextResourceLabels); @@ -4390,7 +4705,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const currentEditorHeight = this.previousInputEditorDimension?.height ?? 0; const nonEditorHeight = Math.max(0, this.height.get() - currentEditorHeight); const budgetForEditor = this._maxHeight - nonEditorHeight; - return Math.min(this.inputEditorMaxHeight, Math.max(0, budgetForEditor)); + + // Floor the budget so the editor keeps at least one usable line. See #322523. + const minEditorHeight = this.inputEditorMinHeight ?? this.singleLineInputEditorHeight; + return Math.max(minEditorHeight, Math.min(this.inputEditorMaxHeight, Math.max(0, budgetForEditor))); } private previousInputEditorDimension: IDimension | undefined; @@ -4451,7 +4769,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // content cards. The editor width is computed here, so it must account // for the same 64px total horizontal gutter or the editor overflows its // container and renders wider than the message content above it. - inputPartHorizontalPadding: this.options.renderStyle === 'compact' ? 16 : (this.options.isSessionsWindow ? 64 : 24), + inputPartHorizontalPadding: this.options.inputPartHorizontalPadding ?? (this.options.renderStyle === 'compact' ? 16 : (this.options.isSessionsWindow ? 64 : 24)), inputPartHorizontalPaddingInside: this.options.renderStyle === 'compact' ? 12 : 10, toolbarsWidth: this.options.renderStyle === 'compact' ? getToolbarsWidthCompact() : 0, sideToolbarWidth: inputSideToolbarWidth > 0 ? inputSideToolbarWidth + 4 /*gap*/ : 0, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts index a85c404c2c0467..edfc0b408a72ab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPickerActionItem.ts @@ -9,6 +9,7 @@ import { autorun, IObservable } from '../../../../../../base/common/observable.j import { ActionWidgetDropdownActionViewItem } from '../../../../../../platform/actions/browser/actionWidgetDropdownActionViewItem.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownOptions } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; +import { IActionListOptions } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; @@ -26,6 +27,23 @@ export interface IChatInputPickerOptions { readonly compact: IObservable<boolean>; } +export const CHAT_INPUT_PICKER_DROPDOWN_CLASS = 'chat-input-picker-dropdown'; +export const CHAT_INPUT_PICKER_DROPDOWN_CLOSING_CLASS = 'chat-input-picker-dropdown-closing'; +export const CHAT_INPUT_PICKER_CLOSE_ANIMATION_DURATION = 150; +export const CHAT_INPUT_PICKER_MOTION_ANCESTOR_CLASSES = ['style-override', 'monaco-enable-motion']; + +function withChatInputPickerMotion(listOptions: IActionListOptions | undefined): IActionListOptions { + return { + ...listOptions, + className: [listOptions?.className, CHAT_INPUT_PICKER_DROPDOWN_CLASS].filter(Boolean).join(' '), + closeAnimation: listOptions?.closeAnimation ?? { + className: CHAT_INPUT_PICKER_DROPDOWN_CLOSING_CLASS, + duration: CHAT_INPUT_PICKER_CLOSE_ANIMATION_DURATION, + requiredAncestorClasses: CHAT_INPUT_PICKER_MOTION_ANCESTOR_CLASSES, + }, + }; +} + /** * Base class for chat input picker action items (model picker, mode picker, session target picker). * Provides common anchor resolution logic for dropdown positioning. @@ -45,6 +63,7 @@ export abstract class ChatInputPickerActionViewItem extends ActionWidgetDropdown const optionsWithAnchor: Omit<IActionWidgetDropdownOptions, 'label' | 'labelRenderer'> = { ...actionWidgetOptions, getAnchor: () => this.getAnchorElement(), + listOptions: withChatInputPickerMotion(actionWidgetOptions.listOptions), }; super(action, optionsWithAnchor, actionWidgetService, keybindingService, contextKeyService, telemetryService); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStatePersistence.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStatePersistence.ts new file mode 100644 index 00000000000000..509efd2b1fd41f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputStatePersistence.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { parse, stringify } from '../../../../../../base/common/marshalling.js'; +import { IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { IChatModelInputState } from '../../../common/model/chatModel.js'; + +export function serializeUntitledInputState(value: IChatModelInputState | undefined): string { + return stringify(value && { ...value, attachments: [] }); +} + +export function deserializeUntitledInputState(value: string): IChatModelInputState { + return parse(value) as IChatModelInputState; +} + +export function serializeUntitledInputAttachments(attachments: readonly IChatRequestVariableEntry[]): string { + return stringify(attachments.map(IChatRequestVariableEntry.toExport)); +} + +export function deserializeUntitledInputAttachments(value: string): IChatRequestVariableEntry[] { + return (parse(value) as IChatRequestVariableEntry[]).map(IChatRequestVariableEntry.fromExport); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts index da9397b81e02a6..4f770a7b0bb440 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelPicker.ts @@ -15,6 +15,7 @@ import { IStringDictionary } from '../../../../../../base/common/collections.js' import { Codicon } from '../../../../../../base/common/codicons.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { renderMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { disposableTimeout } from '../../../../../../base/common/async.js'; @@ -23,25 +24,28 @@ import { formatTokenCount } from '../../../../../../base/common/numbers.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; -import { ActionListItemKind, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; +import { ActionListItemKind, IActionListHeaderLink, IActionListItem } from '../../../../../../platform/actionWidget/browser/actionList.js'; import { IActionWidgetService } from '../../../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownAction } from '../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { TelemetryTrustedValue } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { MANAGE_CHAT_COMMAND_ID } from '../../../common/constants.js'; import { IModelControlEntry, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelsControlManifest } from '../../../common/languageModels.js'; import { ChatEntitlement, chatRequiresSetup, IChatEntitlementService, isProUser } from '../../../../../services/chat/common/chatEntitlementService.js'; import * as semver from '../../../../../../base/common/semver/semver.js'; import { IModelConfigurationAccess, IModelPickerDelegate } from './modelPickerActionItem.js'; +import { getModelProviderIcon } from './modelProviderIcons.js'; import { getModelPickerUnavailableReason, ModelPickerUnavailableReason } from './chatModelSelectionLogic.js'; import { CHAT_SETUP_ACTION_ID } from '../../actions/chatActions.js'; import { IUriIdentityService } from '../../../../../../platform/uriIdentity/common/uriIdentity.js'; import { GitHubPaths, IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IUpdateService, StateType } from '../../../../../../platform/update/common/update.js'; import { IWorkspaceTrustManagementService, IWorkspaceTrustRequestService } from '../../../../../../platform/workspace/common/workspaceTrust.js'; +import { CHAT_INPUT_PICKER_CLOSE_ANIMATION_DURATION, CHAT_INPUT_PICKER_DROPDOWN_CLASS, CHAT_INPUT_PICKER_DROPDOWN_CLOSING_CLASS, CHAT_INPUT_PICKER_MOTION_ANCESTOR_CLASSES } from './chatInputPickerActionItem.js'; function isVersionAtLeast(current: string, required: string): boolean { const currentSemver = semver.coerce(current); @@ -96,12 +100,20 @@ const SETUP_REQUIRED_SIGN_IN_ACTION_ID = 'setupRequiredSignIn'; /** Synthetic command entries (Trust / Sign in) that are not selectable models. */ const PICKER_COMMAND_ACTION_IDS: ReadonlySet<string> = new Set([RESTRICTED_MODE_TRUST_ACTION_ID, SETUP_REQUIRED_SIGN_IN_ACTION_ID]); +/** Storage key remembering that the user dismissed the cache-break hint. */ +const CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY = 'chat.cacheBreakHintDismissed'; + /** * Returns a human-readable display name for a model vendor. - * Looks up the registered provider descriptor's displayName first, - * then falls back to capitalizing the raw vendor id. + * Uses known product names before falling back to the registered provider + * descriptor or a capitalized vendor id. */ function getVendorDisplayName(languageModelsService: ILanguageModelsService, vendor: string): string { + if (vendor === 'copilotcli') { + // @vritant24: This is temporary until we we have 2 distinct vendors for Copilot CLI vs Copilot Chat. + // For now, we want to show "Copilot" in the model picker for both. + return localize('chat.modelPicker.copilotGroup', "Copilot"); + } const descriptor = languageModelsService.getVendors().find(v => v.vendor === vendor); if (descriptor?.displayName) { return descriptor.displayName; @@ -160,6 +172,13 @@ function getProviderGroupForModel( modelToGroup: Map<string, IProviderGroupInfo>, languageModelsService: ILanguageModelsService, ): IProviderGroupInfo { + // Agent-host models share one vendor but declare their upstream provider (a vendor id) + // via `modelGroup`; bucket by it, resolving the display name from the vendor registry — + // the same source used for every other vendor — so they don't collapse into one section. + if (model.metadata.modelGroup) { + return { vendor: model.metadata.vendor, groupName: getVendorDisplayName(languageModelsService, model.metadata.modelGroup.id) }; + } + const info = modelToGroup.get(model.identifier); if (info) { return info; @@ -243,7 +262,7 @@ function createModelItem( onConfigure?: (model: ILanguageModelChatMetadataAndIdentifier, group: string) => void, ): IActionListItem<IActionWidgetDropdownAction> { const hover = model && openerService - ? getModelHoverContent(model, isUBB, onConfigure ? (group) => onConfigure(model, group) : undefined) + ? getModelHoverContent(model, isUBB, onConfigure ? (group) => onConfigure(model, group) : undefined, openerService) : undefined; return { item: action, @@ -1048,6 +1067,7 @@ export class ModelPickerWidget extends Disposable { @IDefaultAccountService private readonly _defaultAccountService: IDefaultAccountService, @IWorkspaceTrustManagementService private readonly _workspaceTrustManagementService: IWorkspaceTrustManagementService, @IWorkspaceTrustRequestService private readonly _workspaceTrustRequestService: IWorkspaceTrustRequestService, + @IStorageService private readonly _storageService: IStorageService, ) { super(); this._register(this._languageModelsService.onDidChangeLanguageModels(() => { @@ -1266,11 +1286,50 @@ export class ModelPickerWidget extends Disposable { })); } + /** The "Learn more" header link for cache-break hints; `undefined` when the product has no URL. */ + private getCacheBreakLearnMoreLink(): IActionListHeaderLink | undefined { + const url = this._productService.defaultChatAgent?.optimizeUsageDocumentationUrl; + return url ? { label: localize('chat.cacheBreak.learnMore', "Learn more"), uri: URI.parse(url) } : undefined; + } + + private isCacheBreakHintDismissed(): boolean { + return this._storageService.getBoolean(CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY, StorageScope.APPLICATION, false); + } + + private dismissCacheBreakHint(): void { + this._storageService.store(CACHE_BREAK_HINT_DISMISSED_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER); + } + + /** + * Whether a picker should show the cache-break hint: the hint has not been + * dismissed and the session's cache is warm. When `excludeAutoModel` is + * set (the model picker), the hint is suppressed for the Auto model, since + * Auto routes each request dynamically so "switching models" does not apply. + * The options picker passes `false`: changing reasoning effort / context size + * resets the cache even under Auto, which only routes the model. + */ + private shouldShowCacheBreakHint(excludeAutoModel: boolean): boolean { + if (this.isCacheBreakHintDismissed()) { + return false; + } + if (!(this._delegate.isCacheWarm?.() ?? false)) { + return false; + } + if (excludeAutoModel && !!this._selectedModel && isAutoModel(this._selectedModel)) { + return false; + } + return true; + } + show(anchor?: HTMLElement): void { const anchorElement = anchor ?? this._domNode; if (!anchorElement || this._domNode?.classList.contains('disabled')) { return; } + if (this._nameButton?.getAttribute('aria-expanded') === 'true') { + this._actionWidgetService.hide(true); + return; + } const previousModel = this._selectedModel; @@ -1359,7 +1418,12 @@ export class ModelPickerWidget extends Disposable { // stale, unusable models. Shown otherwise (it also hosts the secondary // heading). const unavailable = this.isRestrictedMode() || this.isSetupRequired(); + const showCacheBreakHint = this.shouldShowCacheBreakHint(/* excludeAutoModel */ true); const listOptions = { + headerText: showCacheBreakHint ? localize('chat.modelPicker.cacheBreakHint', "Switching models mid-session resets the prompt cache and may increase cost.") : undefined, + headerIcon: showCacheBreakHint ? Codicon.info : undefined, + headerLink: showCacheBreakHint ? this.getCacheBreakLearnMoreLink() : undefined, + headerDismiss: showCacheBreakHint ? () => this.dismissCacheBreakHint() : undefined, showFilter: !unavailable, filterPlaceholder: localize('chat.modelPicker.search', "Search models"), focusFilterOnOpen: true, @@ -1378,6 +1442,12 @@ export class ModelPickerWidget extends Disposable { void this._openerService.open(uri, { allowCommands: true }); }, minWidth: 200, + className: CHAT_INPUT_PICKER_DROPDOWN_CLASS, + closeAnimation: { + className: CHAT_INPUT_PICKER_DROPDOWN_CLOSING_CLASS, + duration: CHAT_INPUT_PICKER_CLOSE_ANIMATION_DURATION, + requiredAncestorClasses: CHAT_INPUT_PICKER_MOTION_ANCESTOR_CLASSES, + }, }; const previouslyFocusedElement = dom.getActiveElement(); @@ -1461,6 +1531,11 @@ export class ModelPickerWidget extends Disposable { // --- Name section --- const nameChildren: (HTMLElement | string)[] = []; + const modelIcon = this._selectedModel ? getModelProviderIcon(this._selectedModel, this._delegate.useGenericModelIcon?.()) : undefined; + const compact = this._compact?.get() ?? false; + if (modelIcon && !noModelsAvailable) { + nameChildren.push(renderIcon(modelIcon)); + } if (statusIcon && !noModelsAvailable) { nameChildren.push(renderIcon(statusIcon)); } @@ -1471,7 +1546,9 @@ export class ModelPickerWidget extends Disposable { : genericNoModels ? localize('chat.modelPicker.noModels', "No models available") : (name ?? localize('chat.modelPicker.auto', "Auto")); - nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); + if (!compact || !modelIcon || noModelsAvailable) { + nameChildren.push(dom.$('span.chat-input-picker-label', undefined, modelLabel)); + } if (this._badgeIcon) { nameChildren.push(this._badgeIcon); } @@ -1481,7 +1558,7 @@ export class ModelPickerWidget extends Disposable { const effortConfig = this._getConfigProperty('navigation'); const tokensConfig = this._getConfigProperty('tokens'); if (this._configButton) { - if (this._selectedModel && !noModelsAvailable && (effortConfig || tokensConfig)) { + if (!compact && this._selectedModel && !noModelsAvailable && (effortConfig || tokensConfig)) { const labelParts: string[] = []; const ariaParts: string[] = []; if (effortConfig) { @@ -1510,11 +1587,13 @@ export class ModelPickerWidget extends Disposable { // Aria — name the control "Models" to match the visible label; the comma // separates the control name from its current value / state. - this._domNode.ariaLabel = restrictedMode + const ariaLabel = restrictedMode ? localize('chat.modelPicker.ariaLabelRestricted', "Models, unavailable while in Restricted mode") : setupRequired ? localize('chat.modelPicker.ariaLabelSetupRequired', "Models, sign in to use Copilot") : localize('chat.modelPicker.ariaLabel', "Models, {0}", modelLabel); + this._domNode.ariaLabel = ariaLabel; + this._nameButton.ariaLabel = ariaLabel; } /** @@ -1689,6 +1768,8 @@ export class ModelPickerWidget extends Disposable { this._configButton.setAttribute('aria-expanded', 'true'); + const showCacheBreakHint = this.shouldShowCacheBreakHint(/* excludeAutoModel */ false); + this._actionWidgetService.show( 'ChatModelConfigPicker', false, @@ -1705,8 +1786,16 @@ export class ModelPickerWidget extends Disposable { getWidgetRole: () => 'menu' as const, }, { - headerText: localize('chat.config.costHint', "Non-default options may increase cost"), - headerIcon: Codicon.info, + headerText: showCacheBreakHint ? localize('chat.config.cacheBreakHint', "Changing these options mid-session resets the prompt cache and may increase cost.") : undefined, + headerIcon: showCacheBreakHint ? Codicon.info : undefined, + headerLink: showCacheBreakHint ? this.getCacheBreakLearnMoreLink() : undefined, + headerDismiss: showCacheBreakHint ? () => this.dismissCacheBreakHint() : undefined, + className: CHAT_INPUT_PICKER_DROPDOWN_CLASS, + closeAnimation: { + className: CHAT_INPUT_PICKER_DROPDOWN_CLOSING_CLASS, + duration: CHAT_INPUT_PICKER_CLOSE_ANIMATION_DURATION, + requiredAncestorClasses: CHAT_INPUT_PICKER_MOTION_ANCESTOR_CLASSES, + }, } ); @@ -1729,12 +1818,12 @@ export class ModelPickerWidget extends Disposable { */ const SUPPORTED_CONFIG_GROUPS: readonly string[] = ['navigation', 'tokens']; -export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, isUBB?: boolean, onConfigure?: (group: string) => void): { element: HTMLElement; disposable: DisposableStore } | undefined { +export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentifier, isUBB: boolean | undefined, onConfigure: ((group: string) => void) | undefined, openerService: IOpenerService): { element: HTMLElement; disposable: DisposableStore } | undefined { const isAuto = isAutoModel(model); const container = dom.$('.chat-model-hover'); const disposables = new DisposableStore(); - // --- Title row: model name + category tag + price category badge (top-right) --- + // --- Title row: model name + category tag + price/discount badge (top-right) --- const titleRow = dom.$('.chat-model-hover-title-row'); titleRow.appendChild(dom.$('.chat-model-hover-name', undefined, model.metadata.name)); const tags = dom.$('.chat-model-hover-title-tags'); @@ -1743,9 +1832,12 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif tags.appendChild(dom.$('span.chat-model-hover-category', undefined, categoryLabel)); } const priceCategoryLabel = !isAuto ? getPriceCategoryLabel(model.metadata.priceCategory) : undefined; - if (priceCategoryLabel) { - const badge = dom.$('span.chat-model-hover-price-badge', undefined, priceCategoryLabel); - if (isHighCostCategory(model.metadata.priceCategory)) { + // Auto has no fixed price category; when it carries a discount, surface it as + // the pill (e.g. "10% discount") so it reads like the other models' cost pill. + const badgeLabel = isAuto ? model.metadata.detail : priceCategoryLabel; + if (badgeLabel) { + const badge = dom.$('span.chat-model-hover-price-badge', undefined, badgeLabel); + if (!isAuto && isHighCostCategory(model.metadata.priceCategory)) { badge.classList.add('high-cost'); } tags.appendChild(badge); @@ -1755,7 +1847,23 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif } container.appendChild(titleRow); + // --- Warning text --- + if (!isAuto && model.metadata.warningText) { + const warningMessages = Object.values(model.metadata.warningText); + for (const message of warningMessages) { + const warningContainer = dom.$('.chat-model-hover-warning-text'); + warningContainer.appendChild(renderIcon(Codicon.warning)); + const warningMd = new MarkdownString(message, { isTrusted: false, supportThemeIcons: true }); + const rendered = disposables.add(renderMarkdown(warningMd, { + actionHandler: (link: string) => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + warningContainer.appendChild(rendered.element); + container.appendChild(warningContainer); + } + } + // --- Cost info --- + let costInfoRendered = false; let costTableRendered = false; if (!isAuto && isUBB) { const metrics: { label: string; def: number | null | undefined; long: number | null | undefined }[] = [ @@ -1816,6 +1924,7 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif container.appendChild(table); costTableRendered = true; + costInfoRendered = true; } else if (model.metadata.pricing && (isMultiplierPricing(model) || !priceCategoryLabel)) { // No per-token credit table for this model: surface the pricing string // (e.g. a "2x" multiplier for PRU models) in the hover instead of the @@ -1823,6 +1932,7 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif const costSection = dom.$('.chat-model-hover-cost'); costSection.appendChild(dom.$('span', undefined, localize('models.cost', 'Cost: {0}', model.metadata.pricing))); container.appendChild(costSection); + costInfoRendered = true; } } else if (!isAuto && model.metadata.pricing) { // Non-UBB (PRU): usage is not billed in credits, so the per-token credit @@ -1831,6 +1941,20 @@ export function getModelHoverContent(model: ILanguageModelChatMetadataAndIdentif const costSection = dom.$('.chat-model-hover-cost'); costSection.appendChild(dom.$('span', undefined, localize('models.cost', 'Cost: {0}', model.metadata.pricing))); container.appendChild(costSection); + costInfoRendered = true; + } + + // --- Description fallback --- + // When there's no cost data to show (Auto, or any model without pricing), fill + // the cost region with the model's tooltip text. Rendered as markdown so links + // like Auto's "Learn More" work. + if (!costInfoRendered && model.metadata.tooltip) { + const descriptionMd = new MarkdownString(model.metadata.tooltip, { supportThemeIcons: true }); + const rendered = disposables.add(renderMarkdown(descriptionMd, { + actionHandler: (link: string) => { void openerService.open(link, { allowCommands: false, fromUserGesture: true }); }, + })); + rendered.element.classList.add('chat-model-hover-description'); + container.appendChild(rendered.element); } // --- Context size (only when not already shown in the cost table) --- diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts index c79f7ce11599c3..e9b6b799fe4a0a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatModelSelectionLogic.ts @@ -99,6 +99,89 @@ export function isModelValidForSession( return !model.metadata.targetChatSessionType; } +/** + * Whether the selected model carried by the shared, session-type-agnostic untitled draft + * (`chat.untitledInputState`) must be dropped before the draft is applied to an empty session + * that is being opened. + * + * The draft is shared across all session types, so its `selectedModel` can belong to a + * different pool in either direction — e.g. a `copilot/*` model leaking into an agent-host + * session, or an `agent-host-*` model leaking into a general/local session. Applying such a + * cross-pool model while the session is opening lets the sync resolve it to (and persist) a + * wrong default over the destination pool's persisted model. Dropped when present but not valid + * for `sessionType`; an in-pool draft model is kept. See + * `chatInputPart._getPersistedEmptyInputState`. + */ +export function shouldDropAgnosticDraftModel( + draftModel: ILanguageModelChatMetadataAndIdentifier | undefined, + allModels: ILanguageModelChatMetadataAndIdentifier[], + sessionType: string | undefined, +): boolean { + return !!draftModel && !isModelValidForSession(draftModel, allModels, sessionType); +} + +/** + * Whether an {@link ILanguageModelChatMetadataAndIdentifier} selection should be written to the + * persisted per-(location, sessionType) model storage key. + * + * A model selection is only persisted for an explicit request (`storeSelection`) that is NOT + * happening while the input is switching to a session (`suppressDuringSessionSwitch`). While + * switching, the model may be set in-memory (for the picker) and restored from the key, but must + * never WRITE the key — only an explicit user action may. This is the single guard on + * `chatInputPart`'s sole storage writer (`setCurrentLanguageModel`). + */ +export function shouldPersistModelSelection(storeSelection: boolean, suppressDuringSessionSwitch: boolean): boolean { + return storeSelection && !suppressDuringSessionSwitch; +} + +/** + * Whether model-selection persistence must be suppressed while the input switches to a session. + * + * True for every empty session of an own-pool (agent-host) session type: the per-type key holds + * the user's last explicit pick, and switching to the session must not clobber it via any of the + * paths that run during the switch (draft sync, empty-state seeding, autorun default). + * General/local (no own pool) is unaffected. + */ +export function shouldSuppressModelPersistenceOnSessionSwitch(isEmpty: boolean, sessionOwnsPool: boolean): boolean { + return isEmpty && sessionOwnsPool; +} + +/** + * Whether the persisted per-session-type model should be restored (into the picker) when the + * input switches to a session. + * + * True only for a FRESH untitled own-pool session — one with no incoming `selectedModel` in its + * own input state. A session that already carries its own model (a transferred/handoff or + * startup-restored draft) keeps that model in-memory and is left alone. Distinct from + * {@link shouldSuppressModelPersistenceOnSessionSwitch}, which suppresses the STORAGE write for + * ALL empty own-pool sessions regardless. + */ +export function shouldRestorePerTypeModelOnSessionSwitch(isEmpty: boolean, sessionOwnsPool: boolean, hadIncomingModel: boolean): boolean { + return isEmpty && sessionOwnsPool && !hadIncomingModel; +} + +/** + * Whether the input should WAIT for a restored session's own remembered model to be contributed, + * instead of falling back to the pool default. + * + * True when the session's remembered `desiredModel` belongs to this session's own pool (it + * targets `sessionType`) but is not yet present in `allModels` — i.e. the session-type pool has + * not finished loading at restore time (cold or partial). Waiting avoids persisting a transient + * pool default (e.g. Haiku) over the session's remembered model (e.g. Opus) while the pool + * settles. A model that does not belong to this session's pool returns false, so the caller + * defaults instead of waiting forever. + */ +export function shouldWaitForSessionModel( + desiredModel: ILanguageModelChatMetadataAndIdentifier, + sessionType: string | undefined, + allModels: ILanguageModelChatMetadataAndIdentifier[], +): boolean { + if (!sessionType || desiredModel.metadata.targetChatSessionType !== sessionType) { + return false; + } + return !allModels.some(m => m.identifier === desiredModel.identifier); +} + /** * Find a model in `pool` that matches `previous` by id, then family, then * name (case-insensitive). Used to carry a selection across model pools diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts index bdfb9ecdc1d189..b3434aa83a5232 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatPhoneInputPresenter.ts @@ -16,6 +16,7 @@ import { InstantiationType, registerSingleton } from '../../../../../../platform import { createDecorator } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IModePickerDelegate } from './modePickerActionItem.js'; import { IModelPickerDelegate } from './modelPickerActionItem.js'; +import { getModelProviderIcon } from './modelProviderIcons.js'; /** * Implementation of the phone-only chat-input picker presenter, registered @@ -196,6 +197,9 @@ export class MobileChatInputCombinedPickerActionItem extends BaseActionViewItem } const currentModel = this._modelDelegate.currentModel.get(); + if (currentModel) { + dom.append(trigger, renderIcon(getModelProviderIcon(currentModel, this._modelDelegate.useGenericModelIcon?.()))); + } const labelText = currentModel?.metadata.name ?? localize('chatPhoneInput.autoLabel', "Auto"); const labelSpan = dom.append(trigger, dom.$('span.chat-input-picker-label')); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts index 1b7cdb77b0fbae..8ec0e0534afec5 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatQueuePickerActionItem.ts @@ -25,7 +25,7 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IWorkbenchContribution } from '../../../../../common/contributions.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { ChatSubmitAction } from '../../actions/chatExecuteActions.js'; +import { ChatSubmitAction, type IChatExecuteActionContext } from '../../actions/chatExecuteActions.js'; import { ChatQueueMessageAction, ChatSteerWithMessageAction } from '../../actions/chatQueueActions.js'; /** @@ -239,7 +239,7 @@ export class ChatQueuePickerActionItem extends BaseActionViewItem { content: localize('chat.sendImmediately.hover', "Cancel the current request and send this message immediately."), }, run: () => { - this.commandService.executeCommand(ChatSubmitAction.ID); + this.commandService.executeCommand(ChatSubmitAction.ID, { acceptInputOptions: { cancelCurrentRequest: true } } satisfies IChatExecuteActionContext); } }; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index 74195e8684cdd3..c4b8bc0eb3c939 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -151,6 +151,13 @@ export class ChatSelectedTools extends Disposable { } } for (const toolSet of this._toolsService.getToolSetsForModel(lm, r)) { + // Hidden tool sets (e.g. the built-in client tool sets that only exist to group tools + // in the Chat Customizations UI) can't be toggled here and are ignored by the picker. + // Their member tools are already resolved by the loop above, so skip them entirely - + // otherwise they'd override individual tool state and re-enable disabled tools. + if (toolSet.hiddenInToolsPicker) { + continue; + } const toolSetEnabled = currentMap.toolSets.get(toolSet.id) !== false; // if unknown, it's enabled map.set(toolSet, toolSetEnabled); for (const tool of toolSet.getTools(r)) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 5ec3916c8214fc..69163e8b8c2b6c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -16,11 +16,12 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { isVisibleEditorChatSessionType } from '../../../common/constants.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentCanContinueIn, getAgentSessionProvider, isAgentHostTarget, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; @@ -34,8 +35,6 @@ import { IGitService } from '../../../../git/common/gitService.js'; */ export class DelegationSessionPickerActionItem extends SessionTypePickerActionItem { - private readonly _isSessionsWindow: boolean; - constructor( action: MenuItemAction, chatSessionPosition: 'sidebar' | 'editor', @@ -51,10 +50,10 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt @IChatEntitlementService chatEntitlementService: IChatEntitlementService, @ILanguageModelsService languageModelsService: ILanguageModelsService, @IConfigurationService configurationService: IConfigurationService, + @IStorageService storageService: IStorageService, @IGitService private readonly gitService: IGitService, ) { - super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService); - this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; + super(action, chatSessionPosition, delegate, pickerOptions, actionWidgetService, keybindingService, contextKeyService, chatSessionsService, commandService, openerService, telemetryService, chatEntitlementService, languageModelsService, configurationService, storageService); } protected override _run(sessionTypeItem: ISessionTypeItem): void { @@ -124,6 +123,11 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return false; } + // Apply the same visibility guards as the new-session picker (e.g. Local hidden when chat.editor.localAgent.enabled is false). + if (!isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService)) { + return false; + } + return getAgentCanContinueIn(type); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts new file mode 100644 index 00000000000000..f12f0a64b313a1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCommandArgumentHint.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, MutableDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { ICodeEditorService } from '../../../../../../../editor/browser/services/codeEditorService.js'; +import { IRange, Range } from '../../../../../../../editor/common/core/range.js'; +import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; +import { ITextModel } from '../../../../../../../editor/common/model.js'; +import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +import { getCommandArgumentHint } from '../../../../../../../platform/agentHost/common/meta/agentCompletionAttachmentMeta.js'; +import { AgentHostCompletionReferenceKind, getAgentHostCompletionReferenceKindFromValue } from '../../../../common/attachments/chatVariableEntries.js'; +import { ChatDynamicVariableModel } from '../../../attachments/chatDynamicVariables.js'; +import { IChatWidget } from '../../../chat.js'; +import { ChatWidget } from '../../chatWidget.js'; +import { getInputPlaceholderColor, getRangeForPlaceholder } from './chatInputPlaceholderDecoration.js'; + +const decorationDescription = 'chat'; +const commandArgumentHintDecorationType = 'chat-command-argument-hint'; + +/** + * Renders an inline placeholder (ghost text) argument hint after an accepted + * agent-host slash-command completion (e.g. `/rename `). The hint text is + * carried on the completion's dynamic-variable reference `_meta` (see + * {@link getCommandArgumentHint}); it is shown only while the command reference + * is the sole content followed by a single trailing space, i.e. before any + * argument has been typed. + * + * Reads the accepted references directly from {@link ChatDynamicVariableModel} + * rather than the parsed input, because the chat request parser resolves a + * leading `/command` as a slash-prompt part (which carries no `_meta`) before + * dynamic-variable parsing runs. + */ +class InputEditorCommandArgumentHint extends Disposable { + + public readonly id = 'inputEditorCommandArgumentHint'; + + /** + * Subscription to {@link ChatDynamicVariableModel.onDidChangeReferences}. + * Established lazily because that contribution may be constructed after this + * one; accepting a completion adds the reference via a command that runs + * after the insert, and it does not change the parsed input, so neither + * `onDidChangeModelContent` nor `onDidChangeParsedInput` re-fires with the + * reference present — this event is what triggers the hint on accept. + */ + private readonly _referencesListener = this._register(new MutableDisposable()); + + constructor( + private readonly widget: IChatWidget, + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IThemeService private readonly themeService: IThemeService, + ) { + super(); + + this._register(this.codeEditorService.registerDecorationType(decorationDescription, commandArgumentHintDecorationType, {})); + + this.update(); + this._register(this.widget.onDidChangeParsedInput(() => this.update())); + this._register(this.widget.inputEditor.onDidChangeModelContent(() => this.update())); + } + + private update(): void { + this._ensureSubscribedToReferences(); + const decoration = this.getArgumentHintDecoration(); + this.widget.inputEditor.setDecorationsByType(decorationDescription, commandArgumentHintDecorationType, decoration ? [decoration] : []); + } + + private _ensureSubscribedToReferences(): void { + if (this._referencesListener.value) { + return; + } + const dynamicVariableModel = this.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); + if (dynamicVariableModel) { + this._referencesListener.value = dynamicVariableModel.onDidChangeReferences(() => this.update()); + } + } + + private getArgumentHintDecoration(): IDecorationOptions | undefined { + const model = this.widget.inputEditor.getModel(); + if (!model) { + return undefined; + } + + const dynamicVariableModel = this.widget.getContrib<ChatDynamicVariableModel>(ChatDynamicVariableModel.ID); + if (!dynamicVariableModel) { + return undefined; + } + + // Find an agent-host command reference that carries an argument hint. + for (const ref of dynamicVariableModel.variables) { + if (getAgentHostCompletionReferenceKindFromValue(ref.data) !== AgentHostCompletionReferenceKind.Command) { + continue; + } + const argumentHint = getCommandArgumentHint(ref._meta); + if (!argumentHint) { + continue; + } + + // Only show the hint while the command is the sole content followed by exactly + // one trailing space (i.e. no argument has been typed yet). + if (!this.isCommandOnlyContent(model, ref.range)) { + return undefined; + } + + return { + range: getRangeForPlaceholder(ref.range), + renderOptions: { + after: { + contentText: argumentHint, + color: getInputPlaceholderColor(this.themeService), + } + } + }; + } + + return undefined; + } + + private isCommandOnlyContent(model: ITextModel, range: IRange): boolean { + // Nothing meaningful before the command reference. + const beforeRange = new Range(1, 1, range.startLineNumber, range.startColumn); + if (model.getValueInRange(beforeRange).trim().length > 0) { + return false; + } + + // Exactly one space after the command reference and nothing else. + const fullRange = model.getFullModelRange(); + const afterRange = new Range(range.endLineNumber, range.endColumn, fullRange.endLineNumber, fullRange.endColumn); + return model.getValueInRange(afterRange) === ' '; + } +} + +ChatWidget.CONTRIBS.push(InputEditorCommandArgumentHint); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 1876f9632dd2e3..be46be5b0998f1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -993,7 +993,7 @@ class BuiltinDynamicCompletions extends Disposable { // User has typed #session: — fetch all sessions and show them inline const allSessions: { title: string; sessionResource: URI; lastMessageDate: number; icon: ThemeIcon }[] = []; - const sessionProviderFilter = [AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Claude]; + const sessionProviderFilter = [AgentSessionProviders.Local, AgentSessionProviders.Background, AgentSessionProviders.Claude, AgentSessionProviders.AgentHostCopilot]; for await (const group of this.chatSessionsService.getChatSessionItems(sessionProviderFilter, token)) { if (token.isCancellationRequested) { return; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts index cc4f48e683cf3d..7e5d4178ebb28c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputEditorContrib.ts @@ -15,8 +15,8 @@ import { Range } from '../../../../../../../editor/common/core/range.js'; import { IDecorationOptions } from '../../../../../../../editor/common/editorCommon.js'; import { TrackedRangeStickiness } from '../../../../../../../editor/common/model.js'; import { ILabelService } from '../../../../../../../platform/label/common/label.js'; -import { inputPlaceholderForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +import { getInputPlaceholderColor, getRangeForPlaceholder } from './chatInputPlaceholderDecoration.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../../../../common/participants/chatAgents.js'; import { localize } from '../../../../../../../nls.js'; import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../common/widget/chatColors.js'; @@ -58,15 +58,6 @@ function exactlyOneSpaceAfterPart(parsedRequest: readonly IParsedChatRequestPart return nextPart && nextPart instanceof ChatRequestTextPart && nextPart.text === ' '; } -function getRangeForPlaceholder(part: IParsedChatRequestPart) { - return { - startLineNumber: part.editorRange.startLineNumber, - endLineNumber: part.editorRange.endLineNumber, - startColumn: part.editorRange.endColumn + 1, - endColumn: 1000 - }; -} - class InputEditorDecorations extends Disposable { private static readonly UPDATE_DELAY = 200; @@ -193,9 +184,7 @@ class InputEditorDecorations extends Disposable { } private getPlaceholderColor(): string | undefined { - const theme = this.themeService.getColorTheme(); - const transparentForeground = theme.getColor(inputPlaceholderForeground); - return transparentForeground?.toString(); + return getInputPlaceholderColor(this.themeService); } private triggerInputEditorDecorationsUpdate(): void { @@ -261,7 +250,7 @@ class InputEditorDecorations extends Disposable { const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentPart.agent.metadata.followupPlaceholder; if (agentPart.agent.description && exactlyOneSpaceAfterPart(parsedRequest, agentPart)) { placeholderDecoration = [{ - range: getRangeForPlaceholder(agentPart), + range: getRangeForPlaceholder(agentPart.editorRange), renderOptions: { after: { contentText: shouldRenderFollowupPlaceholder ? agentPart.agent.metadata.followupPlaceholder : agentPart.agent.description, @@ -279,7 +268,7 @@ class InputEditorDecorations extends Disposable { const shouldRenderFollowupPlaceholder = isFollowupSlashCommand && agentSubcommandPart.command.followupPlaceholder; if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(parsedRequest, agentSubcommandPart)) { placeholderDecoration = [{ - range: getRangeForPlaceholder(agentSubcommandPart), + range: getRangeForPlaceholder(agentSubcommandPart.editorRange), renderOptions: { after: { contentText: shouldRenderFollowupPlaceholder ? agentSubcommandPart.command.followupPlaceholder : agentSubcommandPart.command.description, @@ -295,7 +284,7 @@ class InputEditorDecorations extends Disposable { // Agent subcommand with no other text - show the placeholder if (agentSubcommandPart?.command.description && exactlyOneSpaceAfterPart(parsedRequest, agentSubcommandPart)) { placeholderDecoration = [{ - range: getRangeForPlaceholder(agentSubcommandPart), + range: getRangeForPlaceholder(agentSubcommandPart.editorRange), renderOptions: { after: { contentText: agentSubcommandPart.command.description, @@ -333,10 +322,10 @@ class InputEditorDecorations extends Disposable { if (slashPromptPart && promptSlashCommand) { const onlyPromptCommandAndWhitespace = slashPromptPart && parsedRequest.every(isWhitespaceOrPromptPart); if (onlyPromptCommandAndWhitespace && exactlyOneSpaceAfterPart(parsedRequest, slashPromptPart) && promptSlashCommand) { - const description = promptSlashCommand.argumentHint ?? promptSlashCommand.description; + const description = promptSlashCommand.argumentHint; if (description) { this.widget.inputEditor.setDecorationsByType(decorationDescription, placeholderDecorationType, [{ - range: getRangeForPlaceholder(slashPromptPart), + range: getRangeForPlaceholder(slashPromptPart.editorRange), renderOptions: { after: { contentText: description, diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputPlaceholderDecoration.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputPlaceholderDecoration.ts new file mode 100644 index 00000000000000..8441b48fcf3bf9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputPlaceholderDecoration.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IRange } from '../../../../../../../editor/common/core/range.js'; +import { inputPlaceholderForeground } from '../../../../../../../platform/theme/common/colorRegistry.js'; +import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; + +/** + * Computes the editor range used to render inline placeholder (ghost text) + * after a parsed part, spanning from just after the part to the end of the line. + */ +export function getRangeForPlaceholder(editorRange: IRange): IRange { + return { + startLineNumber: editorRange.startLineNumber, + endLineNumber: editorRange.endLineNumber, + startColumn: editorRange.endColumn + 1, + endColumn: 1000 + }; +} + +/** + * Resolves the color used for inline placeholder / ghost text in the chat input. + */ +export function getInputPlaceholderColor(themeService: IThemeService): string | undefined { + const theme = themeService.getColorTheme(); + const transparentForeground = theme.getColor(inputPlaceholderForeground); + return transparentForeground?.toString(); +} 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 d67f8055704807..a20bd6748bb53e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -40,6 +40,8 @@ export interface IModePickerDelegate { readonly currentMode: IObservable<IChatMode>; readonly currentChatModes: IObservable<IChatModes>; readonly sessionResource: () => URI | undefined; + /** Direct mode-change callback for hosts without a registered IChatWidget (bypasses ToggleAgentModeActionId). */ + readonly setMode?: (mode: IChatMode) => void; /** * When set, the mode picker will show custom agents whose target matches this value. * Custom agents without a target are always shown in all session types. If no agents match the target, shows a default "Agent" option. @@ -136,6 +138,20 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { if (isDisabledViaPolicy) { return; // Block interaction if disabled by policy } + // Session-less hosts (e.g. the automations dialog) provide + // `setMode` and a `sessionResource` that returns undefined. + // Skip the command path because it requires a registered + // `IChatWidget`. Route the change to the host directly so the + // input's mode observable is actually updated. Real chat + // widgets always have a session URI. They always take the + // command path (telemetry, confirmation, new-chat-on-clear). + if (this.delegate.setMode && !this.delegate.sessionResource()) { + this.delegate.setMode(mode); + if (this.element) { + this.renderLabel(this.element); + } + return; + } const result = await commandService.executeCommand( ToggleAgentModeActionId, { modeId: mode.id, sessionResource: this.delegate.sessionResource() } satisfies IToggleChatModeArgs @@ -302,7 +318,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } } -function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { +export function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { if (mode.isBuiltin) { return true; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts index e8bfddcf79c289..709ff6ad0a33f0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelPickerActionItem.ts @@ -47,6 +47,7 @@ export interface IModelPickerDelegate { showManageModelsAction(): boolean; showUnavailableFeatured(): boolean; showFeatured(): boolean; + useGenericModelIcon?(): boolean; /** * Whether the synthetic "Auto" model is available for the current session, * so it can fall back to Auto. Defaults to `true` when omitted. When this @@ -62,6 +63,14 @@ export interface IModelPickerDelegate { * Returns `undefined` when no session is active. */ getChatSessionId?(): string | undefined; + /** + * UI hint flag controlling whether the picker shows the cache-break hint. + * Returns `true` when the session has likely warmed the prompt cache (e.g. it + * has sent a request), inferred from request history / session status rather + * than the provider's actual cache state — so it does not account for cache + * expiry or a cache that was already reset. Defaults to `false` when omitted. + */ + isCacheWarm?(): boolean; /** * Per-editor model configuration access. When omitted, the picker reads and * writes configuration through the global {@link ILanguageModelsService}. diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modelProviderIcons.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modelProviderIcons.ts new file mode 100644 index 00000000000000..3238ebcfe37426 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modelProviderIcons.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { localize } from '../../../../../../nls.js'; +import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; +import { ILanguageModelChatMetadataAndIdentifier, isAutoLanguageModel } from '../../../common/languageModels.js'; + +const copilotModelProviderIcon = registerIcon('chat-model-provider-copilot', Codicon.copilotCompact, localize('chatModelProviderCopilotIcon', "Icon for Copilot models.")); +const openAIModelProviderIcon = registerIcon('chat-model-provider-openai', Codicon.openai, localize('chatModelProviderOpenAIIcon', "Icon for OpenAI models.")); +const claudeModelProviderIcon = registerIcon('chat-model-provider-claude', Codicon.claude, localize('chatModelProviderClaudeIcon', "Icon for Claude models.")); +const geminiModelProviderIcon = registerIcon('chat-model-provider-gemini', Codicon.sparkle, localize('chatModelProviderGeminiIcon', "Icon for Gemini models.")); +const genericModelProviderIcon = registerIcon('chat-model-provider-generic', Codicon.sparkle, localize('chatModelProviderGenericIcon', "Icon for other model providers.")); + +export function getModelProviderIcon(model: ILanguageModelChatMetadataAndIdentifier, useGenericIcon = false): ThemeIcon { + if (isAutoLanguageModel(model)) { + return copilotModelProviderIcon; + } + if (useGenericIcon) { + return genericModelProviderIcon; + } + const identity = `${model.metadata.vendor} ${model.metadata.family} ${model.metadata.id} ${model.metadata.name}`.toLowerCase(); + if (identity.includes('claude') || identity.includes('anthropic')) { + return claudeModelProviderIcon; + } + if (identity.includes('gemini') || identity.includes('google')) { + return geminiModelProviderIcon; + } + if (identity.includes('openai') || identity.includes('gpt') || identity.includes('codex') || /\bo[134]\b/.test(identity)) { + return openAIModelProviderIcon; + } + if (identity.includes('copilot')) { + return copilotModelProviderIcon; + } + return genericModelProviderIcon; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts index f5b3ad47c46ef8..182ea1fdebb0bd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/permissionPickerActionItem.ts @@ -28,7 +28,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { URI } from '../../../../../../base/common/uri.js'; import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { maybeConfirmElevatedPermissionLevel } from '../../../common/chatPermissionWarnings.js'; -import { AgentSandboxEnabledValue, AgentSandboxSettingId, isAgentSandboxEnabledValue, type AgentSandboxEnabledSettingValue } from '../../../../../../platform/sandbox/common/settings.js'; +import { AgentSandboxEnabledSettingValue, AgentSandboxEnabledValue, AgentSandboxSettingId, isAgentSandboxEnabledValue } from '../../../../../../platform/sandbox/common/settings.js'; export interface IExtensionPermissionState { /** Stable identifier for the contributing chat session type, used to namespace action ids. */ @@ -188,9 +188,9 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { const policyRestricted = isAutoApprovePolicyRestricted(); const sandboxToggleEnabled = this.isSandboxToggleAvailable(); const setSandboxEnabled = async (enableSandbox: boolean) => { + const target: AgentSandboxEnabledValue = enableSandbox ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; if (this.isSandboxingEnabled() !== enableSandbox) { - const value = enableSandbox ? AgentSandboxEnabledValue.On : AgentSandboxEnabledValue.Off; - await configurationService.updateValue(getSandboxEnabledSettingId(), value); + await configurationService.updateValue(getSandboxEnabledSettingId(), target); } }; const levels = delegate.availableLevels ?? DEFAULT_PERMISSION_LEVELS; @@ -270,7 +270,7 @@ export class PermissionPickerActionItem extends ChatInputPickerActionViewItem { } private isSandboxingEnabled(): boolean { - const value = this.configurationService.getValue<AgentSandboxEnabledSettingValue | undefined>(getSandboxEnabledSettingId()); + const value = this.configurationService.getValue<AgentSandboxEnabledSettingValue>(getSandboxEnabledSettingId()); return isAgentSandboxEnabledValue(value); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index a7fb19353c85c0..9e28f66b2c61c1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -19,13 +19,15 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { IsSessionsWindowContext } from '../../../../../common/contextkeys.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { AgentSessionProviders, AgentSessionTarget, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { getSessionTypeAvailability, getSessionTypeUnavailableDescription, getSessionTypeUnavailableHover, SessionTypeAvailability } from '../../agentSessions/sessionTypeAvailability.js'; -import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType } from '../../../common/constants.js'; +import { ChatConfiguration, getDefaultNewChatSessionType, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../../common/constants.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; @@ -47,6 +49,7 @@ const otherCategory = { label: localize('chat.sessionTarget.category.other', "Ot */ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { private _sessionTypeItems: ISessionTypeItem[] = []; + protected readonly _isSessionsWindow: boolean; constructor( action: MenuItemAction, @@ -63,6 +66,7 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { @IChatEntitlementService protected readonly chatEntitlementService: IChatEntitlementService, @ILanguageModelsService protected readonly languageModelsService: ILanguageModelsService, @IConfigurationService protected readonly configurationService: IConfigurationService, + @IStorageService protected readonly storageService: IStorageService, ) { const actionProvider: IActionWidgetDropdownActionProvider = { @@ -109,6 +113,8 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { super(action, sessionTargetPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); + this._isSessionsWindow = IsSessionsWindowContext.getValue(contextKeyService) === true; + this._register(this.chatSessionsService.onDidChangeAvailability(() => { this._updateAgentSessionItems(); })); @@ -128,6 +134,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { } protected _run(sessionTypeItem: ISessionTypeItem): void { + if (!this._isSessionsWindow) { + recordUserSelectedSessionType(this.storageService, this.configurationService, this.chatSessionsService, sessionTypeItem.type); + } + if (this.delegate.setActiveSessionProvider) { // Use provided setter (for welcome view) this.delegate.setActiveSessionProvider(sessionTypeItem.type); @@ -224,11 +234,19 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { * when the selected provider is registered. */ protected _getDefaultSessionType(): AgentSessionTarget { - return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService) as AgentSessionTarget; + return getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService) as AgentSessionTarget; } protected _isVisible(type: AgentSessionTarget): boolean { - return isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService); + // Hide the Extension Host Copilot CLI in the editor picker when configured. + const hideEhCopilotCli = this.configurationService.getValue<boolean>(ChatConfiguration.CopilotCliHideExtensionHostEditor) ?? false; + if (hideEhCopilotCli && type === AgentSessionProviders.Background) { + return false; + } + + // Defer to the delegate when it has an opinion on visibility. Otherwise + // fall back to the shared editor-visibility check. + return this.delegate.isSessionTypeVisible?.(type) ?? isVisibleEditorChatSessionType(type, this.configurationService, this.chatSessionsService); } protected _isSessionTypeEnabled(type: AgentSessionTarget): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts new file mode 100644 index 00000000000000..d83ebb8b2255fc --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/workspacePickerInputActionItem.ts @@ -0,0 +1,45 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { BaseActionViewItem, IBaseActionViewItemOptions } from '../../../../../../base/browser/ui/actionbar/actionViewItems.js'; +import { MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { MenuItemAction } from '../../../../../../platform/actions/common/actions.js'; +import { IChatInputWorkspacePicker } from '../../chat.js'; + +/** + * Toolbar chip that delegates rendering to an externally-owned + * {@link IChatInputWorkspacePicker}. The picker owns selection state and + * popup behavior; this view item is a thin lifecycle wrapper so the trigger + * participates in `MenuWorkbenchToolBar` responsive overflow, refresh, and + * disposal. + * + * Used by the automations dialog: its single `AutomationsWorkspacePicker` + * instance renders one trigger into the form row and another into the chat + * input's primary toolbar via this view item. + */ +export class WorkspacePickerInputActionItem extends BaseActionViewItem { + + private readonly _triggerDisposable = this._register(new MutableDisposable()); + + constructor( + action: MenuItemAction, + private readonly picker: IChatInputWorkspacePicker, + options?: IBaseActionViewItemOptions, + ) { + super(undefined, action, options); + } + + override render(container: HTMLElement): void { + super.render(container); + // Add `chat-input-picker-item` so the container picks up the standard + // chat input toolbar chip layout (height, padding, dividers) used by + // the Mode and Model picker neighbors. Visual sizing of the inner + // `.sessions-chat-picker-slot` label/icon is overridden in + // `aiCustomizationManagement.css`. The default sessions-layer + // styling targets a 18px welcome-flow chip, which is too big here. + container.classList.add('chat-input-picker-item'); + this._triggerDisposable.value = this.picker.renderTrigger(container); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index d20cbb313d6286..b546ea32caa0ac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -696,6 +696,17 @@ margin: 4px 0; } +/* Codeblocks only carry a bottom margin (see codeBlockPart.css) and rely on the previous element's bottom +margin for the space above them. Inside lists that margin is collapsed away to keep items tight, so a codeblock +following list content ends up with no space above it. Add a matching top margin in just those cases. */ +.interactive-item-container .value .rendered-markdown { + ul + [data-code], + ol + [data-code], + li > [data-code]:not(:first-child) { + margin-top: 16px; + } +} + /* NOTE- We want to dedent codeblocks in lists specifically to give them the full width. No more elegant way to do this, these values have to be updated for changes to the rules above, or to support more deeply nested lists. */ .interactive-item-container .value .rendered-markdown ul .interactive-result-code-block { @@ -1080,6 +1091,11 @@ have to be updated for changes to the rules above, or to support more deeply nes transform: translateX(-0.5px); } +.interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item > .action-label.codicon-newline::before { + display: inline-block; + transform: translateY(0.5px); +} + /* Focus indicator drawn on the action-item wrapper so it sits cleanly around the button surface with a small offset. */ .interactive-session .chat-input-toolbars > .chat-execute-toolbar .monaco-action-bar .action-item:not(.disabled):has(> .action-label.codicon-arrow-up:focus-visible) { @@ -1641,15 +1657,14 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars { display: flex; align-items: center; - gap: 6px; + gap: 2px; margin-top: 4px; } -/* Secondary toolbar below the input box */ .interactive-session .chat-secondary-toolbar { display: flex; align-items: center; - gap: 6px; + gap: 2px; padding: 0 4px; } @@ -1657,35 +1672,11 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } -/* Dividers between adjacent items in the secondary toolbar. */ -.interactive-session .chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item.chat-input-picker-item { - overflow: visible; -} - -.interactive-session .chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item { - position: relative; -} - -.interactive-session .chat-secondary-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item::before { - content: ''; - position: absolute; - left: -5px; - top: 25%; - bottom: 25%; - width: 1px; - background-color: var(--vscode-editorWidget-border); - pointer-events: none; -} - -.interactive-session .chat-secondary-toolbar .chat-sessionPicker-container > .chat-sessionPicker-item + .chat-sessionPicker-item { - box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); -} - .interactive-session .chat-secondary-toolbar .chat-secondary-generic-chips { display: flex; align-items: center; flex-shrink: 0; - gap: 6px; + gap: 2px; } .interactive-session .chat-secondary-toolbar .chat-secondary-input-toolbar { @@ -1696,6 +1687,9 @@ have to be updated for changes to the rules above, or to support more deeply nes .monaco-action-bar .action-item .codicon { color: var(--vscode-icon-foreground); + font-size: var(--vscode-codiconFontSize-compact); + width: auto; + height: auto; } .chat-input-picker-item { @@ -1713,7 +1707,7 @@ have to be updated for changes to the rules above, or to support more deeply nes } span + .chat-input-picker-label { - margin-left: 2px; + margin-left: 6px; } .codicon { @@ -1854,9 +1848,8 @@ have to be updated for changes to the rules above, or to support more deeply nes overflow: visible; } -.chat-input-picker-item .action-label.model-picker-split { - padding: 0 6px; - gap: 2px; +.interactive-session .chat-input-toolbar .chat-input-picker-item .action-label.model-picker-split { + padding: 0; overflow: visible; height: auto; border-radius: 0; @@ -1864,30 +1857,6 @@ have to be updated for changes to the rules above, or to support more deeply nes cursor: default; } -/* Dividers between adjacent items in the workbench chat input toolbar. */ -.interactive-session .chat-input-toolbar .monaco-action-bar .actions-container > .action-item.chat-input-picker-item { - overflow: visible; -} - -.interactive-session .chat-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item { - position: relative; -} - -.interactive-session .chat-input-toolbar .monaco-action-bar .actions-container > .action-item + .action-item::before { - content: ''; - position: absolute; - left: -2px; - top: 25%; - bottom: 25%; - width: 1px; - background-color: var(--vscode-editorWidget-border); - pointer-events: none; -} - -.interactive-session .chat-input-toolbar .chat-sessionPicker-container > .chat-sessionPicker-item + .chat-sessionPicker-item { - box-shadow: -5px 0 0 -4px var(--vscode-editorWidget-border); -} - .chat-input-picker-item .action-label.model-picker-split .model-picker-section { display: flex; align-items: center; @@ -1906,12 +1875,14 @@ have to be updated for changes to the rules above, or to support more deeply nes background-color: transparent !important; } -.chat-input-picker-item .action-label.model-picker-split .model-picker-section:hover { - background-color: var(--vscode-toolbar-hoverBackground); +.chat-input-picker-item .action-label.model-picker-split:not(.disabled):has(.model-picker-section:hover) .model-picker-section { + color: var(--vscode-foreground); } +.chat-input-picker-item .action-label.model-picker-split .model-picker-section:hover, .chat-input-picker-item .action-label.model-picker-split .model-picker-section[aria-expanded="true"] { background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); } .chat-input-picker-item .action-label.model-picker-split .model-picker-name { @@ -2006,11 +1977,7 @@ have to be updated for changes to the rules above, or to support more deeply nes .interactive-session .chat-input-toolbars .monaco-action-bar .actions-container, .interactive-session .chat-secondary-toolbar .monaco-action-bar .actions-container { display: flex; - gap: 4px; -} - -.interactive-session .chat-secondary-input-toolbar .monaco-action-bar .actions-container { - gap: 10px; + gap: 2px; } .interactive-session .chat-input-toolbars .chat-sessionPicker-container, @@ -2020,11 +1987,6 @@ have to be updated for changes to the rules above, or to support more deeply nes max-width: 100%; } -.interactive-session .chat-input-toolbar .chat-sessionPicker-container, -.interactive-session .chat-secondary-toolbar .chat-sessionPicker-container { - gap: 10px; -} - .monaco-workbench .interactive-session .chat-input-toolbars .monaco-action-bar .action-item .codicon, .monaco-workbench .interactive-session .chat-input-toolbars .action-label .codicon { color: var(--vscode-icon-foreground) !important; @@ -2057,6 +2019,45 @@ have to be updated for changes to the rules above, or to support more deeply nes margin-bottom: 4px; } +.style-override.monaco-enable-motion .action-widget:has(.chat-input-picker-dropdown) { + animation: chat-input-picker-dropdown-open 250ms cubic-bezier(0.22, 1, 0.36, 1) both; + transform-origin: bottom left; + will-change: transform, opacity; +} + +.style-override.monaco-enable-motion .context-view.bottom .action-widget:has(.chat-input-picker-dropdown) { + transform-origin: top left; +} + +.style-override.monaco-enable-motion .action-widget.chat-input-picker-dropdown-closing:has(.chat-input-picker-dropdown) { + animation: chat-input-picker-dropdown-close 150ms cubic-bezier(0.22, 1, 0.36, 1) both; + pointer-events: none; +} + +@keyframes chat-input-picker-dropdown-open { + 0% { + opacity: 0; + transform: scale(0.97); + } + + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes chat-input-picker-dropdown-close { + 0% { + opacity: 1; + transform: scale(1); + } + + 100% { + opacity: 0; + transform: scale(0.99); + } +} + .action-widget .monaco-list-row.chat-model-picker-section-toggle.has-toolbar .action-list-item-toolbar { display: flex; } @@ -2199,7 +2200,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .chat-attached-context .chat-attached-context-attachment.show-file-icons.warning, -.chat-attached-context .chat-attached-context-attachment.show-file-icons.partial-warning { +.chat-attached-context .chat-attached-context-attachment.show-file-icons.partial-warning, +.chat-attached-context .chat-attached-context-attachment.show-file-icons.auto-image-warning { border-color: var(--vscode-notificationsWarningIcon-foreground); } @@ -2890,7 +2892,7 @@ have to be updated for changes to the rules above, or to support more deeply nes min-height: 20px; max-width: 100%; width: fit-content; - padding: var(--vscode-spacing-size20); + padding: 0; display: flex; gap: var(--vscode-spacing-size40); align-items: center; @@ -2983,6 +2985,102 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +/* Turn changes summary: reuses the compact checkpoint summary styling and adds an + inline "preview <file>" action shown when the turn produced a previewable file. */ +.interactive-session .chat-turn-pills-part .chat-turn-preview { + display: flex; + align-items: center; + flex: none; + gap: var(--vscode-spacing-size40); + min-width: 0; + overflow: hidden; +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview.hidden, +.interactive-session .chat-turn-pills-part > .checkpoint-file-changes-summary > .checkpoint-file-changes-disclosure > .checkpoint-file-changes-summary-header > .hidden { + display: none; +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-separator { + flex: none; + width: var(--vscode-strokeThickness); + align-self: stretch; + margin: var(--vscode-spacing-size20) 0; + background-color: var(--vscode-chat-requestBorder); +} + +/* In preview-only mode the changes summary (and its label) is hidden, leaving the + preview action as the only header content — drop its leading separator so there + is no divider with nothing before it. */ +.interactive-session .chat-turn-pills-part .chat-file-changes-label.hidden ~ .chat-turn-preview .chat-turn-preview-separator { + display: none; +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-action { + display: inline-flex; + align-items: center; + gap: var(--vscode-spacing-size40); + min-width: 0; + max-width: 200px; + padding: var(--vscode-spacing-sizeNone) var(--vscode-spacing-size40); + border: none; + border-radius: var(--vscode-cornerRadius-small); + color: inherit; + background: transparent; + font: inherit; + cursor: pointer; + overflow: hidden; +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-action:hover { + color: var(--vscode-foreground); + background-color: var(--vscode-toolbar-hoverBackground); +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-action:focus-visible { + outline: var(--vscode-strokeThickness) solid var(--vscode-focusBorder); + outline-offset: calc(-1 * var(--vscode-strokeThickness)); +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-verb { + flex: none; +} + +.interactive-session .chat-turn-pills-part .chat-turn-preview-action .monaco-icon-label { + min-width: 0; + align-items: center; + overflow: hidden; +} + +/* Per-row action bar in the changed-files list (e.g. the "Preview" action). The + row becomes a flex row so the file label fills the remaining width and the + actions hug the right edge. Scoped via the class the renderer adds only when a + row-action provider is supplied. */ +.interactive-session .chat-summary-list .monaco-list-row.chat-summary-list-row-with-actions, +.interactive-session .chat-summary-list-row-with-actions { + display: flex; + align-items: center; +} + +.interactive-session .chat-summary-list-row-with-actions > .monaco-icon-label { + flex: 1 1 auto; + min-width: 0; +} + +.interactive-session .chat-summary-list-row-with-actions > .chat-summary-list-actions { + flex: 0 0 auto; + padding-right: var(--vscode-spacing-size40); +} + +.interactive-session .chat-summary-list-actions .action-label { + color: var(--vscode-textLink-foreground); + cursor: pointer; +} + +.interactive-session .chat-summary-list-actions .action-label:hover { + color: var(--vscode-textLink-activeForeground); +} + .interactive-session .chat-used-context { display: flex; flex-direction: column; @@ -3045,7 +3143,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .chat-file-changes-label { - color: var(--vscode-interactive-session-foreground); user-select: none; } @@ -3762,6 +3859,12 @@ have to be updated for changes to the rules above, or to support more deeply nes justify-content: center; } + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment.warning, + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment.partial-warning, + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment.auto-image-warning { + border-color: var(--vscode-notificationsWarningIcon-foreground); + } + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment .chat-attached-context-pill { width: 100%; height: 100%; @@ -3769,6 +3872,15 @@ have to be updated for changes to the rules above, or to support more deeply nes margin: 0; } + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment.warning .chat-attached-context-pill { + justify-content: center; + } + + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment.warning .chat-attached-context-pill .codicon-warning { + margin: 0; + font-size: var(--vscode-codiconFontSize); + } + .interactive-item-container.interactive-request .value .chat-request-attachment-cards .chat-attached-context-attachment.image-attachment .chat-attached-context-pill-image { width: 100%; height: 100%; @@ -4178,6 +4290,10 @@ have to be updated for changes to the rules above, or to support more deeply nes width: 100%; } +.interactive-session .interactive-item-container.interactive-request.editing > .value > .chat-attached-context { + padding: 0px 16px; +} + .chat-row-disabled-overlay, .interactive-item-container .chat-edit-input-container .chat-editing-session, .chat-input-overlay { @@ -4388,6 +4504,25 @@ have to be updated for changes to the rules above, or to support more deeply nes /* max-width: 250px; */ } +.chat-model-hover-warning-text { + display: flex; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + border: var(--vscode-strokeThickness) solid var(--vscode-editorWidget-border, var(--vscode-contrastBorder, transparent)); + border-radius: 4px; + padding: 6px 8px; +} + +.chat-model-hover-warning-text > .codicon { + flex-shrink: 0; + color: var(--vscode-notificationsWarningIcon-foreground); +} + +.chat-model-hover-warning-text p { + margin: 0; +} + .chat-model-hover.has-long-context { max-width: 300px; } @@ -4397,6 +4532,16 @@ have to be updated for changes to the rules above, or to support more deeply nes font-size: 14px; } +.chat-model-hover-description { + font-size: 12px; + max-width: 300px; + color: var(--vscode-descriptionForeground); +} + +.chat-model-hover-description p { + margin: 0; +} + .chat-model-hover-title-row { display: flex; align-items: center; @@ -4618,6 +4763,21 @@ have to be updated for changes to the rules above, or to support more deeply nes color: var(--vscode-charts-green, #3fb950) !important; } +/* + * Voice mode connecting indicator: only spin the spinner glyph, not the whole + * button. Otherwise the action-label (including its hover background) rotates + * along with the icon. + */ +.chat-input-container .chat-input-toolbars .action-label.codicon-loading { + animation: none; +} + +.chat-input-container .chat-input-toolbars .action-label.codicon-loading::before { + display: inline-block; + transform-origin: center center; + animation: codicon-spin 1.5s steps(30) infinite; +} + .monaco-reduce-motion .action-item.chat-restore-checkpoint-item.confirming .action-label:first-child { animation: none; background: none; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts index 1db1afbfef1dae..2ceef3dbe8d628 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditor.ts @@ -42,6 +42,13 @@ export interface IChatEditorOptions extends IEditorOptions { * https://github.com/microsoft/vscode/pull/278476 as input state is stored on the model. */ modelInputState?: IChatModelInputState; + /** + * Session type explicitly selected by the user when opening a new chat editor. + * Non-local session types are already encoded in the editor resource, so this + * currently preserves an explicit local selection when default/last-used + * provider resolution would otherwise apply. + */ + explicitSessionType?: string; target?: { data: IExportableChatData | ISerializableChatData }; title?: { preferred?: string; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts index f4fa2c80e2cb3c..34639ebf3cebf9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/editor/chatEditorInput.ts @@ -16,16 +16,17 @@ import * as nls from '../../../../../../nls.js'; import { ConfirmResult, IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { ILogService } from '../../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../../platform/storage/common/storage.js'; import { registerIcon } from '../../../../../../platform/theme/common/iconRegistry.js'; import { EditorInputCapabilities, IEditorIdentifier, IEditorSerializer, IUntypedEditorInput, Verbosity } from '../../../../../common/editor.js'; import { EditorInput, IEditorCloseHandler } from '../../../../../common/editor/editorInput.js'; import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatEditorTitleMaxLength, ChatLastUsedEditorSessionTypeStorageKey, getDefaultNewChatSessionResource, getDefaultNewChatSessionType, getNewChatEditorSessionResource } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatEditorTitleMaxLength, getComputedDefaultSessionResource, getComputedDefaultSessionType, getDefaultNewChatSessionResource } from '../../../common/constants.js'; import { IChatEditingSession, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel } from '../../../common/model/chatModel.js'; -import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; +import { LocalChatSessionUri, getChatSessionType, isUntitledChatSession } from '../../../common/model/chatUri.js'; import { IClearEditingSessionConfirmationOptions } from '../../actions/chatActions.js'; import type { IChatEditorOptions } from './chatEditor.js'; @@ -67,6 +68,7 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IStorageService private readonly storageService: IStorageService, + @ILogService private readonly logService: ILogService, ) { super(); @@ -218,13 +220,32 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler const inputType = chatSessionType ?? this.resource.authority; if (this._sessionResource) { - this.modelRef.value = await this.chatService.acquireOrLoadSession(this._sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolve'); + try { + this.modelRef.value = await this.chatService.acquireOrLoadSession(this._sessionResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolve'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire session ${this._sessionResource.toString()}`, error); + } + + if (!this.model && isUntitledChatSession(this._sessionResource) && getChatSessionType(this._sessionResource) !== localChatSessionType) { + this.logService.warn(`[ChatEditorInput] Falling back to a local chat session because ${this._sessionResource.toString()} could not be acquired`); + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitledFallback' }); + } if (this.shouldReplaceEmptyLocalSession(this._sessionResource)) { - const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService); + const defaultResource = getComputedDefaultSessionResource(this.configurationService, this.chatSessionsService); if (getChatSessionType(defaultResource) !== localChatSessionType) { - this._sessionResource = defaultResource; - this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultSession'); + let modelRef: IChatModelReference | undefined; + try { + modelRef = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultSession'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire default session ${defaultResource.toString()}`, error); + } + if (modelRef) { + this._sessionResource = defaultResource; + this.modelRef.value = modelRef; + } else { + this.logService.warn(`[ChatEditorInput] Keeping local chat session because default session ${defaultResource.toString()} could not be acquired`); + } } } @@ -233,13 +254,25 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: true, debugOwner: 'ChatEditorInput#resolveNewLocalSession' }); } } else if (!this.options.target) { - const lastUsedSessionType = this.storageService.get(ChatLastUsedEditorSessionTypeStorageKey, StorageScope.PROFILE); - const defaultResource = getNewChatEditorSessionResource(this.configurationService, this.chatSessionsService, lastUsedSessionType); - if (getChatSessionType(defaultResource) === localChatSessionType) { - this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); + if (this.options.explicitSessionType === localChatSessionType) { + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveExplicitLocal' }); } else { - this._sessionResource = defaultResource; - this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultUntitled'); + const defaultResource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService); + if (getChatSessionType(defaultResource) === localChatSessionType) { + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitled' }); + } else { + try { + this.modelRef.value = await this.chatService.acquireOrLoadSession(defaultResource, ChatAgentLocation.Chat, CancellationToken.None, 'ChatEditorInput#resolveDefaultUntitled'); + } catch (error) { + this.logService.warn(`[ChatEditorInput] Failed to acquire default session ${defaultResource.toString()}`, error); + } + if (this.model) { + this._sessionResource = defaultResource; + } else { + this.logService.warn(`[ChatEditorInput] Falling back to a local chat session because ${defaultResource.toString()} could not be acquired`); + this.modelRef.value = this.chatService.startNewLocalSession(ChatAgentLocation.Chat, { canUseTools: !inputType, debugOwner: 'ChatEditorInput#resolveUntitledFallback' }); + } + } } } else if (this.options.target.data) { this.modelRef.value = this.chatService.loadSessionFromData(this.options.target.data, 'ChatEditorInput#resolveImportedData'); @@ -266,9 +299,10 @@ export class ChatEditorInput extends EditorInput implements IEditorCloseHandler private shouldReplaceEmptyLocalSession(sessionResource: URI): boolean { return LocalChatSessionUri.isLocalSession(sessionResource) + && this.options.explicitSessionType !== localChatSessionType && !!this.model && !this.model.hasRequests - && getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService) !== localChatSessionType; + && getComputedDefaultSessionType(this.configurationService, this.chatSessionsService) !== localChatSessionType; } /** diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 39da33f09f54fe..da3e3fda0d3455 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -7,6 +7,7 @@ import './media/chatContextUsageDetails.css'; import * as dom from '../../../../../../base/browser/dom.js'; import { toAction, type IAction } from '../../../../../../base/common/actions.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, type IObservable } from '../../../../../../base/common/observable.js'; import { localize } from '../../../../../../nls.js'; import { IMenuService, MenuId } from '../../../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -58,6 +59,7 @@ export class ChatContextUsageDetails extends Disposable { constructor( private _chatWidget: IChatWidget | undefined, + private readonly _dataObservable: IObservable<IChatContextUsageData | undefined>, @IInstantiationService private readonly instantiationService: IInstantiationService, @IMenuService private readonly menuService: IMenuService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @@ -128,6 +130,14 @@ export class ChatContextUsageDetails extends Disposable { }; this._register(menu.onDidChange(updateActions)); updateActions(); + + this._register(autorun(reader => { + const data = this._dataObservable.read(reader); + // Re-render when the usage data changes; keep the last-rendered DOM when data becomes undefined. + if (data) { + this._render(data); + } + })); } setChatWidget(widget: IChatWidget): void { @@ -152,7 +162,7 @@ export class ChatContextUsageDetails extends Disposable { }); } - update(data: IChatContextUsageData): void { + private _render(data: IChatContextUsageData): void { const { percentage, usedTokens, totalContextWindow, outputBufferPercentage, promptTokenDetails, sessionCost } = data; // Update session cost — hide section when no cost data is available diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 8617d4790f3e5e..38cf2c4ec83b3a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -10,7 +10,8 @@ import { IDelayedHoverOptions } from '../../../../../../base/browser/ui/hover/ho import { IStringDictionary } from '../../../../../../base/common/collections.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; -import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { IObservable, observableValue, observableValueOpts } from '../../../../../../base/common/observable.js'; +import { equals } from '../../../../../../base/common/arrays.js'; import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -56,6 +57,28 @@ export function resolveContextWindowInputTokens( ?? maxInputTokens; } +/** + * Equality comparer for {@link IChatContextUsageData} used to suppress redundant updates. + * + * @internal - exported for testing + */ +export function isSameContextUsageData(a: IChatContextUsageData | undefined, b: IChatContextUsageData | undefined): boolean { + if (a === b) { + return true; + } + if (!a || !b) { + return false; + } + return a.usedTokens === b.usedTokens + && a.completionTokens === b.completionTokens + && a.totalContextWindow === b.totalContextWindow + && a.percentage === b.percentage + && a.outputBufferPercentage === b.outputBufferPercentage + && a.sessionCost === b.sessionCost + && equals(a.promptTokenDetails, b.promptTokenDetails, (x, y) => + x.category === y.category && x.label === y.label && x.percentageOfPrompt === y.percentageOfPrompt); +} + /** * A reusable circular progress indicator that displays a ring. * The ring fills clockwise from the top based on the percentage value. @@ -143,7 +166,7 @@ export class ChatContextUsageWidget extends Disposable { private readonly _contextUsageDetails = this._register(new MutableDisposable<ChatContextUsageDetails>()); private _chatWidget: IChatWidget | undefined; - private currentData: IChatContextUsageData | undefined; + private readonly _currentData = observableValueOpts<IChatContextUsageData | undefined>({ owner: this, equalsFn: isSameContextUsageData }, undefined); private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; private static readonly _HOVER_ID = 'chat.contextUsage'; @@ -199,7 +222,7 @@ export class ChatContextUsageWidget extends Disposable { this._enabled = this.configurationService.getValue<boolean>(ChatConfiguration.ChatContextUsageEnabled) !== false; if (!this._enabled) { this.hide(); - } else if (this.currentData) { + } else if (this._currentData.get()) { this.show(); } } @@ -239,13 +262,13 @@ export class ChatContextUsageWidget extends Disposable { }; private _createDetails(): ChatContextUsageDetails | undefined { - if (!this._isVisible.get() || !this.currentData) { + if (!this._isVisible.get() || !this._currentData.get()) { return undefined; } if (!this._contextUsageDetails.value) { - this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails, this._chatWidget); + // Details subscribes to `_currentData` and re-renders reactively. + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails, this._chatWidget, this._currentData); } - this._contextUsageDetails.value.update(this.currentData); return this._contextUsageDetails.value; } @@ -294,14 +317,14 @@ export class ChatContextUsageWidget extends Disposable { if (!lastRequest) { // New/empty chat session clear everything - this.currentData = undefined; + this._currentData.set(undefined, undefined); this.hide(); return; } if (!lastRequest.response || !lastRequest.modelId) { // Pending request keep old data visible if available - if (!this.currentData) { + if (!this._currentData.get()) { this.hide(); } return; @@ -399,7 +422,7 @@ export class ChatContextUsageWidget extends Disposable { // context window of its own, so fall back to the model that actually served the request (see issue #321781). const contextWindow = this.resolveContextWindow(this._selectedModelId) ?? this.resolveContextWindow(effectiveModelId); if (!usage || !contextWindow) { - if (!this.currentData) { + if (!this._currentData.get()) { this.hide(); } return; @@ -432,7 +455,7 @@ export class ChatContextUsageWidget extends Disposable { } private render(data: IChatContextUsageData): void { - this.currentData = data; + this._currentData.set(data, undefined); // Pie chart shows actual usage percentage only this.progressIndicator.setProgress(data.percentage); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index 1018670abaccf5..c0396c11276614 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -50,7 +50,7 @@ import { CHAT_PROVIDER_ID } from '../../../common/participants/chatParticipantCo import { IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri, getChatSessionType } from '../../../common/model/chatUri.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, getComputedDefaultSessionType, getDefaultNewChatSessionResource, getDefaultNewChatSessionType } from '../../../common/constants.js'; import { AgentSessionsControl } from '../../agentSessions/agentSessionsControl.js'; import { ACTION_ID_NEW_CHAT } from '../../actions/chatActions.js'; import { ChatWidget } from '../../widget/chatWidget.js'; @@ -549,23 +549,50 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { })); this._register({ dispose: () => stopGlowAnimation() }); + // Voice transcript is per-session. The transcript is "owned" by the + // session the user is dictating into (the explicit target session, or the + // focused session when dictation began) and is only shown in that + // session's view. Switching focus to a different session hides the + // transcript here; switching while actively dictating also stops + // transcription so it isn't misrouted to the newly focused session. + let listeningSession: URI | undefined; + let ownerSession: URI | undefined; this._register(autorun(reader => { const turns = this.voiceSessionController.transcriptTurns.read(reader); const connected = this.voiceSessionController.isConnected.read(reader); const voiceState = this.voiceSessionController.voiceState.read(reader); + const targetSession = this.voiceSessionController.targetSession.read(reader); + const currentSession = this._currentSessionResource.read(reader); const showTranscript = this.configurationService.getValue<boolean>('agents.voice.showTranscript') !== false; const visible = turns.filter(t => t.text.length > 0 || (t.speaker === 'user' && t.isPartial)); if (!connected) { + listeningSession = undefined; + ownerSession = undefined; transcriptOverlayNode.style.display = 'none'; transcriptOverlayNode.classList.remove('has-transcript'); return; } - // Don't show transcript from a different session in this widget - const targetSession = this.voiceSessionController.targetSession.read(reader); - const currentSession = this._currentSessionResource.read(reader); - if (targetSession && currentSession && !isEqual(targetSession, currentSession)) { + // Capture / maintain the session the current transcript belongs to. + if (voiceState === 'listening') { + if (!listeningSession) { + listeningSession = targetSession ?? currentSession; + ownerSession = listeningSession; + } else if (!targetSession && currentSession && !isEqual(currentSession, listeningSession)) { + // User switched to a different session mid-dictation — stop + // transcription so it isn't sent to the newly focused session. + this.voiceSessionController.stopListening(); + listeningSession = undefined; + } + } else { + // Allow the next dictation to re-capture the owning session. + listeningSession = undefined; + } + + // Don't show a transcript that belongs to a different session here. + const effectiveOwner = targetSession ?? ownerSession; + if (effectiveOwner && currentSession && !isEqual(effectiveOwner, currentSession)) { transcriptOverlayNode.style.display = 'none'; transcriptOverlayNode.classList.remove('has-transcript'); return; @@ -573,8 +600,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Show hint when connected but no transcript yet if (visible.length === 0 || !showTranscript) { - const autoSendDelay = this.configurationService.getValue<number>('agents.voice.autoSendDelay') ?? 500; - if (voiceState === 'idle' && visible.length === 0 && showTranscript && autoSendDelay < 0) { + const handsFree = this.configurationService.getValue<boolean>('agents.voice.handsFree') !== false; + if (voiceState === 'idle' && visible.length === 0 && showTranscript && !handsFree) { transcriptOverlayNode.style.display = ''; transcriptOverlayNode.classList.remove('has-transcript'); transcriptOverlay.replaceChildren(); @@ -1028,11 +1055,11 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { * Returns `undefined` to fall back to `startNewLocalSession`. */ private async acquireDefaultNewSession(token: CancellationToken): Promise<IChatModelReference | undefined> { - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService); + const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService, this.storageService); if (defaultType === localChatSessionType) { return undefined; } - const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService); + const resource = getDefaultNewChatSessionResource(this.configurationService, this.chatSessionsService, this.storageService); try { return await this.chatService.acquireOrLoadSession(resource, ChatAgentLocation.Chat, token, 'ChatViewPane#acquireDefaultNewSession'); } catch (error) { @@ -1061,7 +1088,7 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { } private shouldSkipRestoredLocalSession(sessionResource: URI, model: IChatModel): boolean { - const defaultType = getDefaultNewChatSessionType(this.configurationService, this.chatSessionsService); + const defaultType = getComputedDefaultSessionType(this.configurationService, this.chatSessionsService); const prefersAgentHostCopilot = this.configurationService.getValue<boolean>(ChatConfiguration.EditorLocalAgentEnabled) === false && this.configurationService.getValue<string>(ChatConfiguration.EditorDefaultProvider) === 'copilotAh'; return (defaultType !== localChatSessionType || prefersAgentHostCopilot) @@ -1269,6 +1296,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private layoutingBody = false; private relayout(): void { + if (!this._widget?.visible) { + return; + } + if (this.lastDimensions) { this.layoutBody(this.lastDimensions.height, this.lastDimensions.width); } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index e5664d806f564b..764b21056f23af 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -65,6 +65,12 @@ export namespace ChatContextKeys { */ export const lockedToCodingAgent = new RawContextKey<boolean>('lockedToCodingAgent', false, { type: 'boolean', description: localize('lockedToCodingAgent', "True when the chat widget is locked to the coding agent session.") }); export const lockedCodingAgentId = new RawContextKey<string>('lockedCodingAgentId', '', { type: 'string', description: localize('lockedCodingAgentId', "The agent ID when the chat widget is locked to a coding agent session.") }); + /** + * Widget-scoped: true when the chat shown in this widget is read-only (non-interactive), + * e.g. an observable worker chat. Read-only chats hide the composer and do not offer + * mutating actions such as Start Over or Restore Checkpoint. + */ + export const readOnly = new RawContextKey<boolean>('chatIsReadonly', false, { type: 'boolean', description: localize('chatIsReadonly', "True when the chat shown in the widget is read-only (non-interactive).") }); /** * Widget-scoped: true when this chat widget is locked to an Agent Host-backed chat session. */ @@ -102,6 +108,7 @@ export namespace ChatContextKeys { export const location = new RawContextKey<ChatAgentLocation>('chatLocation', undefined); export const inQuickChat = new RawContextKey<boolean>('quickChatHasFocus', false, { type: 'boolean', description: localize('inQuickChat', "True when the quick chat UI has focus, false otherwise.") }); export const inAgentSessionsWelcome = new RawContextKey<boolean>('inAgentSessionsWelcome', false, { type: 'boolean', description: localize('inAgentSessionsWelcome', "True when the chat input is within the agent sessions welcome page.") }); + export const inAutomationsDialog = new RawContextKey<boolean>('inAutomationsDialog', false, { type: 'boolean', description: localize('inAutomationsDialog', "True when the chat input is within the automations dialog.") }); export const chatSessionType = new RawContextKey<string>('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); export const hasFileAttachments = new RawContextKey<boolean>('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey<boolean>('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); diff --git a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts index 1f1acfa27ab14f..7b013b579790e8 100644 --- a/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts +++ b/src/vs/workbench/contrib/chat/common/aiCustomizationWorkspaceService.ts @@ -8,7 +8,7 @@ import { IObservable } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; -import { IChatPromptSlashCommand, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IChatPromptSlashCommand } from './promptSyntax/service/promptsService.js'; export const IAICustomizationWorkspaceService = createDecorator<IAICustomizationWorkspaceService>('aiCustomizationWorkspaceService'); @@ -41,6 +41,7 @@ export const AICustomizationManagementSection = { Instructions: 'instructions', Prompts: 'prompts', Hooks: 'hooks', + Automations: 'automations', McpServers: 'mcpServers', Plugins: 'plugins', Models: 'models', @@ -53,40 +54,11 @@ export type AICustomizationManagementSection = typeof AICustomizationManagementS * Per-type filter policy controlling which storage sources are visible * for a given customization type. */ -export interface IStorageSourceFilter { - /** - * Which storage groups to display (e.g. workspace, user, extension, builtin). - */ - readonly sources: readonly AICustomizationSource[]; -} - -/** - * Controls which features are shown on the welcome page of the - * AI Customization Management Editor. - */ export interface IWelcomePageFeatures { /** Show the "Configure Your AI" getting-started banner. */ readonly showGettingStartedBanner: boolean; } -/** - * Applies a source filter to an array of items that have uri and source. - * Removes items whose source is not in the filter's source list. - */ -export function applySourceFilter<T extends { readonly uri: URI; readonly source: AICustomizationSource }>(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { - const sourceSet = new Set(filter.sources); - return items.filter(item => sourceSet.has(item.source)); -} - -/** - * Applies a storage filter to an array of items that have uri and storage. - * Removes items whose storage is not in the filter's source list. - */ -export function applyStorageSourceFilter<T extends { readonly uri: URI; readonly storage: PromptsStorage }>(items: readonly T[], filter: IStorageSourceFilter): readonly T[] { - const sourceSet = new Set(filter.sources); - return items.filter(item => sourceSet.has(item.storage)); -} - /** * Provides workspace context for AI Customization views. */ diff --git a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts index d36aad891f6767..4e11691615de6b 100644 --- a/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts +++ b/src/vs/workbench/contrib/chat/common/attachments/chatVariableEntries.ts @@ -111,19 +111,26 @@ export function toAgentHostCompletionVariableEntryFromMetadata(kind: AgentHostCo } export function getAgentHostCompletionReferenceKind(entry: IChatRequestVariableEntry): AgentHostCompletionReferenceKind | undefined { - if (entry.kind !== 'generic' || typeof entry.value !== 'object' || entry.value === null) { + if (entry.kind !== 'generic') { + return undefined; + } + return getAgentHostCompletionReferenceKindFromValue(entry.value); +} + +export function getAgentHostCompletionReferenceKindFromValue(value: IChatRequestVariableValue): AgentHostCompletionReferenceKind | undefined { + if (typeof value !== 'object' || value === null) { return undefined; } - const value = entry.value as Record<string, unknown>; - if (value.$mid !== 'agentHostCompletion') { + const record = value as Record<string, unknown>; + if (record.$mid !== 'agentHostCompletion') { return undefined; } - switch (value.kind) { + switch (record.kind) { case AgentHostCompletionReferenceKind.Skill: case AgentHostCompletionReferenceKind.Command: - return value.kind; + return record.kind; } return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/automations/automation.ts b/src/vs/workbench/contrib/chat/common/automations/automation.ts new file mode 100644 index 00000000000000..a5b4c712887931 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automation.ts @@ -0,0 +1,106 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; + +/** + * How often an automation runs. `hourly` fires every hour from creation/update; + * `daily`/`weekly` fire at the configured local-time hour/minute (and day-of-week). + */ +export type AutomationInterval = 'manual' | 'hourly' | 'daily' | 'weekly'; + +/** + * Describes the cadence at which an automation should fire. + * + * Times are stored in local-time wall-clock values. The scheduler converts + * them to UTC when computing concrete run instants so DST transitions are + * handled correctly. + */ +export interface IAutomationSchedule { + readonly interval: AutomationInterval; + + /** Hour-of-day, 0-23. Ignored for `manual` and `hourly`. */ + readonly scheduleHour: number; + + /** Minute-of-hour, 0-59. Ignored for `manual` and `hourly`. */ + readonly scheduleMinute: number; + + /** Day-of-week, 0 (Sunday) through 6 (Saturday). Only used for `weekly`. */ + readonly scheduleDay: number; +} + +/** + * A single scheduled automation. Identity is the immutable `id`; everything + * else may be edited by the user. + */ +export interface IAutomation { + readonly id: string; + readonly name: string; + readonly prompt: string; + readonly schedule: IAutomationSchedule; + + /** Workspace folder for the spawned session. Required. */ + readonly folderUri: URI; + + /** + * Sessions provider for the scheduled run (e.g. `local-agent-host`). Omitted + * on automations predating the picker; the runner then falls back to the + * workspace default provider. + */ + readonly providerId?: string; + + /** Session type to create within {@link providerId}, captured alongside it. */ + readonly sessionTypeId?: string; + + /** Optional language model identifier to seed the new session with. */ + readonly modelId?: string; + + /** Optional chat mode (`agent`/`ask`/`edit`). Defaults to provider's default; custom modes unsupported. */ + readonly mode?: string; + + /** Optional permission level (`default`/`autoApprove`/`autopilot`). Overrides only for scheduled runs; defaults to provider's default. */ + readonly permissionLevel?: string; + + /** Optional worktree isolation mode (`worktree` or `workspace`). */ + readonly isolationMode?: string; + + /** Optional git branch for isolated runs. */ + readonly branch?: string; + + readonly enabled: boolean; + + /** ISO-8601 UTC timestamp. */ + readonly createdAt: string; + readonly updatedAt: string; + readonly lastRunAt?: string; + + /** ISO-8601 UTC timestamp; `undefined` when interval is `manual`. */ + readonly nextRunAt?: string; +} + +export type AutomationRunStatus = 'pending' | 'running' | 'completed' | 'failed'; + +/** + * What kicked off a run. `catch_up` fires once at startup for a due-time that + * passed while VS Code was closed. + */ +export type AutomationRunTrigger = 'schedule' | 'catch_up' | 'manual'; + +export interface IAutomationRun { + readonly id: string; + readonly automationId: string; + readonly status: AutomationRunStatus; + readonly trigger: AutomationRunTrigger; + + /** Session resource URI (stringified) assigned by ISessionsManagementService, if any. */ + readonly sessionResource?: string; + + readonly startedAt: string; + readonly completedAt?: string; + readonly errorMessage?: string; + + /** Window that claimed this run; the leader-election guard uses it to avoid duplicate execution across windows. */ + readonly leaderWindowId: number; +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts new file mode 100644 index 00000000000000..12382040b1e7c7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationDialogService.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IAutomation } from './automation.js'; +import { ICreateAutomationOptions, IUpdateAutomationOptions } from './automationService.js'; + +export interface IShowAutomationDialogOptions { + readonly existing?: IAutomation; +} + +export type IAutomationDialogResult = + | { readonly kind: 'create'; readonly value: ICreateAutomationOptions } + | { readonly kind: 'update'; readonly id: string; readonly value: IUpdateAutomationOptions }; + +export const IAutomationDialogService = createDecorator<IAutomationDialogService>('automationDialogService'); + +/** + * Bridges the workbench Automations UI (list widget) to the Sessions-layer + * dialog implementation without a cross-layer import: the widget depends only + * on this interface, while {@link AutomationDialogService} (sessions) provides it. + */ +export interface IAutomationDialogService { + readonly _serviceBrand: undefined; + showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined>; +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationRunner.ts b/src/vs/workbench/contrib/chat/common/automations/automationRunner.ts new file mode 100644 index 00000000000000..5e379c1b47896d --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationRunner.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { AutomationRunTrigger, IAutomation } from './automation.js'; + +export const IAutomationRunner = createDecorator<IAutomationRunner>('automationRunner'); + +/** + * Runs a single automation: claim the per-automation slot, record the run, + * drive the chat session, report success/failure to {@link IAutomationService}. + * Implemented in the sessions layer via `ISessionsManagementService`. + */ +export interface IAutomationRunner { + readonly _serviceBrand: undefined; + + /** + * Runs `automation` once (skips if another run for it is already in flight). + * Never throws. Failures are recorded on the run row in {@link IAutomationService}. + */ + runOnce( + automation: IAutomation, + trigger: AutomationRunTrigger, + leaderWindowId: number, + token?: CancellationToken, + ): Promise<void>; +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationService.ts b/src/vs/workbench/contrib/chat/common/automations/automationService.ts new file mode 100644 index 00000000000000..ba59d6fd42d2c1 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationService.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IAutomation, IAutomationRun, AutomationRunTrigger, IAutomationSchedule } from './automation.js'; + +export const IAutomationService = createDecorator<IAutomationService>('automationService'); + +/** + * Input for `createAutomation`. The service fills in `id`, timestamps, and + * `nextRunAt`. `folderUri` is required. + */ +export interface ICreateAutomationOptions { + readonly name: string; + readonly prompt: string; + readonly schedule: IAutomationSchedule; + readonly folderUri: URI; + readonly providerId?: string; + readonly sessionTypeId?: string; + readonly modelId?: string; + readonly mode?: string; + readonly permissionLevel?: string; + readonly isolationMode?: string; + readonly branch?: string; + readonly enabled?: boolean; +} + +/** + * Patch for `updateAutomation`. Absent fields are unchanged. Pass `null` for + * `providerId`/`sessionTypeId`/`modelId`/`mode`/`permissionLevel` to clear them; + * `folderUri` cannot be cleared. + */ +export interface IUpdateAutomationOptions { + readonly name?: string; + readonly prompt?: string; + readonly schedule?: IAutomationSchedule; + readonly folderUri?: URI; + readonly providerId?: string | null; + readonly sessionTypeId?: string | null; + readonly modelId?: string | null; + readonly mode?: string | null; + readonly permissionLevel?: string | null; + readonly isolationMode?: string | null; + readonly branch?: string | null; + readonly enabled?: boolean; +} + +/** Patch for `updateRun`. Absent fields are unchanged. */ +export interface IUpdateAutomationRunOptions { + readonly status?: IAutomationRun['status']; + readonly sessionResource?: string; + readonly completedAt?: string; + readonly errorMessage?: string; +} + +/** + * Persistent store for automations and their run history, and the single + * mutation point. Scheduler, runner, and UI all flow through it to keep + * cross-window propagation, persistence, and observables consistent. + */ +export interface IAutomationService { + readonly _serviceBrand: undefined; + + /** All defined automations, newest first. */ + readonly automations: IObservable<readonly IAutomation[]>; + + /** All recorded runs across all automations, newest first. */ + readonly runs: IObservable<readonly IAutomationRun[]>; + + /** Snapshot accessor (no observable dependency). */ + getAutomation(id: string): IAutomation | undefined; + + /** Runs for a single automation, newest first. */ + runsFor(automationId: string): IObservable<readonly IAutomationRun[]>; + + createAutomation(options: ICreateAutomationOptions): Promise<IAutomation>; + updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise<IAutomation>; + deleteAutomation(id: string): Promise<void>; + + /** Records a new run as `pending`. Throws if the automation does not exist. */ + recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise<IAutomationRun>; + + /** Applies a patch to a run; returns the updated run or `undefined` if not found. */ + updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise<IAutomationRun | undefined>; + + /** Most recent `pending`/`running` run for an automation, or `undefined`. Backs the runner's per-automation claim. */ + getActiveRunFor(automationId: string): IAutomationRun | undefined; + + /** Marks all stuck (`pending`/`running`) runs failed. Called on startup to recover from crashes. */ + markStaleRunsFailed(reason: string): Promise<void>; + + /** + * Sets `lastRunAt = now` and recomputes `nextRunAt`. Called right after + * dispatch so the same automation isn't picked up twice on the next tick. + */ + advanceNextRunAt(id: string, now?: Date): Promise<IAutomation | undefined>; +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationSessionTypes.ts b/src/vs/workbench/contrib/chat/common/automations/automationSessionTypes.ts new file mode 100644 index 00000000000000..d3cf865d0456c5 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationSessionTypes.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; + +/** + * A (provider, session-type) choice an automation can target. Captured at + * creation and reused on every run, independent of the workspace default. + */ +export interface IAutomationSessionTypeChoice { + readonly providerId: string; + readonly sessionTypeId: string; + /** Human-readable label suitable for display in a dropdown. */ + readonly label: string; + /** Optional sub-label distinguishing duplicates (e.g. a remote host). */ + readonly description?: string; +} + +export const IAutomationSessionTypeProvider = createDecorator<IAutomationSessionTypeProvider>('automationSessionTypeProvider'); + +/** + * Bridges the workbench-side Automations UI to whichever layer knows which + * session types exist (the Sessions layer wraps + * `ISessionsManagementService.getSessionTypesForFolder`). + */ +export interface IAutomationSessionTypeProvider { + readonly _serviceBrand: undefined; + + /** + * Lists the session types the user can pick from for a given folder. + * Returns an empty array when nothing is registered or no provider + * can serve the folder. + */ + getSessionTypesForFolder(folderUri: URI): readonly IAutomationSessionTypeChoice[]; +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts new file mode 100644 index 00000000000000..1087a9322917e3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationTelemetry.ts @@ -0,0 +1,144 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { AutomationInterval, AutomationRunTrigger, IAutomation } from './automation.js'; + +/** + * GDPR-classified telemetry events for the Automations feature. + * + * Events capture cadence (`intervalKind`, `trigger`) and low-cardinality + * enum values (`permissionLevel`, `isolationMode`) only. + * Prompt text, automation names, folder URIs, and model identifiers are + * never sent. They are user content / workspace-specific information. + */ + +type AutomationCreateEvent = { + intervalKind: AutomationInterval; + permissionLevel: string; + isolationMode: string; + enabled: boolean; +}; + +type AutomationCreateClassification = { + intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence the user picked (manual/hourly/daily/weekly).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level chosen (default/autoApprove/autopilot).' }; + isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode chosen (workspace/worktree).' }; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the automation was created in the enabled state.' }; + owner: 'benvillalobos'; + comment: 'Tracks Automations feature adoption and which options users configure.'; +}; + +export function publishAutomationCreated(telemetryService: ITelemetryService, automation: IAutomation): void { + telemetryService.publicLog2<AutomationCreateEvent, AutomationCreateClassification>('automation.create', { + intervalKind: automation.schedule.interval, + permissionLevel: automation.permissionLevel ?? '', + isolationMode: automation.isolationMode ?? '', + enabled: automation.enabled, + }); +} + +type AutomationUpdateEvent = { + intervalKind: AutomationInterval; + scheduleChanged: boolean; + enabledChanged: boolean; + enabled: boolean; +}; + +type AutomationUpdateClassification = { + intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence after the update.' }; + scheduleChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the schedule itself changed (interval/time/day).' }; + enabledChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the enabled flag flipped.' }; + enabled: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Enabled state after the update.' }; + owner: 'benvillalobos'; + comment: 'Tracks how often users edit Automations and which fields they touch.'; +}; + +export function publishAutomationUpdated(telemetryService: ITelemetryService, before: IAutomation, after: IAutomation): void { + telemetryService.publicLog2<AutomationUpdateEvent, AutomationUpdateClassification>('automation.update', { + intervalKind: after.schedule.interval, + scheduleChanged: before.schedule.interval !== after.schedule.interval + || before.schedule.scheduleHour !== after.schedule.scheduleHour + || before.schedule.scheduleMinute !== after.schedule.scheduleMinute + || before.schedule.scheduleDay !== after.schedule.scheduleDay, + enabledChanged: before.enabled !== after.enabled, + enabled: after.enabled, + }); +} + +type AutomationDeleteEvent = { + intervalKind: AutomationInterval; +}; + +type AutomationDeleteClassification = { + intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence of the deleted automation.' }; + owner: 'benvillalobos'; + comment: 'Tracks Automations deletion.'; +}; + +export function publishAutomationDeleted(telemetryService: ITelemetryService, automation: IAutomation): void { + telemetryService.publicLog2<AutomationDeleteEvent, AutomationDeleteClassification>('automation.delete', { + intervalKind: automation.schedule.interval, + }); +} + +type AutomationRunEvent = { + trigger: AutomationRunTrigger; + intervalKind: AutomationInterval; + success: boolean; + durationMs: number; + permissionLevel: string; + isolationMode: string; +}; + +type AutomationRunClassification = { + trigger: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'What kicked off the run (schedule/catch_up/manual).' }; + intervalKind: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Cadence of the automation that ran.' }; + success: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the run completed without error.' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Wall-clock duration of the run kickoff (recordRunStart through completed/failed).' }; + permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Permission level applied to the run (default/autoApprove/autopilot).' }; + isolationMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Isolation mode applied to the run (workspace/worktree).' }; + owner: 'benvillalobos'; + comment: 'Tracks Automations run outcomes and timing.'; +}; + +export function publishAutomationRun(telemetryService: ITelemetryService, args: { + trigger: AutomationRunTrigger; + automation: IAutomation; + success: boolean; + durationMs: number; +}): void { + telemetryService.publicLog2<AutomationRunEvent, AutomationRunClassification>('automation.run', { + trigger: args.trigger, + intervalKind: args.automation.schedule.interval, + success: args.success, + durationMs: Math.max(0, Math.round(args.durationMs)), + permissionLevel: args.automation.permissionLevel ?? '', + isolationMode: args.automation.isolationMode ?? '', + }); +} + +type AutomationRunErrorEvent = { + trigger: AutomationRunTrigger; + intervalKind: AutomationInterval; + // TODO: classify error types (e.g. 'network', 'auth', 'timeout') instead of raw messages +}; + +type AutomationRunErrorClassification = { + trigger: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'What kicked off the failed run (schedule/catch_up/manual).' }; + intervalKind: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Cadence of the automation that failed.' }; + owner: 'benvillalobos'; + comment: 'Tracks Automations run failures for reliability.'; +}; + +export function publishAutomationRunError(telemetryService: ITelemetryService, args: { + trigger: AutomationRunTrigger; + automation: IAutomation; +}): void { + telemetryService.publicLogError2<AutomationRunErrorEvent, AutomationRunErrorClassification>('automation.runError', { + trigger: args.trigger, + intervalKind: args.automation.schedule.interval, + }); +} diff --git a/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts new file mode 100644 index 00000000000000..ae8d8894a5297e --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/automationsEnabled.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; + +/** + * Gates the entire Automations feature: sidebar entry, editor section, + * session composer option, and scheduled execution. Default `false`. + */ +export const CHAT_AUTOMATIONS_ENABLED_SETTING = 'chat.automations.enabled'; + +/** Per-run timeout in minutes. Hung runs are cancelled and marked failed so they don't block the dispatch chain. */ +export const CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING = 'chat.automations.runTimeoutMinutes'; + +/** Default for {@link CHAT_AUTOMATIONS_RUN_TIMEOUT_MINUTES_SETTING}. */ +export const DEFAULT_AUTOMATIONS_RUN_TIMEOUT_MINUTES = 30; + +/** Context key mirroring {@link CHAT_AUTOMATIONS_ENABLED_SETTING}, for `when` clauses on menus/commands. */ +export const ChatAutomationsEnabledContext = new RawContextKey<boolean>('chatAutomationsEnabled', false, { + type: 'boolean', + description: 'True when the chat Automations feature is enabled via the chat.automations.enabled setting.', +}); diff --git a/src/vs/workbench/contrib/chat/common/automations/schedule.ts b/src/vs/workbench/contrib/chat/common/automations/schedule.ts new file mode 100644 index 00000000000000..8578d8ea3cfd30 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/automations/schedule.ts @@ -0,0 +1,95 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IAutomationSchedule } from './automation.js'; +import { localize } from '../../../../../nls.js'; + +export const DAYS_OF_WEEK: readonly string[] = [ + localize('automation.day.sun', "Sunday"), + localize('automation.day.mon', "Monday"), + localize('automation.day.tue', "Tuesday"), + localize('automation.day.wed', "Wednesday"), + localize('automation.day.thu', "Thursday"), + localize('automation.day.fri', "Friday"), + localize('automation.day.sat', "Saturday"), +]; + +/** + * Computes the next fire time for a schedule. Daily/weekly times are + * local wall-clock (not UTC) so DST transitions don't shift them. + * Day advances use `Date` constructor overflow, not milliseconds, to + * avoid skipping spring-forward days. Returns `undefined` for `manual`. + */ +export function computeNextRunAt(schedule: IAutomationSchedule, now: Date): Date | undefined { + const { interval, scheduleHour, scheduleMinute, scheduleDay } = schedule; + + switch (interval) { + case 'manual': + return undefined; + + case 'hourly': + return new Date(now.getTime() + 60 * 60 * 1000); + + case 'daily': { + if (!isValidHourMinute(scheduleHour, scheduleMinute)) { + return undefined; + } + const today = buildLocalDate(now.getFullYear(), now.getMonth(), now.getDate(), scheduleHour, scheduleMinute); + if (today.getTime() > now.getTime()) { + return today; + } + // Advance by calendar date, not milliseconds, so spring-forward + // days are not skipped (a `+ 24 * 60 * 60 * 1000` jump on the + // DST day can land on the day after the target). + return buildLocalDate(now.getFullYear(), now.getMonth(), now.getDate() + 1, scheduleHour, scheduleMinute); + } + + case 'weekly': { + if (!isValidHourMinute(scheduleHour, scheduleMinute)) { + return undefined; + } + if (!Number.isInteger(scheduleDay) || scheduleDay < 0 || scheduleDay > 6) { + return undefined; + } + const currentDay = now.getDay(); + let daysAhead = scheduleDay - currentDay; + const sameDayButPassed = daysAhead === 0 && (now.getHours() > scheduleHour || + (now.getHours() === scheduleHour && now.getMinutes() >= scheduleMinute)); + if (daysAhead < 0 || sameDayButPassed) { + daysAhead += 7; + } + return buildLocalDate(now.getFullYear(), now.getMonth(), now.getDate() + daysAhead, scheduleHour, scheduleMinute); + } + + default: + return undefined; + } +} + +function isValidHourMinute(hour: number, minute: number): boolean { + return Number.isInteger(hour) && hour >= 0 && hour <= 23 + && Number.isInteger(minute) && minute >= 0 && minute <= 59; +} + +/** + * Builds a Date for a specific local wall-clock time. If the requested time + * falls in a DST spring-forward gap (e.g. 2:30 AM on a day that jumps from + * 2:00 to 3:00), shifts forward in 1-hour increments until a valid time is + * found. Ambiguous fall-back times are resolved to the first occurrence, + * matching JavaScript's default behavior. + */ +function buildLocalDate(year: number, monthIndex: number, day: number, hour: number, minute: number): Date { + const candidate = new Date(year, monthIndex, day, hour, minute, 0, 0); + if (candidate.getHours() === hour && candidate.getMinutes() === minute) { + return candidate; + } + for (let shift = 1; shift <= 3; shift++) { + const shifted = new Date(year, monthIndex, day, hour + shift, minute, 0, 0); + if (shifted.getHours() === (hour + shift) % 24 && shifted.getMinutes() === minute) { + return shifted; + } + } + return candidate; +} diff --git a/src/vs/workbench/contrib/chat/common/chatImageExtraction.ts b/src/vs/workbench/contrib/chat/common/chatImageExtraction.ts index ab8ec9fb6c28c8..7bb98a493c8cbd 100644 --- a/src/vs/workbench/contrib/chat/common/chatImageExtraction.ts +++ b/src/vs/workbench/contrib/chat/common/chatImageExtraction.ts @@ -13,7 +13,7 @@ import { IChatResponseViewModel, IChatRequestViewModel, isRequestVM } from './mo import { ChatResponseResource } from './model/chatModel.js'; import { IChatContentInlineReference, IChatToolInvocation, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from './chatService/chatService.js'; import { isToolResultInputOutputDetails, isToolResultOutputDetails, IToolResultOutputDetails } from './tools/languageModelToolsService.js'; -import { getExplicitFileOrImageAttachmentSummary, isImageVariableEntry } from './attachments/chatVariableEntries.js'; +import { getExplicitFileOrImageAttachmentSummary, type IChatRequestVariableEntry, isImageVariableEntry } from './attachments/chatVariableEntries.js'; export interface IChatExtractedImage { readonly id: string; @@ -207,9 +207,15 @@ export function coerceImageBuffer(value: unknown): Uint8Array | undefined { */ export function extractImagesFromChatRequest( request: IChatRequestViewModel, +): IChatExtractedImage[] { + return extractImagesFromChatVariables(request.variables); +} + +export function extractImagesFromChatVariables( + variables: readonly IChatRequestVariableEntry[], ): IChatExtractedImage[] { const images: IChatExtractedImage[] = []; - for (const variable of request.variables) { + for (const variable of variables) { if (!isImageVariableEntry(variable)) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index f1b62786e02bc3..9423d75c99051c 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -259,6 +259,11 @@ export interface IChatProgressMessage { shimmer?: boolean; } +export interface IChatSystemNotificationPart { + content: IMarkdownString; + kind: 'systemNotification'; +} + export interface IChatTask extends IChatTaskDto { deferred: DeferredPromise<string | void>; progress: (IChatWarningMessage | IChatContentReference)[]; @@ -532,6 +537,23 @@ export interface IChatThinkingPart { generatedTitle?: string; } +/** + * A progress part representing an auto-mode model routing resolution. + * Shown as a collapsible widget in the chat stream: collapsed displays + * "Routed to <model>", expanded shows routing details and confidence. + */ +export interface IChatAutoModeResolutionPart { + kind: 'autoModeResolution'; + /** The model ID that was selected by the router */ + resolvedModel: string; + /** The user-facing display name of the resolved model */ + resolvedModelName: string; + /** The router's classification label */ + predictedLabel: 'needs_reasoning' | 'no_reasoning' | 'fallback'; + /** Confidence score (0-1) from the router */ + confidence: number; +} + /** * A progress part representing the execution result of a hook. * Aligned with the hook output JSON structure: { stopReason, systemMessage, hookSpecificOutput }. @@ -563,6 +585,12 @@ export interface IChatTerminalToolInvocationData { // isSandboxWrapped boolean to run in the terminal (potentially different from original command) isSandboxWrapped?: boolean; }; + /** + * LM-generated intention describing why the command is being run, shown + * above the command in the terminal tool card. Set by the Agent Host; the + * built-in terminal tool leaves this unset. + */ + intention?: string; /** The working directory URI for the terminal */ cwd?: UriComponents; /** @@ -1031,12 +1059,21 @@ export interface IChatPullRequestContent { export interface IChatSubagentToolInvocationData { kind: 'subagent'; + isActive?: boolean; description?: string; agentName?: string; prompt?: string; result?: string; modelName?: string; credits?: number; + /** + * Resource (URI string) of the subagent's own chat, when the subagent runs as + * a distinct chat (e.g. an agent host worker chat). Used to offer an "Open + * chat" link that reveals the subagent's read-only chat. Undefined when the + * subagent has no separately-openable chat. A string (not a `URI`) so it stays + * serializable across the extension host protocol. + */ + chatResource?: string; } /** @@ -1136,14 +1173,18 @@ export interface IChatAgentFeedbackReviewComment { * Command ids the agent feedback review confirmation renderer (workbench/chat) * uses to fetch unreviewed comments and apply the user's selection. They are * implemented by the agent feedback feature in `vs/sessions`, keeping the chat - * layer decoupled from the feedback model. All take the owning session resource - * (`UriComponents`) as their first argument. + * layer decoupled from the feedback model. Most take the owning session resource + * (`UriComponents`) as their first argument; {@link AgentFeedbackReviewCommandId.RevealAt} + * instead resolves the session from the file resource so a rendered tool call + * can link to a comment without knowing the session URI. */ export const enum AgentFeedbackReviewCommandId { /** `(sessionResource)` -> `IChatAgentFeedbackReviewComment[]` (the `created` reviewable comments). */ GetComments = '_agentFeedbackReview.getComments', /** `(sessionResource, commentId)` -> opens the file and reveals the comment. */ Reveal = '_agentFeedbackReview.reveal', + /** `(resourceUri, range)` -> resolves the owning session and reveals the comment at that file range. */ + RevealAt = '_agentFeedbackReview.revealAt', /** `(sessionResource, commentId)` -> deletes the comment entirely. */ Delete = '_agentFeedbackReview.delete', /** `(sessionResource, commentIds)` -> accepts (reveals) the given comments. */ @@ -1163,6 +1204,45 @@ export interface IChatMcpServersStartingSerialized { didStartServerIds?: string[]; } +export interface IChatMcpAuthenticationRequired { + readonly kind: 'mcpAuthenticationRequired'; + readonly sessionResource: UriComponents; + readonly servers: IObservable<readonly IChatMcpAuthenticationRequiredServer[]>; + isUsed: boolean; +} + +export interface IChatMcpAuthenticationRequiredServer { + readonly id: string; + readonly name: string; + readonly resource: string; + readonly authorizationServers?: readonly string[]; + readonly requiredScopes?: readonly string[]; + readonly reason?: string; +} + +/** + * Surfaced by agent-host sessions when one or more MCP servers are still in the + * {@link McpServerStatus.Starting starting} state a noticeable time after a + * turn began without any content arriving from the host. The part lists the + * servers still starting and updates dynamically via {@link servers}: it hides + * itself (by emptying the observable) once every server has started, content + * starts being received, or the turn ends — whichever happens first. + * + * Unlike {@link IChatMcpServersStarting} (used by the in-process MCP autostart + * flow), this is a lightweight progress hint with no interactive affordance + * (there is no "Skip" button). + */ +export interface IChatMcpServersStartingSlow { + readonly kind: 'mcpServersStartingSlow'; + readonly sessionResource: UriComponents; + readonly servers: IObservable<readonly IChatMcpStartingServer[]>; +} + +export interface IChatMcpStartingServer { + readonly id: string; + readonly name: string; +} + export interface IChatDisabledClaudeHooksPart { readonly kind: 'disabledClaudeHooks'; } @@ -1272,6 +1352,7 @@ export type IChatProgress = | IChatContentInlineReference | IChatCodeCitation | IChatProgressMessage + | IChatSystemNotificationPart | IChatTask | IChatTaskResult | IChatCommandButton @@ -1298,9 +1379,12 @@ export type IChatProgress = | IChatElicitationRequestSerialized | IChatMcpServersStarting | IChatMcpServersStartingSerialized + | IChatMcpAuthenticationRequired + | IChatMcpServersStartingSlow | IChatHookPart | IChatExternalToolInvocationUpdate - | IChatDisabledClaudeHooksPart; + | IChatDisabledClaudeHooksPart + | IChatAutoModeResolutionPart; export interface IChatFollowup { kind: 'reply'; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index e3b3d552297a06..762f270d2d2844 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -122,6 +122,40 @@ class CancellableRequest implements IDisposable { const EMPTY_REFERENCES: ReadonlyArray<IDynamicVariable> = Object.freeze([]); const EMPTY_TOOL_ENABLEMENT_MAP: ToolAndToolSetEnablementMap = ToolAndToolSetEnablementMap.fromEntries([]); +/** + * Fill in the picker selections (`selectedModel`, `mode`) that `stateToApply` is missing, using + * the session's previously `savedState`. + * + * `stateToApply` is the input state about to be applied to the session being restored (an + * agent-host transferred draft, or the saved draft as a fallback). `savedState` is the session's + * own previously saved input state. At cold restore `stateToApply` can lose these two picker + * selections, so take them from `savedState`: + * - `selectedModel`: `stateToApply` leaves it undefined when the model is not registered yet (the + * agent-host model list has not loaded). `savedState` keeps the full model (id + capabilities), + * so use it. The input part re-validates it against the live model list and re-resolves it once + * the list loads, so a genuinely stale/wrong model is still dropped safely. + * - `mode`: `stateToApply` falls back to the default Agent when it did not capture the user's + * custom agent. Prefer the custom agent from `savedState`, but only promote it OVER the plain + * default Agent — never override a different explicit mode. + */ +export function backfillRestoredPickerState( + stateToApply: ISerializableChatModelInputState | undefined, + savedState: ISerializableChatModelInputState | undefined, + defaultAgentModeId: string, +): ISerializableChatModelInputState | undefined { + if (!stateToApply || !savedState) { + return stateToApply; + } + const selectedModel = stateToApply.selectedModel ?? savedState.selectedModel; + const mode = (stateToApply.mode.id === defaultAgentModeId && savedState.mode.id !== defaultAgentModeId) + ? savedState.mode + : stateToApply.mode; + if (selectedModel === stateToApply.selectedModel && mode === stateToApply.mode) { + return stateToApply; + } + return { ...stateToApply, selectedModel, mode }; +} + export class ChatService extends Disposable implements IChatService { declare _serviceBrand: undefined; @@ -698,13 +732,18 @@ export class ChatService extends Disposable implements IChatService { const restoredDraft: ISerializableChatModelInputState | undefined = storedInputState ? { ...storedInputState, selectedModel: historyDerivedModel } : undefined; + // At cold restore the agent-host transferred draft can drop the user's per-session picker + // selections (model/mode); restore them from the session's own saved `storedInputState` + // (see {@link backfillRestoredPickerState}). + const stateToApply = providedSession.transferredState?.inputState ?? restoredDraft; + const inputState = backfillRestoredPickerState(stateToApply, storedInputState, ChatMode.Agent.id); const modelRef = this._sessionModels.acquireOrCreate({ initialData, location, sessionResource: sessionResource, canUseTools: false, transferEditingSession: providedSession.transferredState?.editingSession, - inputState: providedSession.transferredState?.inputState ?? restoredDraft, + inputState, }, debugOwner ?? 'ChatService#loadRemoteSession'); logChangesToStateModel(modelRef.object.inputModel, `loadRemoteSession inputState source: session=${sessionResource.toString()}, chatSessionType=${chatSessionType}, historyModelId=${modelId}, agentUri=${agentUri?.toString()}, historySelectedModel=${historySelectedModel}, transferredSelectedModel=${providedSession.transferredState?.inputState?.selectedModel?.identifier}, storedSelectedModel=${storedInputState?.selectedModel?.identifier}, finalSelectedModel=${modelRef.object.inputModel.state.get()?.selectedModel?.identifier}, hasTransferredInputState=${!!providedSession.transferredState?.inputState}, hasStoredInputState=${!!storedInputState}, hasInitialData=${!!initialData}`, modelRef.object.inputModel.state.get(), undefined, this.logService); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts index 8c1e9d902b6c36..e579b14239f290 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceTelemetry.ts @@ -14,6 +14,7 @@ import { isImageVariableEntry } from '../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ILanguageModelsService } from '../languageModels.js'; import { chatSessionResourceToId, getChatSessionType } from '../model/chatUri.js'; +import { isRemoteAgentHostSessionType, parseRemoteAgentHostHarness } from '../../../../../platform/agentHost/common/agentHostSessionType.js'; type ChatVoteEvent = { direction: 'up' | 'down'; @@ -156,6 +157,7 @@ export type ChatProviderInvokedEvent = { permissionLevel: ChatPermissionLevel | undefined; chatMode: string | undefined; sessionType: string | undefined; + harness: string | undefined; }; export type ChatProviderInvokedClassification = { @@ -177,6 +179,7 @@ export type ChatProviderInvokedClassification = { permissionLevel: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The tool auto-approval permission level selected in the permission picker (default, autoApprove, or autopilot). Undefined when the picker is not applicable (e.g. ask mode or API-driven requests).' }; chatMode: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The chat mode used for the request. Built-in modes (ask, agent, edit), extension-contributed names (e.g. Plan), or a hashed identifier for user-created custom agents.' }; sessionType: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The session type scheme (e.g. vscodeLocalChatSession for local, or remote session scheme).' }; + harness: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'For remote agent host sessions, the underlying harness/provider (e.g. copilotcli, claude, codex) so remote activity can be split by harness. Undefined for non-remote sessions.' }; owner: 'roblourens'; comment: 'Provides insight into the performance of Chat agents.'; }; @@ -318,6 +321,7 @@ export class ChatRequestTelemetry { permissionLevel: this.opts.options?.modeInfo?.kind === ChatModeKind.Ask ? undefined : this.opts.options?.modeInfo?.permissionLevel, chatMode: this.opts.options?.modeInfo?.telemetryModeName ?? this.opts.options?.modeInfo?.telemetryModeId, sessionType: getChatSessionTypeForTelemetry(this.opts.sessionResource), + harness: getHarnessForTelemetry(this.opts.sessionResource), }); } @@ -366,5 +370,16 @@ export class ChatRequestTelemetry { function getChatSessionTypeForTelemetry(sessionResource: URI): string { const sessionType = getChatSessionType(sessionResource); - return sessionType.startsWith('remote-') ? 'remote-agent-host' : sessionType; + // Collapse the high-cardinality, host-specific authority into a single + // value (the authority is PII); the harness is reported separately. + return isRemoteAgentHostSessionType(sessionType) ? 'remote-agent-host' : sessionType; +} + +/** + * For remote agent host sessions, the underlying harness/provider so remote + * activity can be split by harness (the collapsed sessionType cannot). See + * telemetry gap #2 in #8209. Undefined for non-remote sessions. + */ +function getHarnessForTelemetry(sessionResource: URI): string | undefined { + return parseRemoteAgentHostHarness(getChatSessionType(sessionResource)); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts b/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts new file mode 100644 index 00000000000000..59d8e2fdb84056 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/chatSessionTypePreference.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; + +export const CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY = 'chat.userSelectedSessionType'; + +export function getRememberedSessionType(storageService: IStorageService): string | undefined { + return storageService.get(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, StorageScope.PROFILE); +} + +export function storeUserSelectedSessionType(storageService: IStorageService, sessionType: string): void { + storageService.store(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, sessionType, StorageScope.PROFILE, StorageTarget.MACHINE); +} + +export function clearUserSelectedSessionType(storageService: IStorageService): void { + storageService.remove(CHAT_USER_SELECTED_SESSION_TYPE_STORAGE_KEY, StorageScope.PROFILE); +} diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 22c6ff55947ee8..f0b92ed5d1a9ee 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -257,6 +257,7 @@ export interface IChatSessionFileChange { readonly originalUri?: URI; readonly insertions: number; readonly deletions: number; + readonly reviewed?: boolean; } export interface IChatSessionFileChange2 { @@ -265,6 +266,7 @@ export interface IChatSessionFileChange2 { readonly modifiedUri?: URI; readonly insertions: number; readonly deletions: number; + readonly reviewed?: boolean; } export type IChatSessionHistoryItem = { @@ -407,6 +409,9 @@ export interface IChatSession extends IDisposable { export interface IChatSessionContentProvider { provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise<IChatSession>; + /** Resolves a parsed response Markdown URI before it is sanitized and rendered. */ + resolveChatResponseUri?(sessionResource: URI, href: string, kind: 'link' | 'image'): string; + /** * Optional. Compute completion items for an input being composed in this * session. Returning `undefined` lets the workbench fall back to its @@ -711,6 +716,8 @@ export interface IChatSessionsService { registerChatSessionContentProvider(scheme: string, provider: IChatSessionContentProvider): IDisposable; canResolveChatSession(sessionType: string): Promise<boolean>; getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise<IChatSession>; + /** Resolves a parsed response Markdown URI through its session content provider. */ + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string; /** * Compute completion items for an input being composed in the chat diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 751d39980801d5..df9ad98246cad7 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Schemas } from '../../../../base/common/network.js'; -import { IChatSessionsService, localChatSessionType, SessionType } from './chatSessionsService.js'; +import { IChatSessionsService, isAgentHostTarget, localChatSessionType, SessionType } from './chatSessionsService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ContextKeyExpr, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { ChatEntitlementContextKeys } from '../../../services/chat/common/chatEntitlementService.js'; @@ -13,6 +14,13 @@ import { IsAuxiliaryWindowContext, IsSessionsWindowContext } from '../../../comm import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { LocalChatSessionUri } from './model/chatUri.js'; +import { clearUserSelectedSessionType, getRememberedSessionType, storeUserSelectedSessionType } from './chatSessionTypePreference.js'; + +export const enum BYOKUtilityModelDefault { + None = 'none', + MainAgent = 'mainAgent', + Copilot = 'copilot', +} export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', @@ -27,6 +35,7 @@ export enum ChatConfiguration { ExploreAgentDefaultModel = 'chat.exploreAgent.defaultModel', UtilityModel = 'chat.utilityModel', UtilitySmallModel = 'chat.utilitySmallModel', + BYOKUtilityModelDefault = 'chat.byokUtilityModelDefault', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', @@ -96,12 +105,14 @@ export enum ChatConfiguration { EditorLocalAgentEnabled = 'chat.editor.localAgent.enabled', CopilotCliHideExtensionHostEditor = 'chat.editor.copilotCli.hideExtensionHost', AgentsHandoffTipMode = 'chat.agentsHandoffTip.mode', + TurnStatusPills = 'chat.turnStatusPills', IncrementalRendering = 'chat.experimental.incrementalRendering.enabled', IncrementalRenderingStyle = 'chat.experimental.incrementalRendering.animationStyle', IncrementalRenderingBuffering = 'chat.experimental.incrementalRendering.buffering', CollectInstructionsInExtension = 'chat.experimental.collectInstructionsInExtension', + ImplicitContextActiveEditor = 'chat.implicitContext.includeActiveEditor', } /** @@ -253,7 +264,7 @@ export function isSupportedChatFileScheme(accessor: ServicesAccessor, scheme: st * Falls back to {@link localChatSessionType} when local is enabled, or when no * visible non-local provider is available. */ -export function getDefaultNewChatSessionType( +export function getComputedDefaultSessionType( configurationService: IConfigurationService, chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'> ): string { @@ -274,62 +285,80 @@ export function getDefaultNewChatSessionType( return getVisibleNonLocalEditorChatSessionTypes(configurationService, chatSessionsService)[0] ?? localChatSessionType; } -export function getDefaultNewChatSessionResource( +export function getComputedDefaultSessionResource( configurationService: IConfigurationService, chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'> ): URI { - const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService); + const defaultType = getComputedDefaultSessionType(configurationService, chatSessionsService); return defaultType === localChatSessionType ? LocalChatSessionUri.getNewSessionUri() : URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); } -/** - * Storage key for the last-used non-local editor chat session type (agent), persisted at profile scope. - */ -export const ChatLastUsedEditorSessionTypeStorageKey = 'chat.lastUsedEditorSessionType'; +export function isRememberedSessionTypeUsable( + sessionType: string, + configurationService: IConfigurationService, + chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'> +): boolean { + if (sessionType === localChatSessionType) { + return isEditorLocalAgentEnabled(configurationService); + } + if (isAgentHostTarget(sessionType)) { + return true; + } + return !!chatSessionsService.getChatSessionContribution(sessionType); +} -/** - * Resolves the session type (agent) for a new chat editor, preferring the last-used visible non-local agent when `chat.editor.defaultProvider` isn't explicitly configured. - */ -export function getNewChatEditorSessionType( +export interface IDefaultNewChatSessionTypeOptions { + readonly explicitOverride?: string; + readonly currentSessionType?: string; +} + +export function getDefaultNewChatSessionType( configurationService: IConfigurationService, chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'>, - lastUsedSessionType: string | undefined, + storageService: IStorageService, + options?: IDefaultNewChatSessionTypeOptions ): string { - const inspected = configurationService.inspect<string>(ChatConfiguration.EditorDefaultProvider); - const explicitlyConfigured = inspected.applicationValue !== undefined - || inspected.userValue !== undefined - || inspected.userLocalValue !== undefined - || inspected.userRemoteValue !== undefined - || inspected.workspaceValue !== undefined - || inspected.workspaceFolderValue !== undefined - || inspected.memoryValue !== undefined - || inspected.policyValue !== undefined; - - if (!explicitlyConfigured - && lastUsedSessionType - && lastUsedSessionType !== localChatSessionType - && isVisibleEditorChatSessionType(lastUsedSessionType, configurationService, chatSessionsService)) { - return lastUsedSessionType; + if (options?.explicitOverride) { + return options.explicitOverride; + } + + const remembered = getRememberedSessionType(storageService); + if (remembered && isRememberedSessionTypeUsable(remembered, configurationService, chatSessionsService)) { + return remembered; } - return getDefaultNewChatSessionType(configurationService, chatSessionsService); + if (options?.currentSessionType) { + return options.currentSessionType; + } + + return getComputedDefaultSessionType(configurationService, chatSessionsService); } -/** - * Like {@link getDefaultNewChatSessionResource}, but prefers the user's - * last-used session type via {@link getNewChatEditorSessionType}. - */ -export function getNewChatEditorSessionResource( +export function getDefaultNewChatSessionResource( configurationService: IConfigurationService, chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'>, - lastUsedSessionType: string | undefined, + storageService: IStorageService, + options?: IDefaultNewChatSessionTypeOptions ): URI { - const sessionType = getNewChatEditorSessionType(configurationService, chatSessionsService, lastUsedSessionType); - return sessionType === localChatSessionType + const defaultType = getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, options); + return defaultType === localChatSessionType ? LocalChatSessionUri.getNewSessionUri() - : URI.from({ scheme: sessionType, path: `/untitled-${generateUuid()}` }); + : URI.from({ scheme: defaultType, path: `/untitled-${generateUuid()}` }); +} + +export function recordUserSelectedSessionType( + storageService: IStorageService, + configurationService: IConfigurationService, + chatSessionsService: Pick<IChatSessionsService, 'getChatSessionContribution' | 'getAllChatSessionContributions'>, + sessionType: string +): void { + if (sessionType === getComputedDefaultSessionType(configurationService, chatSessionsService)) { + clearUserSelectedSessionType(storageService); + } else { + storeUserSelectedSessionType(storageService, sessionType); + } } export function isEditorLocalAgentEnabled(configurationService: IConfigurationService): boolean { diff --git a/src/vs/workbench/contrib/chat/common/contextContrib/chatContext.ts b/src/vs/workbench/contrib/chat/common/contextContrib/chatContext.ts index 52211ca84b382b..99b9097b7519a9 100644 --- a/src/vs/workbench/contrib/chat/common/contextContrib/chatContext.ts +++ b/src/vs/workbench/contrib/chat/common/contextContrib/chatContext.ts @@ -32,6 +32,6 @@ export interface IChatExplicitContextProvider { } export interface IChatResourceContextProvider { - provideChatContext(resource: URI, withValue: boolean, token: CancellationToken): Promise<IChatContextItem | undefined>; + provideChatContext(resource: URI, withValue: boolean, viewType: string | undefined, token: CancellationToken): Promise<IChatContextItem | undefined>; resolveChatContext(context: IChatContextItem, token: CancellationToken): Promise<IChatContextItem>; } diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index c29b6a2ed2fc6a..b6017b1dedd74a 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -11,7 +11,7 @@ import { ThemeIcon } from '../../../../base/common/themables.js'; import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { AICustomizationManagementSection, AICustomizationSource, AICustomizationSources, BUILTIN_STORAGE, IStorageSourceFilter } from './aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSource, BUILTIN_STORAGE } from './aiCustomizationWorkspaceService.js'; import { PromptsType } from './promptSyntax/promptTypes.js'; import { AGENT_MD_FILENAME } from './promptSyntax/config/promptFileLocations.js'; import { IAgentSource, IChatPromptSlashCommand, ICustomAgent, IPromptsService, IResolvedChatPromptSlashCommand, matchesSessionType, PromptsStorage } from './promptSyntax/service/promptsService.js'; @@ -107,11 +107,6 @@ export interface IHarnessDescriptor { * When `undefined`, the harness is always available (e.g. Local). */ readonly requiredAgentId?: string; - /** - * Returns the storage source filter that should be applied to customization - * items of the given type when this harness is active. - */ - getStorageSourceFilter(type: PromptsType): IStorageSourceFilter; /** * When set, this harness is backed by an extension-contributed provider * that can supply customization items directly (bypassing promptsService @@ -240,6 +235,8 @@ export interface ICustomizationSourceFolder { readonly uri: URI; /** Display label for the picker when multiple folders are offered. */ readonly label: string; + /** Customization source for this folder (typically 'local' or 'user' for writable creation locations). */ + readonly source: AICustomizationSource; } /** @@ -368,14 +365,7 @@ export interface ICustomizationSlashCommand { readonly sessionTypes?: readonly string[]; } -// #region Shared filter constants - -/** - * Empty filter returned when no harness is registered yet. - */ -const EMPTY_FILTER: IStorageSourceFilter = { - sources: [], -}; +// #region Shared descriptor constants /** * Empty descriptor returned when no harness is registered yet. @@ -384,7 +374,6 @@ const EMPTY_DESCRIPTOR: IHarnessDescriptor = { id: '', label: '', icon: Codicon.sparkle, - getStorageSourceFilter: () => EMPTY_FILTER, }; @@ -405,7 +394,6 @@ const EMPTY_DESCRIPTOR: IHarnessDescriptor = { * with no user-root restrictions. */ export function createVSCodeHarnessDescriptor(): IHarnessDescriptor { - const filter: IStorageSourceFilter = { sources: AICustomizationSources.all }; return { id: SessionType.Local, label: localize('harness.local', "Local"), @@ -417,7 +405,6 @@ export function createVSCodeHarnessDescriptor(): IHarnessDescriptor { rootFileShortcuts: [AGENT_MD_FILENAME], }], ]), - getStorageSourceFilter: () => filter, }; } diff --git a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts index 2fec427255dd41..034d702c5969f7 100644 --- a/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts +++ b/src/vs/workbench/contrib/chat/common/editing/chatEditingService.ts @@ -291,6 +291,20 @@ export interface IEditSessionEntryDiff extends IEditSessionDiffStats { originalURI: URI; modifiedURI: URI; + /** + * Optional frozen "after" content for the RHS. When set, this is the exact + * modified-side snapshot the diff represents (e.g. an agent-host per-turn + * checkpoint), as opposed to {@link modifiedURI} which may be the live + * working file and therefore include later changes. Consumers that want the + * changeset's own diff should prefer this when present; {@link modifiedURI} + * remains the file's identity for labels and go-to-file. + * + * Note: distinct from the agent-host checkpoint-ref readability fix (#323932). + * That made the frozen snapshot blobs *readable*; this field carries *which* + * snapshot to diff against so a per-turn review shows only that turn's changes. + */ + modifiedSnapshotURI?: URI; + /** Diff state information: */ quitEarly: boolean; identical: boolean; diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 2cb6fc4c2e405b..24fc6f32997a56 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -283,11 +283,25 @@ export interface ILanguageModelChatMetadata { * when the user is in a session matching this type. */ readonly targetChatSessionType?: string; + /** + * Optional grouping hint for the model picker. When set, the picker buckets this model + * under a sub-group within its vendor, identified by this vendor id — e.g. agent-host models, + * which all share one vendor, grouped by their upstream provider — instead of a single + * vendor-wide bucket. The display name is resolved from the vendor registry + * ({@link ILanguageModelsService.getVendors}), the same source used for every other vendor. + * Presentation-only; it does not affect model selection or routing. + */ + readonly modelGroup?: { readonly id: string }; /** * An optional JSON schema describing the per-model configuration options. * Used to validate user-provided per-model configuration in `chatLanguageModels.json`. */ readonly configurationSchema?: ILanguageModelConfigurationSchema; + /** + * Optional warning text to display in the model picker hover as a warning banner. + * The keys are warning categories (e.g. "data_retention") and the values are markdown strings. + */ + readonly warningText?: IStringDictionary<string>; } export namespace ILanguageModelChatMetadata { @@ -306,6 +320,30 @@ export namespace ILanguageModelChatMetadata { } return name === asQualifiedName(metadata); } + + /** + * Documentation link explaining how Auto model selection works. + * NOTE: Also defined in extensions/copilot/src/extension/conversation/common/languageModelAccess.ts — keep in sync. + */ + export const autoModelSelectionDocsUrl = 'https://docs.github.com/en/copilot/concepts/models/auto-model-selection'; + + /** + * Builds the shared description shown for the Auto model, rendered as Markdown + * (it contains a "Learn More" link). The discount sentence is only included + * when a positive discount is provided. + * + * @param discountPercent Whole-number percentage (e.g. `10` for 10%). When + * omitted or not positive, the discount sentence is left out entirely. + */ + export function getAutoModelDescription(discountPercent?: number): string { + const base = localize('autoModel.description', "Auto routes based on your task and real-time system health and model performance."); + const learnMore = localize('autoModel.learnMore', "[Learn More]({0})", autoModelSelectionDocsUrl); + if (typeof discountPercent === 'number' && discountPercent > 0) { + const discount = localize('autoModel.discount', "Models routed via auto receive a {0}% discount.", discountPercent); + return `${base} ${discount} ${learnMore}`; + } + return `${base} ${learnMore}`; + } } export interface ILanguageModelChatResponse { @@ -723,6 +761,11 @@ const CHAT_MODEL_VISIBILITY_STORAGE_KEY = 'chatModelVisibility'; * Auto should never appear in user-curated lists (MRU, pinned). */ const AUTO_MODEL_IDENTIFIER = 'copilot/auto'; + +export function isAutoLanguageModel(model: ILanguageModelChatMetadataAndIdentifier | undefined): boolean { + return model?.metadata.id === 'auto' || model?.identifier === AUTO_MODEL_IDENTIFIER; +} + const CHAT_PARTICIPANT_NAME_REGISTRY_STORAGE_KEY = 'chat.participantNameRegistry'; const CHAT_MODELS_CONTROL_STORAGE_KEY = 'chat.modelsControl'; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 0a540554ce7241..6efac954fa15c5 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -31,7 +31,7 @@ import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCo import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; import { ChatPerfMark, markChat } from '../chatPerf.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatPlanReview, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatInfoMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatAutoModeResolutionPart, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatDisabledClaudeHooksPart, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalEdit, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatInfoMessage, IChatLocationData, IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMcpServersStartingSlow, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatPlanReview, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionTiming, IChatSystemNotificationPart, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsagePromptTokenDetail, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, ToolConfirmKind, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; import { ChatPlanReviewData } from './chatProgressTypes/chatPlanReviewData.js'; @@ -191,6 +191,7 @@ export type IChatProgressHistoryResponseContent = | IChatMultiDiffDataSerialized | IChatContentInlineReference | IChatProgressMessage + | IChatSystemNotificationPart | IChatCommandButton | IChatWarningMessage | IChatInfoMessage @@ -206,7 +207,8 @@ export type IChatProgressHistoryResponseContent = | IChatHookPart | IChatPullRequestContent | IChatWorkspaceEdit - | IChatExternalEdit; + | IChatExternalEdit + | IChatAutoModeResolutionPart; /** * "Normal" progress kinds that are rendered as parts of the stream of content. @@ -222,6 +224,8 @@ export type IChatProgressResponseContent = | IChatClearToPreviousToolInvocation | IChatMcpServersStarting | IChatMcpServersStartingSerialized + | IChatMcpAuthenticationRequired + | IChatMcpServersStartingSlow | IChatDisabledClaudeHooksPart; export type IChatProgressResponseContentSerialized = Exclude<IChatProgressResponseContent, @@ -230,6 +234,8 @@ export type IChatProgressResponseContentSerialized = Exclude<IChatProgressRespon | IChatTask | IChatMultiDiffData | IChatMcpServersStarting + | IChatMcpAuthenticationRequired + | IChatMcpServersStartingSlow | IChatDisabledClaudeHooksPart >; @@ -612,11 +618,17 @@ class AbstractResponse implements IResponse { case 'hook': case 'multiDiffData': case 'mcpServersStarting': + case 'mcpAuthenticationRequired': + case 'mcpServersStartingSlow': case 'questionCarousel': case 'planReview': case 'disabledClaudeHooks': + case 'autoModeResolution': // Ignore continue; + case 'systemNotification': + segment = { text: part.content.value, isBlock: true }; + break; case 'toolInvocation': case 'toolInvocationSerialized': // Include tool invocations in the copy text diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index 3b92a3d19abb5d..8d2b2276bd5855 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -10,7 +10,7 @@ import { isEqual as _urisEqual } from '../../../../../base/common/resources.js'; import { hasKey } from '../../../../../base/common/types.js'; import { URI, UriComponents } from '../../../../../base/common/uri.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { IChatMarkdownContent, ResponseModelState } from '../chatService/chatService.js'; +import { IChatMarkdownContent, IChatMcpAuthenticationRequired, IChatMcpServersStartingSlow, ResponseModelState } from '../chatService/chatService.js'; import { ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatAgentEditedFileEvent, IChatDataSerializerLog, IChatModel, IChatPendingRequest, IChatProgressResponseContent, IChatRequestModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState, ISerializableChatRequestData, ISerializablePendingRequestData, SerializedChatResponsePart, serializeSendOptions } from './chatModel.js'; @@ -38,7 +38,7 @@ const toJson = <T>(obj: T): T extends { toJSON?(): infer R } ? R : T => { return (cast && typeof cast.toJSON === 'function' ? cast.toJSON() : obj) as any; }; -const responsePartSchema = Adapt.v<IChatProgressResponseContent, SerializedChatResponsePart>( +const responsePartSchema = Adapt.v<Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow>, SerializedChatResponsePart>( (obj): SerializedChatResponsePart => obj.kind === 'markdownContent' ? obj.content : toJson(obj), (a, b) => { if (isMarkdownString(a) && isMarkdownString(b)) { @@ -76,6 +76,7 @@ const responsePartSchema = Adapt.v<IChatProgressResponseContent, SerializedChatR case 'markdownVuln': case 'notebookEditGroup': case 'progressMessage': + case 'systemNotification': case 'pullRequest': case 'questionCarousel': case 'planReview': @@ -86,6 +87,7 @@ const responsePartSchema = Adapt.v<IChatProgressResponseContent, SerializedChatR case 'workspaceEdit': case 'externalEdit': case 'disabledClaudeHooks': + case 'autoModeResolution': return a.kind === b.kind; default: { @@ -137,8 +139,7 @@ const requestSchema = Adapt.object<IChatRequestModel, ISerializableChatRequestDa isHidden: Adapt.v(() => undefined), // deprecated, always undefined for new data isCanceled: Adapt.v(() => undefined), // deprecated, modelState is used instead - // response parts (from ISerializableChatResponseData via response.toJSON()) - response: Adapt.t(m => m.response?.entireResponse.value, Adapt.array(responsePartSchema)), + response: Adapt.t(m => m.response?.entireResponse.value.filter((p): p is Exclude<IChatProgressResponseContent, IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow> => p.kind !== 'mcpAuthenticationRequired' && p.kind !== 'mcpServersStartingSlow'), Adapt.array(responsePartSchema)), responseId: Adapt.v(m => m.response?.id), result: Adapt.v(m => m.response?.result, objectsEqual), responseMarkdownInfo: Adapt.v( diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index 1e88e91aabe4b5..9ca8b73e07cf91 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -13,7 +13,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { ChatAgentVoteDirection, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatFollowup, IChatMcpServersStarting, IChatPlanReview, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsage, IChatUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatFollowup, IChatMcpAuthenticationRequired, IChatMcpServersStarting, IChatMcpServersStartingSlow, IChatPlanReview, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsage, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { IChatModel, IChatProgressRenderableResponseContent, IChatRequestDisablement, IChatRequestModel, IChatResponseModel, IChatTextEditGroup, IResponse } from './chatModel.js'; @@ -32,6 +32,31 @@ export function isPendingDividerVM(item: unknown): item is IChatPendingDividerVi return !!item && typeof item === 'object' && (item as IChatPendingDividerViewModel).kind === 'pendingDivider'; } +interface IChatViewModelItemWithPendingState { + readonly id: string; + readonly kind?: string; + readonly pendingKind?: ChatRequestQueueKind; +} + +function isPendingChatViewModelItem(item: IChatViewModelItemWithPendingState): boolean { + return item.kind === 'pendingDivider' || item.pendingKind !== undefined; +} + +/** + * The active response that content streams into: the last non-pending item, ignoring + * trailing queued/steering rows (and their dividers). Falls back to the last item when + * everything is pending. + */ +export function getStickyScrollTargetItem<T extends IChatViewModelItemWithPendingState>(items: readonly T[]): T | undefined { + for (let i = items.length - 1; i >= 0; i--) { + const item = items[i]; + if (!isPendingChatViewModelItem(item)) { + return item; + } + } + return items.at(-1); +} + export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel { return isRequestVM(item) || isResponseVM(item); } @@ -95,6 +120,7 @@ export interface IChatRequestViewModel { readonly shouldBeBlocked: IObservable<boolean>; readonly attachedContext?: readonly IChatRequestVariableEntry[]; readonly modelId?: string; + readonly resolvedModelId?: string; readonly timestamp: number; /** The kind of pending request, or undefined if not pending */ readonly pendingKind?: ChatRequestQueueKind; @@ -200,10 +226,16 @@ export interface IChatChangesSummaryPart { readonly sessionResource: URI; } +export interface IChatTurnPillsPart { + readonly kind: 'turnPills'; + readonly requestId: string; + readonly sessionResource: URI; +} + /** * Type for content parts rendered by IChatListRenderer (not necessarily in the model) */ -export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatQuestionCarousel | IChatPlanReview | IChatDisabledClaudeHooksPart; +export type IChatRendererContent = IChatProgressRenderableResponseContent | IChatReferences | IChatCodeCitations | IChatErrorDetailsPart | IChatChangesSummaryPart | IChatWorkingProgress | IChatMcpServersStarting | IChatMcpAuthenticationRequired | IChatMcpServersStartingSlow | IChatQuestionCarousel | IChatPlanReview | IChatDisabledClaudeHooksPart | IChatTurnPillsPart; export interface IChatResponseViewModel { readonly model: IChatResponseModel; @@ -500,6 +532,11 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.modelId; } + get resolvedModelId() { + const resolvedModel = this._model.response?.result?.metadata?.resolvedModel; + return typeof resolvedModel === 'string' ? resolvedModel : undefined; + } + get timestamp() { return this._model.timestamp; } @@ -636,6 +673,11 @@ export class ChatResponseViewModel extends Disposable implements IChatResponseVi } get isLast(): boolean { + // NOTE: this is used in `dataId` to force a re-render when the response transitions + // between being the last row and not, e.g. when a queued/steering row is added below + // it. It must reflect the actual last row so the row re-renders and drops the + // reserved-space filler class. Progressive rendering targets the streaming response + // separately (see `getStickyScrollTargetItem`). return this.session.getItems().at(-1) === this; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md index c73142d629f4f2..3545048a6a039b 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md +++ b/src/vs/workbench/contrib/chat/common/plugins/AGENTS_PLUGINS.md @@ -125,7 +125,7 @@ Auto-detection logic: if a `.plugin/plugin.json` manifest exists, the Open Plugi Manages the catalog of available and installed plugins: -- **Fetch** — reads `chat.pluginMarketplaces` config (GitHub shorthand, Git URLs, or file URIs), fetches `marketplace.json` from each, and returns parsed `IMarketplacePlugin` entries. +- **Fetch** — reads `chat.plugins.marketplaces` config (GitHub shorthand, Git URLs, or file URIs), fetches `marketplace.json` from each, and returns parsed `IMarketplacePlugin` entries. - **Installed storage** — persists installed plugins in application-scoped storage (`chat.plugins.installed.v1`). Each entry tracks `{ pluginUri, plugin, enabled }`. - **Trust** — marketplace canonical IDs must be explicitly trusted before install proceeds (`chat.plugins.trustedMarketplaces.v1`). - **Auto-update** — checks for upstream changes approximately every 24 hours when `extensions.autoUpdate` is enabled; sets `hasUpdatesAvailable` observable. @@ -144,6 +144,7 @@ Checked in order per repository: Orchestrates install and update workflows: - `installPlugin()` — checks marketplace trust, delegates to the appropriate source strategy to ensure files are locally available, and registers the plugin in installed storage. +- `installPluginFromSource()` — installs from a source string: GitHub shorthand (`owner/repo`), a git clone URL, or a local folder path (`file://` URI, absolute path, or `~`-prefixed path). Local folders are inspected to decide whether they are a marketplace (registered under `chat.plugins.marketplaces`) or a standalone plugin (registered under `chat.pluginLocations`). - `updatePlugin()` / `updateAllPlugins()` — pulls latest changes for cloned repositories and re-runs package-manager installs where applicable. ### Plugin Source Strategies (IPluginSource) diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts index 5c7a10cd0cadcc..10add18c1cd778 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginEnablement.ts @@ -68,15 +68,21 @@ export function getCanonicalAgentPluginCollisionGroups(discoveries: readonly IDi return groups; } +/** + * Whether the `ChatEnabledPlugins` enterprise policy explicitly blocks this plugin. + * + * The policy is a **deny list**, not an allowlist: enterprise-managed `enabledPlugins` entries + * add to (never replace) the plugins a user has installed. A plugin is blocked only when its + * policy id is mapped to `false`; entries mapped to `true` enable the plugin, and a plugin the + * policy never mentions is left to the user's own enablement. This keeps a customer's existing + * plugins working when their enterprise deploys `enabledPlugins` for a different plugin. + */ export function isAgentPluginBlockedByPolicy( plugin: IAgentPlugin, enabledPluginsPolicy: Record<string, boolean> | undefined, ): boolean { const pluginId = getAgentPluginPolicyId(plugin); - if (enabledPluginsPolicy && Object.keys(enabledPluginsPolicy).length > 0) { - return pluginId !== undefined && enabledPluginsPolicy[pluginId] !== true; - } - return false; + return pluginId !== undefined && enabledPluginsPolicy?.[pluginId] === false; } export function getAgentPluginPolicyId(plugin: IAgentPlugin): string | undefined { diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index a80f835d35cbc6..99bf6d55053399 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -159,7 +159,7 @@ export class AgentPluginService extends Disposable implements IAgentPluginServic for (const plugin of plugins) { const blocked = isAgentPluginBlockedByPolicy(plugin, policy); if (setPolicyBlocked(plugin, blocked, tx) && blocked) { - logService.debug(`[AgentPluginService] Plugin '${getAgentPluginPolicyId(plugin) ?? plugin.uri.toString()}' blocked — not enabled by ChatEnabledPlugins policy`); + logService.debug(`[AgentPluginService] Plugin '${getAgentPluginPolicyId(plugin) ?? plugin.uri.toString()}' blocked — disabled by ChatEnabledPlugins policy`); } } }); diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts index 3174d30853ebc0..7458cdc42ccd0f 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginInstallService.ts @@ -63,14 +63,20 @@ export interface IPluginInstallService { /** * Installs a plugin directly from a source location string. Accepts - * GitHub shorthand (`owner/repo`) or a full git clone URL. Clones the - * repository, reads marketplace metadata to discover plugins, and - * registers the selected plugin. + * GitHub shorthand (`owner/repo`), a full git clone URL, or a local + * folder path (`file://` URI, absolute path, or `~`-prefixed path). + * For git sources, clones the repository, reads marketplace metadata to + * discover plugins, and registers the selected plugin. For local folders, + * detects whether the folder is a marketplace or a standalone plugin and + * registers it under the appropriate configuration. * - * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets - * a specific plugin, installs it, and returns it. + * Returns a result with an optional error message (e.g. invalid source or + * no plugins found); callers are responsible for surfacing it. When + * {@link IInstallPluginFromSourceOptions.plugin} is set, targets a specific + * plugin, installs it, and returns it in + * {@link IInstallPluginFromSourceResult.matchedPlugin}. */ - installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<void>; + installPluginFromSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult>; /** * Synchronously validates the format of a plugin source string. @@ -78,17 +84,6 @@ export interface IPluginInstallService { */ validatePluginSource(source: string): string | undefined; - /** - * Installs a plugin from an already-validated source string. - * Handles trust, cloning, scanning, and registration. Returns a result - * with an optional error message (e.g. no plugins found). - * - * When {@link IInstallPluginFromSourceOptions.plugin} is set, targets - * a specific plugin, installs it, and returns it in - * {@link IInstallPluginFromSourceResult.matchedPlugin}. - */ - installPluginFromValidatedSource(source: string, options?: IInstallPluginFromSourceOptions): Promise<IInstallPluginFromSourceResult>; - /** * Pulls the latest changes for an already-cloned marketplace repository. */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 761c0e2b2da8bf..beba468785f23a 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -199,6 +199,14 @@ export interface IPluginMarketplaceService { * root. */ readSinglePluginManifest(repoDir: URI, reference: IMarketplaceReference): Promise<IMarketplacePlugin | undefined>; + /** + * Returns whether the given directory is a standalone plugin — i.e. it + * contains a single-plugin manifest (e.g. `.plugin/plugin.json`, + * `.claude-plugin/plugin.json`, or `plugin.json`) at its root but is not a + * marketplace. Used by direct-install flows to route a local folder to the + * appropriate configuration. + */ + isPluginDirectory(repoDir: URI): Promise<boolean>; } /** @@ -889,6 +897,15 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke return undefined; } + async isPluginDirectory(repoDir: URI): Promise<boolean> { + for (const def of SINGLE_PLUGIN_MANIFEST_DEFINITIONS) { + if (await this._fileService.exists(joinPath(repoDir, def.path))) { + return true; + } + } + return false; + } + private async _readPluginsFromDirectory(repoDir: URI, reference: IMarketplaceReference, token?: CancellationToken): Promise<IMarketplacePlugin[]> { return this._readPluginsFromDefinitions(reference, async (defPath) => { if (token?.isCancellationRequested) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index eba207145d1558..2d4ad587bc07e0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -16,7 +16,7 @@ import { Selection } from '../../../../../../editor/common/core/selection.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { MARKERS_OWNER_ID } from './promptValidator.js'; +import { MARKERS_OWNER_ID, PromptValidatorMarkerCode } from './promptValidator.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { CodeActionKind } from '../../../../../../editor/contrib/codeAction/common/types.js'; import { getTarget, isVSCodeOrDefaultTarget } from './promptFileAttributes.js'; @@ -48,11 +48,13 @@ export class PromptCodeActionProvider implements CodeActionProvider { switch (promptType) { case PromptsType.agent: this.getUpdateToolsCodeActions(promptAST, promptType, model, range, result); + this.getEnableMcpServerCodeActions(model, range, result); await this.getMigrateModeFileCodeActions(model, result); break; case PromptsType.prompt: this.getUpdateModeCodeActions(promptAST, model, range, result); this.getUpdateToolsCodeActions(promptAST, promptType, model, range, result); + this.getEnableMcpServerCodeActions(model, range, result); break; } @@ -71,16 +73,107 @@ export class PromptCodeActionProvider implements CodeActionProvider { return markers.filter(marker => range.containsRange(marker)); } - private createCodeAction(model: ITextModel, range: Range, title: string, edits: Array<IWorkspaceTextEdit | IWorkspaceFileEdit>): CodeAction { + private createCodeAction(model: ITextModel, range: Range, title: string, edits?: Array<IWorkspaceTextEdit | IWorkspaceFileEdit>, command?: { id: string; title: string; arguments?: unknown[] }): CodeAction { return { title, - edit: { edits }, + ...(edits ? { edit: { edits } } : {}), + ...(command ? { command } : {}), ranges: [range], diagnostics: this.getMarkers(model, range), kind: CodeActionKind.QuickFix.value }; } + private getEnableMcpServerCodeActions(model: ITextModel, range: Range, result: CodeAction[]): void { + const markersInRange = this.getMarkersInRange(model, range); + for (const marker of markersInRange) { + const markerCode = this.getMarkerCode(marker); + if (markerCode === PromptValidatorMarkerCode.MissingGithubMcpServer) { + result.push(this.createCodeAction( + model, + range, + localize('enableGithubMcpServerSetting', "Enable Built-in GitHub MCP Server"), + undefined, + { id: 'workbench.action.openSettings', title: '', arguments: ['@id:github.copilot.chat.githubMcpServer.enabled'] } + )); + result.push(this.createCodeAction( + model, + range, + localize('installGithubMcpServer', "Install GitHub MCP Server from Marketplace"), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: ['@mcp github'] } + )); + } else if (markerCode === PromptValidatorMarkerCode.MissingPlaywrightMcpServer) { + result.push(this.createCodeAction( + model, + range, + localize('installPlaywrightMcpServer', "Install Playwright MCP Server from Marketplace"), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: ['@mcp playwright'] } + )); + } else if (markerCode === PromptValidatorMarkerCode.UnknownExtensionReference) { + const reference = model.getValueInRange(new Range(marker.startLineNumber, marker.startColumn, marker.endLineNumber, marker.endColumn)).trim(); + const extensionId = reference.split('/')[0].replace(/^['"]|['"]$/g, ''); + if (extensionId) { + result.push(this.createCodeAction( + model, + range, + localize('searchExtensionMarketplace', "Search Marketplace for Extension '{0}'", extensionId), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: [`@id:${extensionId}`] } + )); + } + } else if (markerCode === PromptValidatorMarkerCode.UnknownMcpServerReference) { + const reference = model.getValueInRange(new Range(marker.startLineNumber, marker.startColumn, marker.endLineNumber, marker.endColumn)).trim(); + const serverId = reference.replace(/^['"]|['"]$/g, ''); + if (serverId) { + result.push(this.createCodeAction( + model, + range, + localize('searchMcpServerMarketplace', "Search Marketplace for MCP Server '{0}'", serverId), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: [`@mcp ${serverId}`] } + )); + } + } else { + const reference = model.getValueInRange(new Range(marker.startLineNumber, marker.startColumn, marker.endLineNumber, marker.endColumn)).trim(); + if (reference) { + const extensionId = reference.split('/')[0].replace(/^['"]|['"]$/g, ''); + result.push(this.createCodeAction( + model, + range, + localize('searchExtensionMarketplaceGeneric', "Search Marketplace for Extension '{0}'", extensionId), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: [`@id:${extensionId}`] } + )); + const serverId = reference.replace(/^['"]|['"]$/g, ''); + result.push(this.createCodeAction( + model, + range, + localize('searchMcpServerMarketplaceGeneric', "Search Marketplace for MCP Server '{0}'", serverId), + undefined, + { id: 'workbench.extensions.search', title: '', arguments: [`@mcp ${serverId}`] } + )); + } + } + } + } + + private getMarkerCode(marker: IMarkerData): string | undefined { + if (!marker.code) { + return undefined; + } + return typeof marker.code === 'string' ? marker.code : marker.code.value; + } + + private getMarkersInRange(model: ITextModel, range: Range): IMarkerData[] { + const markers = this.markerService.read({ resource: model.uri, owner: MARKERS_OWNER_ID }); + return markers.filter(marker => { + const markerRange = new Range(marker.startLineNumber, marker.startColumn, marker.endLineNumber, marker.endColumn); + return markerRange.intersectRanges(range); + }); + } + private getUpdateModeCodeActions(promptFile: ParsedPromptFile, model: ITextModel, range: Range, result: CodeAction[]): void { const modeAttr = promptFile.header?.getAttribute(PromptHeaderAttributes.mode); if (!modeAttr?.range.containsRange(range)) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index d3be4e1e9dc811..06a362924812fc 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -29,6 +29,14 @@ import { ILogService } from '../../../../../../platform/log/common/log.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; +export const enum PromptValidatorMarkerCode { + MissingGithubMcpServer = 'promptValidator.missingGithubMcpServer', + MissingPlaywrightMcpServer = 'promptValidator.missingPlaywrightMcpServer', + UnknownExtensionReference = 'promptValidator.unknownExtensionReference', + UnknownMcpServerReference = 'promptValidator.unknownMcpServerReference', + UnknownExtensionOrMcpServerReference = 'promptValidator.unknownExtensionOrMcpServerReference' +} + export class PromptValidator { constructor( @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @@ -200,7 +208,17 @@ export class PromptValidator { } } } else { - report(toMarker(localize('promptValidator.unknownVariableReference', "Unknown tool or toolset '{0}'.", variable.name), variable.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); + const missingGithubServerMarker = this.getMissingGithubMcpServerMarker(variable.name, variable.range); + if (missingGithubServerMarker) { + report(missingGithubServerMarker); + } else { + const missingPlaywrightServerMarker = this.getMissingPlaywrightMcpServerMarker(variable.name, variable.range); + if (missingPlaywrightServerMarker) { + report(missingPlaywrightServerMarker); + } else { + report(this.getUnknownToolMarker(variable.name, variable.range, true)); + } + } } } else if (headerToolsMap) { const tool = this.languageModelToolsService.getToolByFullReferenceName(variable.name); @@ -527,7 +545,17 @@ export class PromptValidator { report(toMarker(localize('promptValidator.toolDeprecatedMultipleNames', "Tool or toolset '{0}' has been renamed, use the following tools instead: {1}", item.value, newNames), item.range, MarkerSeverity.Info, [MarkerTag.Deprecated])); } } else { - report(toMarker(localize('promptValidator.toolNotFound', "Unknown tool '{0}' will be ignored.", item.value), item.range, MarkerSeverity.Hint, [MarkerTag.Unnecessary])); + const missingGithubServerMarker = this.getMissingGithubMcpServerMarker(item.value, item.range); + if (missingGithubServerMarker) { + report(missingGithubServerMarker); + } else { + const missingPlaywrightServerMarker = this.getMissingPlaywrightMcpServerMarker(item.value, item.range); + if (missingPlaywrightServerMarker) { + report(missingPlaywrightServerMarker); + } else { + report(this.getUnknownToolMarker(item.value, item.range, false)); + } + } } } } @@ -535,6 +563,97 @@ export class PromptValidator { } } + private getUnknownToolMarker(toolReferenceName: string, range: Range, isVariableReference: boolean): IMarkerData { + const splitBySlash = toolReferenceName.split('/'); + const slashCount = splitBySlash.length - 1; + const hasExtensionLikeName = splitBySlash[0].includes('.'); + if (slashCount >= 2) { + return toMarker( + localize( + 'promptValidator.unknownMcpServerReference', + "Unknown tool '{0}'. It is likely to be a missing MCP server, please ensure it is installed and enabled.", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.UnknownMcpServerReference + ); + } + if (hasExtensionLikeName) { + return toMarker( + localize( + 'promptValidator.unknownExtensionReference', + "Unknown extension tool '{0}'. It is likely to be a missing extension, please ensure it is installed and enabled.", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.UnknownExtensionReference + ); + } + if (isVariableReference) { + return toMarker( + localize( + 'promptValidator.unknownVariableReference', + "Unknown tool or toolset '{0}'.", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.UnknownExtensionOrMcpServerReference + ); + } else { + return toMarker( + localize( + 'promptValidator.unknownToolReference', + "Unknown tool '{0}' will be ignored.", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.UnknownExtensionOrMcpServerReference + ); + } + } + + private getMissingGithubMcpServerMarker(toolReferenceName: string, range: Range): IMarkerData | undefined { + if (toolReferenceName !== 'github/*') { + return undefined; + } + return toMarker( + localize( + 'promptValidator.missingGithubMcpServer', + "Tool alias '{0}' requires the GitHub MCP server. Enable the built-in server with setting 'github.copilot.chat.githubMcpServer.enabled' or install extension 'io.github.github/github-mcp-server' from Extensions (`@mcp github`).", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.MissingGithubMcpServer + ); + } + + private getMissingPlaywrightMcpServerMarker(toolReferenceName: string, range: Range): IMarkerData | undefined { + if (toolReferenceName !== 'playwright/*') { + return undefined; + } + return toMarker( + localize( + 'promptValidator.missingPlaywrightMcpServer', + "Tool alias '{0}' requires the Playwright MCP server. Install it from Extensions (`@mcp playwright`).", + toolReferenceName + ), + range, + MarkerSeverity.Hint, + [MarkerTag.Unnecessary], + PromptValidatorMarkerCode.MissingPlaywrightMcpServer + ); + } + private validateApplyTo(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.applyTo); if (!attribute) { @@ -1223,6 +1342,6 @@ export function getTarget(promptType: PromptsType, header: PromptHeader | URI): return Target.Undefined; } -function toMarker(message: string, range: Range, severity = MarkerSeverity.Error, tags?: MarkerTag[]): IMarkerData { - return { severity, message, ...(tags ? { tags } : {}), ...range }; +function toMarker(message: string, range: Range, severity = MarkerSeverity.Error, tags?: MarkerTag[], code?: string): IMarkerData { + return { severity, message, ...(tags ? { tags } : {}), ...(code ? { code } : {}), ...range }; } 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 27a21895735ba4..c42f7ee6c49556 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -662,6 +662,11 @@ export interface IPromptsService extends IDisposable { */ readonly onDidChangeInstructions: Event<void>; + /** + * Event that is triggered when the list of agent instruction files changes. + */ + readonly onDidChangeAgentInstructions: Event<void>; + /** * Finds all available custom agents */ 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 b9cefc17db8fe7..de333cff407934 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -8,7 +8,7 @@ import { CancellationError, isCancellationError } from '../../../../../../base/c import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ParseError, parse as parseJSONC } from '../../../../../../base/common/json.js'; import { getParseErrorMessage } from '../../../../../../base/common/jsonErrorMessages.js'; -import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { autorun, IReader } from '../../../../../../base/common/observable.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; @@ -84,6 +84,19 @@ export class PromptsService extends Disposable implements IPromptsService { * Cached instructions. */ private readonly cachedInstructions: CachedPromise<IInstructionDiscoveryInfo>; + private readonly agentInstructionsWatcher = this._register(new MutableDisposable<IDisposable>()); + private readonly _onDidChangeAgentInstructions = this._register(new Emitter<void>({ + onWillAddFirstListener: () => { + const store = new DisposableStore(); + const agentInstructionsUpdatedEvent = this.fileLocator.createAgentInstructionsUpdatedEvent(); + store.add(agentInstructionsUpdatedEvent); + store.add(agentInstructionsUpdatedEvent.event(() => this._onDidChangeAgentInstructions.fire())); + this.agentInstructionsWatcher.value = store; + }, + onDidRemoveLastListener: () => { + this.agentInstructionsWatcher.clear(); + } + })); /** * Synchronous mirror of the names exposed by {@link getPromptSlashCommands}, @@ -605,6 +618,10 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedInstructions.onDidChangePromise; } + public get onDidChangeAgentInstructions(): Event<void> { + return this._onDidChangeAgentInstructions.event; + } + public async getCustomAgents(token: CancellationToken): Promise<readonly ICustomAgent[]> { const discoveryInfo = await this.cachedCustomAgents.get(token); const result = this.agentsFromDiscoveryInfo(discoveryInfo); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index ed80727c467e8a..7bc35064457017 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -7,12 +7,12 @@ import { URI } from '../../../../../../base/common/uri.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; -import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; +import { FileOperation, FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; import { basename, dirname, isEqual, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, COPILOT_CONFIG_FOLDER, GITHUB_CONFIG_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; import { PromptFileSource, PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -22,7 +22,7 @@ import { isCancellationError } from '../../../../../../base/common/errors.js'; import { AgentInstructionFileType, IPromptPath, IAgentInstructionFile, Logger, PromptsStorage } from '../service/promptsService.js'; import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { equalsIgnoreCase } from '../../../../../../base/common/strings.js'; @@ -265,6 +265,113 @@ export class PromptFilesLocator { return { event: eventEmitter.event, dispose: () => disposables.dispose() }; } + public createAgentInstructionsUpdatedEvent(): { readonly event: Event<void>; dispose: () => void } { + const disposables = new DisposableStore(); + const eventEmitter = disposables.add(new Emitter<void>()); + const cts = new CancellationTokenSource(); + disposables.add(toDisposable(() => cts.dispose(true))); + const token = cts.token; + const watchers = disposables.add(new DisposableStore()); + const watchedRoots = new ResourceSet(); + + const addWatch = (resource: URI) => { + if (token.isCancellationRequested) { + return; + } + if (watchedRoots.has(resource)) { + return; + } + + watchedRoots.add(resource); + watchers.add(this.fileService.watch(resource)); + }; + + const updateWatchers = async () => { + watchers.clear(); + watchedRoots.clear(); + + const watchWorkspaceRoots = this.configService.getValue(PromptsConfig.USE_AGENT_MD) || this.configService.getValue(PromptsConfig.USE_CLAUDE_MD); + const watchClaudeFolders = this.configService.getValue(PromptsConfig.USE_CLAUDE_MD); + const watchCopilotFolders = this.configService.getValue(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES); + const includeParents = this.configService.getValue(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) === true; + const workspaceRoots = await this.getWorkspaceFolderRoots(includeParents); + if (token.isCancellationRequested) { + return; + } + const userHome = await this.pathService.userHome(); + if (token.isCancellationRequested) { + return; + } + + for (const workspaceRoot of workspaceRoots) { + if (watchWorkspaceRoots) { + addWatch(workspaceRoot); + } + if (watchClaudeFolders) { + addWatch(joinPath(workspaceRoot, CLAUDE_CONFIG_FOLDER)); + } + if (watchCopilotFolders) { + addWatch(joinPath(workspaceRoot, GITHUB_CONFIG_FOLDER)); + } + } + + if (watchClaudeFolders) { + addWatch(joinPath(userHome, CLAUDE_CONFIG_FOLDER)); + } + if (watchCopilotFolders) { + addWatch(joinPath(userHome, COPILOT_CONFIG_FOLDER)); + } + }; + + const refresh = () => { + void updateWatchers(); + eventEmitter.fire(); + }; + + disposables.add(this.configService.onDidChangeConfiguration(e => { + if ( + e.affectsConfiguration(PromptsConfig.USE_AGENT_MD) || + e.affectsConfiguration(PromptsConfig.USE_CLAUDE_MD) || + e.affectsConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES) || + e.affectsConfiguration(PromptsConfig.USE_CUSTOMIZATIONS_IN_PARENT_REPOS) + ) { + refresh(); + } + })); + disposables.add(this.onDidChangeWorkspaceFolders()(() => { + refresh(); + })); + disposables.add(this.workspaceTrustManagementService.onDidChangeTrustedFolders(() => { + refresh(); + })); + disposables.add(this.fileService.onDidFilesChange(e => { + for (const watchedRoot of watchedRoots) { + if (e.affects(watchedRoot)) { + eventEmitter.fire(); + return; + } + } + })); + disposables.add(this.fileService.onDidRunOperation(e => { + for (const watchedRoot of watchedRoots) { + if (isEqualOrParent(e.resource, watchedRoot)) { + eventEmitter.fire(); + return; + } + if (e.isOperation(FileOperation.CREATE) || e.isOperation(FileOperation.MOVE) || e.isOperation(FileOperation.COPY)) { + if (isEqualOrParent(e.target.resource, watchedRoot)) { + eventEmitter.fire(); + return; + } + } + } + })); + + void updateWatchers(); + + return { event: eventEmitter.event, dispose: () => disposables.dispose() }; + } + /** * Gets the hook source folders for creating new hooks. * Returns configured hook folders, excluding Claude paths (which are read-only). diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index e6733330b8c4f8..f7135381b83eca 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -25,7 +25,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { IProgress } from '../../../../../platform/progress/common/progress.js'; import { ChatRequestToolReferenceEntry } from '../attachments/chatVariableEntries.js'; import { IVariableReference } from '../chatModes.js'; -import { IChatAgentFeedbackReviewConfirmationData, IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatSearchToolInvocationData, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; +import { ConfirmedReason, IChatAgentFeedbackReviewConfirmationData, IChatExtensionsContent, IChatModifiedFilesConfirmationData, IChatSearchToolInvocationData, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, type IChatTerminalToolInvocationData } from '../chatService/chatService.js'; import { ILanguageModelChatMetadata, LanguageModelPartAudience } from '../languageModels.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; import { PromptElementJSON, stringifyPromptElementJSON } from './promptTsxTypes.js'; @@ -197,6 +197,16 @@ export interface IToolInvocation { selectedCustomButton?: string; /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ preToolUseResult?: IExternalPreToolUseHookResult; + /** + * A confirmation reason resolved out-of-band by the caller (e.g. the agent + * host, which decides auto-approval server-side). When set, the invocation + * is treated as already auto-approved and transitions straight to executing + * without ever entering the `WaitingForConfirmation` state. This avoids a + * transient "needs input" flicker in surfaces (like the sessions list) that + * observe pending confirmations, for tool calls that will be auto-approved + * anyway. + */ + preApproved?: ConfirmedReason; /** * Optional W3C trace context `traceparent` value identifying the parent distributed * tracing span for this tool invocation. Forwarded to MCP tool implementations as diff --git a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts index 4cad096d542f24..6083ab09ccc0a0 100644 --- a/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts +++ b/src/vs/workbench/contrib/chat/common/voiceClient/voiceClientService.ts @@ -53,6 +53,39 @@ export interface IVoiceSessionInit { readonly sessionId: string; } +/** + * Client turn-endpointing configuration sent to the backend. Serialized + * verbatim into the ``turn_config`` object on ``start_session`` / + * ``resume_session`` and the ``set_turn_config`` live-update event, so the + * field names are snake_case to match the wire contract (same convention as + * ``IVoiceSessionContext``). + */ +export interface IVoiceTurnConfig { + /** How (if at all) the backend ends a held turn on its own. */ + readonly auto_end_mode: 'off' | 'vad' | 'phrase' | 'both'; + /** Trailing silence (ms) before VAD ends the turn; used when mode is ``vad``/``both``. The server clamps. */ + readonly silence_ms: number; + /** Phrases matched at the end of the transcript; the server normalizes and strips them. */ + readonly stop_phrases: readonly string[]; + /** Tri-state: ``true``/``false`` force ASR gating on/off; ``null`` lets the server derive it. */ + readonly vad_gate_asr: boolean | null; +} + +/** Why the backend ended the turn on its own. */ +export type IVoiceTurnAutoEndReason = 'vad_silence' | 'stop_phrase'; + +/** + * Emitted when the backend ends a held turn itself (server VAD silence or a + * matched stop phrase) while the user is still "holding" push-to-talk. The + * consumer must treat this like a local ``ptt_end`` — stop capturing/streaming + * and clear the recording UI — but MUST NOT send its own ``ptt_end`` for the + * turn. ``turnId`` guards against double-ending. + */ +export interface IVoiceTurnAutoEnded { + readonly reason: IVoiceTurnAutoEndReason; + readonly turnId: string; +} + /** * One entry in the cross-session timeline the FE replays to the BE on * ``start_session``. The BE's coding_agent renders these into a @@ -126,6 +159,13 @@ export interface IVoiceClientService { sendPttStart(turnId: string): void; sendPttAudioChunk(audio: string): void; sendPttEnd(): void; + /** + * Barge-in: stream raw mic audio while the assistant speaks (hands-free) so + * the backend can detect the user talking over it. Not a turn; not transcribed. + */ + sendBargeInStart(): void; + sendBargeInAudioChunk(audio: string): void; + sendBargeInStop(): void; /** * Send a per-press post-mortem diagnostic payload for tail-loss * investigation. Fired ~500ms after `pttUp` by the mic service. @@ -177,6 +217,12 @@ export interface IVoiceClientService { readonly onSessionInit: Event<IVoiceSessionInit>; readonly onError: Event<string>; readonly onDidChangeConnectionState: Event<boolean>; + /** + * Fired when the backend ends a held turn on its own (server VAD silence or + * a matched stop phrase). Consumers stop capturing for that turn and clear + * the recording UI without sending their own ``ptt_end``. + */ + readonly onTurnAutoEnded: Event<IVoiceTurnAutoEnded>; // --- State --- readonly isConnected: boolean; diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.ts index 41d348a66a39d1..266ff09f8bb9c9 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/debugAgentHostAction.ts @@ -8,7 +8,8 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { localize, localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; -import { AgentHostEnabledSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { INativeHostService } from '../../../../../platform/native/common/native.js'; import { INotificationService } from '../../../../../platform/notification/common/notification.js'; @@ -26,7 +27,7 @@ export class DebugAgentHostInDevToolsAction extends Action2 { icon: Codicon.debugStart, precondition: ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.equals(`config.${AgentHostEnabledSettingId}`, true), + AGENT_HOST_ENABLED_CONTEXT_KEY, ), }); } diff --git a/src/vs/workbench/contrib/chat/electron-browser/actions/profileAgentHostAction.ts b/src/vs/workbench/contrib/chat/electron-browser/actions/profileAgentHostAction.ts index c92f726dbe9336..30fa8d762b99f1 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/actions/profileAgentHostAction.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/actions/profileAgentHostAction.ts @@ -13,7 +13,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2 } from '../../../../../platform/actions/common/actions.js'; -import { AgentHostEnabledSettingId, IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { AGENT_HOST_ENABLED_CONTEXT_KEY } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from '../../../../../platform/contextkey/common/contextkey.js'; import { IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -252,7 +253,7 @@ export class ProfileAgentHostAction extends Action2 { IsSessionsWindowContext, ContextKeyExpr.and( ChatContextKeys.enabled, - ContextKeyExpr.equals(`config.${AgentHostEnabledSettingId}`, true), + AGENT_HOST_ENABLED_CONTEXT_KEY, ), ), CONTEXT_AGENT_HOST_PROFILE_STATE.notEqualsTo(AgentHostProfileState.Starting), diff --git a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts index 32963a68d60c5e..f4d7bc371f58f6 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/builtInTools/fetchPageTool.ts @@ -9,6 +9,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { Iterable } from '../../../../../base/common/iterator.js'; import { ResourceSet } from '../../../../../base/common/map.js'; import { extname } from '../../../../../base/common/path.js'; +import { normalizePath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; @@ -235,12 +236,17 @@ export class FetchWebPageTool implements IToolImpl { let confirmationNotNeededReason: string | undefined; if (context.chatSessionResource) { const model = this._chatService.getSession(context.chatSessionResource); - const userMessages = model?.getRequests().map(r => r.message.text.toLowerCase()); + const userMessages = model?.getRequests().map(r => r.message.text) ?? []; + // Collect the resources the user actually referenced by parsing whole + // whitespace-delimited tokens from their messages. Parsing at token granularity + // (rather than a substring match) ensures a `file://` URI embedded inside another + // URL the user pasted — e.g. `https://host/p?u=file:///home/victim/.ssh/id_rsa` — + // is parsed as part of its enclosing web URL and is not mistaken for an explicit + // request for that local file, which would otherwise auto-approve the read. + const referencedResources = collectReferencedResources(userMessages); let urlsMentionedInPrompt = false; for (const uri of urlsNeedingConfirmation) { - // Normalize to lowercase and remove any trailing slash - const toToCheck = uri.toString(true).toLowerCase().replace(/\/$/, ''); - if (userMessages?.some(m => m.includes(toToCheck))) { + if (referencedResources.has(uri)) { urlsNeedingConfirmation.delete(uri); urlsMentionedInPrompt = true; } @@ -297,8 +303,8 @@ export class FetchWebPageTool implements IToolImpl { webUris.set(url, uriObj); } } else { - // Try to handle other schemes via file service - fileUris.set(url, uriObj); + // Normalize `..` so the confirmation-gating workspace check and the eventual read agree on one path. + fileUris.set(url, normalizePath(uriObj)); } } catch (e) { invalidUris.add(url); @@ -361,3 +367,41 @@ export class FetchWebPageTool implements IToolImpl { } } } + +/** + * Matches the start of a URI scheme (RFC 3986: ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"). + * Used as a cheap filter so only scheme-qualified tokens are parsed. + */ +const _schemePrefix = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; + +/** + * Collects the URIs a user explicitly referenced across their chat messages, used to decide + * whether a fetch may skip its confirmation dialog. Each message is split on whitespace and + * scheme-qualified tokens are parsed into URIs; parsing at token granularity is what makes + * this safe — a `file://` URI embedded inside another URL the user pasted (e.g. a + * `?u=file:///…` query parameter) is parsed as part of its enclosing URL and never becomes a + * standalone reference. Membership is compared by {@link ResourceSet} (keyed on `URI.toString()`). + */ +function collectReferencedResources(messages: readonly string[]): ResourceSet { + const resources = new ResourceSet(); + for (const message of messages) { + for (const rawToken of message.split(/\s+/)) { + // Trim common punctuation/brackets a user might type around a URL. + const token = rawToken.replace(/^[<("'`[{]+/, '').replace(/[>)"'`\]},.;]+$/, ''); + // Cheap pre-check: only tokens that start with a URI scheme are worth parsing. + // This avoids using exceptions for control flow on every plain word in a message. + if (!_schemePrefix.test(token)) { + continue; + } + try { + // Strict parsing rejects scheme-less tokens, so only genuine `scheme:…` + // tokens (http, https, file, …) are treated as references. + resources.add(URI.parse(token, true)); + } catch { + // Scheme-like but not a valid URI; ignore. + } + } + } + return resources; +} + diff --git a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts index 39b6e252c2ecd2..6ef2003684059e 100644 --- a/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/electron-browser/chat.contribution.ts @@ -32,14 +32,16 @@ import { IWorkbenchLayoutService } from '../../../services/layout/browser/layout import { ILifecycleService, ShutdownReason } from '../../../services/lifecycle/common/lifecycle.js'; import { ACTION_ID_NEW_CHAT, CHAT_OPEN_ACTION_ID, IChatViewOpenOptions } from '../browser/actions/chatActions.js'; import { AgentHostContribution } from '../browser/agentSessions/agentHost/agentHostChatContribution.js'; +import { AgentHostByokLmHandler } from '../browser/agentSessions/agentHost/agentHostByokLmHandler.js'; import { AgentHostSessionListContribution } from '../browser/agentSessions/agentHost/agentHostSessionListContribution.js'; import { AgentHostTerminalContribution } from '../browser/agentSessions/agentHost/agentHostTerminalContribution.js'; -import { AgentHostCopilotPromptContribution } from '../browser/agentSessions/agentHost/agentHostCopilotPromptContribution.js'; +import { AgentHostCopilotCliSettingsContribution } from '../browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../browser/agentSessions/agentSessions.js'; import { IAgentSessionsService } from '../browser/agentSessions/agentSessionsService.js'; import { ChatViewPaneTarget, IChatWidgetService } from '../browser/chat.js'; import { ChatSessionPosition, openChatSession } from '../browser/chatSessions/chatSessions.contribution.js'; import { IAgentHostService } from '../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostByokLmHandler } from '../../../../platform/agentHost/common/agentHostByokLm.js'; import { type AgentInfo, type RootState } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { IChatService } from '../common/chatService/chatService.js'; @@ -264,10 +266,14 @@ registerWorkbenchContribution2(ChatLifecycleHandler.ID, ChatLifecycleHandler, Wo registerWorkbenchContribution2(AgentHostContribution.ID, AgentHostContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostSessionListContribution.ID, AgentHostSessionListContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(AgentHostTerminalContribution.ID, AgentHostTerminalContribution, WorkbenchPhase.AfterRestored); -registerWorkbenchContribution2(AgentHostCopilotPromptContribution.ID, AgentHostCopilotPromptContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(AgentHostCopilotCliSettingsContribution.ID, AgentHostCopilotCliSettingsContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(OpenWorkspaceInAgentsContribution.ID, OpenWorkspaceInAgentsContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(AgentsHandoffInputTipContribution.ID, AgentsHandoffInputTipContribution, WorkbenchPhase.Eventually); +// Renderer-side BYOK language-model handler that backs the node agent host's +// OpenAI proxy. Lazily instantiated when AgentHostClientByokLmChannel resolves it. +registerSingleton(IAgentHostByokLmHandler, AgentHostByokLmHandler, InstantiationType.Delayed); + // How long to wait for the agent host to surface an AgentInfo before // throwing an error. Long enough for normal startup, short enough to avoid // hanging automation indefinitely if the agent host is disabled or fails diff --git a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts index 411153ef8b93cb..8bfe8e41f51c6a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/actions/chatExecuteActions.test.ts @@ -10,8 +10,10 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { CommandsRegistry } from '../../../../../../platform/commands/common/commands.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; -import { GetHandoffsActionId, ExecuteHandoffActionId, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { type IChatAcceptInputOptions, IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { ChatSubmitAction, ExecuteHandoffActionId, GetHandoffsActionId, registerChatExecuteActions } from '../../../browser/actions/chatExecuteActions.js'; import { IChatMode, IChatModes, IChatModeService, ICustomAgentInfo } from '../../../common/chatModes.js'; import { ChatModeKind } from '../../../common/constants.js'; import { IHandOff } from '../../../common/promptSyntax/promptFileParser.js'; @@ -393,3 +395,53 @@ suite('SwitchToNextPinnedModelAction', () => { await runCommandAsync<void>(handler, instantiationService); }); }); + +suite('ChatSubmitAction', () => { + const store = new DisposableStore(); + let instantiationService: TestInstantiationService; + + let chatExecuteActions: DisposableStore; + suiteSetup(() => { + chatExecuteActions = registerChatExecuteActions(); + }); + + suiteTeardown(() => { + chatExecuteActions.dispose(); + }); + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('passes acceptInputOptions to the widget', async () => { + let acceptedOptions: unknown; + const widget = { + input: { + pendingDelegationTarget: undefined, + } as IChatWidget['input'], + acceptInput: async (_query: string | undefined, options: IChatAcceptInputOptions | undefined) => { + acceptedOptions = options; + return undefined; + }, + } satisfies Partial<IChatWidget>; + + instantiationService.set(ITelemetryService, NullTelemetryService); + instantiationService.set(IChatWidgetService, new MockChatWidgetService()); + + const handler = CommandsRegistry.getCommand(ChatSubmitAction.ID)?.handler; + assert.ok(handler); + + await runCommandAsync<void>(handler, instantiationService, { + widget: widget as IChatWidget, + acceptInputOptions: { cancelCurrentRequest: true }, + }); + + assert.deepStrictEqual(acceptedOptions, { cancelCurrentRequest: true }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts index 0777f22c9fb8c5..7719494db48647 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostResponseFileChanges.test.ts @@ -12,6 +12,7 @@ import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import { buildTurnChangesetUri } from '../../../../../../platform/agentHost/common/changesetUri.js'; +import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ChangesetStatus, StateComponents, type ChangesetState, type SessionState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IEditSessionEntryDiff } from '../../../common/editing/chatEditingService.js'; @@ -22,14 +23,20 @@ class FakeAgentConnection extends mock<IAgentConnection>() { private readonly _emitters = new Map<string, Emitter<unknown>>(); private readonly _values = new Map<string, unknown>(); + private readonly _subscriptionCounts = new Map<string, number>(); setState(resource: string, value: unknown): void { this._values.set(resource, value); this._emitters.get(resource)?.fire(value); } + getSubscriptionCount(resource: string): number { + return this._subscriptionCounts.get(resource) ?? 0; + } + override getSubscription<T extends StateComponents>(_kind: T, resource: URI, _owner: string): IReference<IAgentSubscription<never>> { const key = resource.toString(); + this._subscriptionCounts.set(key, (this._subscriptionCounts.get(key) ?? 0) + 1); let emitter = this._emitters.get(key); if (!emitter) { emitter = new Emitter<unknown>(); @@ -87,12 +94,36 @@ suite('AgentHostResponseFileChangesProvider', () => { } satisfies ChangesetState); const { latest } = observe(provider, ds); - assert.deepStrictEqual(latest().map(d => ({ added: d.added, removed: d.removed, modified: d.modifiedURI.path })), [ - { added: 3, removed: 1, modified: '/repo/a.ts' }, - { added: 5, removed: 0, modified: '/repo/b.ts' }, + assert.deepStrictEqual(latest().map(d => ({ + added: d.added, + removed: d.removed, + modified: d.modifiedURI.path, + // The RHS diff content is the frozen after-turn snapshot, not the live file. + after: d.modifiedSnapshotURI && fromAgentHostUri(d.modifiedSnapshotURI).authority, + })), [ + { added: 3, removed: 1, modified: '/repo/a.ts', after: 'a-after' }, + { added: 5, removed: 0, modified: '/repo/b.ts', after: 'b-after' }, ]); }); + test('keeps the changeset subscription when session state updates', () => { + const ds = store.add(new DisposableStore()); + const conn = new FakeAgentConnection(); + const provider = ds.add(new AgentHostResponseFileChangesProvider(conn, authority, () => backendSession)); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + conn.setState(turnChangesetUri('t1'), { status: ChangesetStatus.Ready, files: [] } satisfies ChangesetState); + observe(provider, ds); + const subscriptionCountBeforeUpdate = conn.getSubscriptionCount(turnChangesetUri('t1')); + + conn.setState(backendSession.toString(), sessionStateWithTurnSupport()); + + assert.deepStrictEqual([ + subscriptionCountBeforeUpdate, + conn.getSubscriptionCount(turnChangesetUri('t1')), + ], [1, 1]); + }); + test('returns empty when the agent does not advertise a turn changeset', () => { const ds = store.add(new DisposableStore()); const conn = new FakeAgentConnection(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index 62330586405383..2479a70a6ef3a5 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -10,7 +10,7 @@ import { type AgentInfo } from '../../../../../../platform/agentHost/common/stat import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; -import { authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; +import { authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; function createMockAuthService(overrides: { getOrActivateProviderIdForServer?: (serverUri: URI, resourceUri: URI) => Promise<string | undefined>; @@ -24,6 +24,31 @@ function createMockAuthService(overrides: { } as unknown as IAuthenticationService; } +suite('agentHostMcpServerId', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('is stable for the same authority, server name and resource url', () => { + // The key must not depend on the (per-session / per-sync) customization id, so remembered + // auth survives reloads. Same inputs must always produce the same key. + const a = agentHostMcpServerId('remote-host', 'GitHub', 'https://api.githubcopilot.com/mcp/'); + const b = agentHostMcpServerId('remote-host', 'GitHub', 'https://api.githubcopilot.com/mcp/'); + assert.strictEqual(a, b); + assert.strictEqual(a, 'agent-host-mcp:remote-host/GitHub/https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F'); + }); + + test('differs when authority, name or url differ', () => { + const base = agentHostMcpServerId('host-1', 'GitHub', 'https://a.example/mcp'); + const keys = new Set([ + base, + agentHostMcpServerId('host-2', 'GitHub', 'https://a.example/mcp'), + agentHostMcpServerId('host-1', 'Other', 'https://a.example/mcp'), + agentHostMcpServerId('host-1', 'GitHub', 'https://b.example/mcp'), + ]); + assert.strictEqual(keys.size, 4); + }); +}); + suite('resolveTokenForResource', () => { const log = new NullLogService(); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts new file mode 100644 index 00000000000000..fffef272da3c5f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostByokLmHandler.test.ts @@ -0,0 +1,207 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../../base/common/event.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; +import { NullLogService } from '../../../../../../platform/log/common/log.js'; +import type { IByokLmChatRequest } from '../../../../../../platform/agentHost/common/agentHostByokLm.js'; +import { AgentHostByokLmHandler } from '../../../browser/agentSessions/agentHost/agentHostByokLmHandler.js'; +import { ChatMessageRole, IChatMessage, IChatResponsePart, ILanguageModelChatMetadata, ILanguageModelChatRequestOptions, ILanguageModelChatResponse, ILanguageModelsService } from '../../../common/languageModels.js'; + +interface ICapturedRequest { + modelId: string; + messages: IChatMessage[]; + options: ILanguageModelChatRequestOptions; +} + +/** + * Fake LM API service: resolves a small fixed model set and replays a + * scripted response stream, capturing what the handler forwarded. Stands in + * for the renderer's real `ILanguageModelsService` so the bridge handler can be + * exercised without any extension or model provider. + */ +class TestLanguageModelsService extends mock<ILanguageModelsService>() { + + captured: ICapturedRequest | undefined; + + override readonly onDidChangeLanguageModels = Event.None; + + constructor( + private readonly _models: ReadonlyMap<string, ILanguageModelChatMetadata>, + private readonly _respond: (request: ICapturedRequest) => ILanguageModelChatResponse, + ) { + super(); + } + + override getLanguageModelIds(): string[] { + return [...this._models.keys()]; + } + + override lookupLanguageModel(modelId: string): ILanguageModelChatMetadata | undefined { + return this._models.get(modelId); + } + + override async sendChatRequest(modelId: string, _from: ExtensionIdentifier | undefined, messages: IChatMessage[], options: ILanguageModelChatRequestOptions, _token: CancellationToken): Promise<ILanguageModelChatResponse> { + this.captured = { modelId, messages, options }; + return this._respond(this.captured); + } +} + +function byokModel(vendor: string, id: string, capabilities?: ILanguageModelChatMetadata['capabilities']): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.byok'), + name: `${vendor} ${id}`, + id, + vendor, + version: '1.0.0', + family: 'test', + maxInputTokens: 1000, + maxOutputTokens: 1000, + isDefaultForLocation: {}, + isBYOK: true, + capabilities, + }; +} + +function responseOf(parts: IChatResponsePart[]): ILanguageModelChatResponse { + return { + stream: (async function* () { + for (const part of parts) { + yield part; + } + })(), + result: Promise.resolve(undefined), + }; +} + +suite('AgentHostByokLmHandler', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHandler(service: ILanguageModelsService): AgentHostByokLmHandler { + return store.add(new AgentHostByokLmHandler(service, new NullLogService())); + } + + test('listModels enumerates renderer BYOK models and excludes agent-host copies', async () => { + const service = new TestLanguageModelsService( + new Map<string, ILanguageModelChatMetadata>([ + ['id-acme', byokModel('acme', 'claude', { vision: true })], + ['id-copy', { ...byokModel('acme', 'claude'), targetChatSessionType: 'copilotcli' }], + ['id-capi', { ...byokModel('copilot', 'gpt-4'), isBYOK: false }], + ]), + () => responseOf([]), + ); + const handler = createHandler(service); + + const models = await handler.listModels(CancellationToken.None); + + assert.deepStrictEqual(models, [ + { vendor: 'acme', id: 'claude', name: 'acme claude', maxContextWindowTokens: 2000, supportsVision: true }, + ]); + }); + + test('resolves the BYOK model and buffers text + tool calls', async () => { + const service = new TestLanguageModelsService( + new Map([['id-acme-claude', byokModel('acme', 'claude')]]), + () => responseOf([ + { type: 'text', value: 'hello ' }, + { type: 'text', value: 'world' }, + { type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }, + ]), + ); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + CancellationToken.None, + ); + + assert.strictEqual(service.captured?.modelId, 'id-acme-claude'); + assert.deepStrictEqual(result, { + content: 'hello world', + toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }], + }); + }); + + test('maps bridge messages to LM API chat messages', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => responseOf([{ type: 'text', value: 'ok' }]), + ); + const handler = createHandler(service); + + await handler.chat( + { + vendor: 'acme', + modelId: 'claude', + messages: [ + { role: 'system', content: 'be helpful' }, + { role: 'user', content: 'hi' }, + { role: 'assistant', content: '', toolCalls: [{ id: 't1', name: 'getWeather', argumentsJson: '{"city":"NYC"}' }] }, + { role: 'tool', content: 'sunny', toolCallId: 't1' }, + ], + }, + CancellationToken.None, + ); + + assert.deepStrictEqual(service.captured?.messages, [ + { role: ChatMessageRole.System, content: [{ type: 'text', value: 'be helpful' }] }, + { role: ChatMessageRole.User, content: [{ type: 'text', value: 'hi' }] }, + { role: ChatMessageRole.Assistant, content: [{ type: 'tool_use', name: 'getWeather', toolCallId: 't1', parameters: { city: 'NYC' } }] }, + // A `tool` message (with a toolCallId) rides on a User-role message and carries its + // payload solely in the tool_result part — no duplicate leading text part. + { role: ChatMessageRole.User, content: [{ type: 'tool_result', toolCallId: 't1', value: [{ type: 'text', value: 'sunny' }] }] }, + ]); + }); + + test('maps a tool message without a toolCallId to a plain user text part', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => responseOf([{ type: 'text', value: 'ok' }]), + ); + const handler = createHandler(service); + + await handler.chat( + { vendor: 'acme', modelId: 'claude', messages: [{ role: 'tool', content: 'orphaned tool output' }] }, + CancellationToken.None, + ); + + assert.deepStrictEqual(service.captured?.messages, [ + { role: ChatMessageRole.User, content: [{ type: 'text', value: 'orphaned tool output' }] }, + ]); + }); + + test('returns an error result when no BYOK model matches', async () => { + const service = new TestLanguageModelsService(new Map(), () => responseOf([])); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'missing', messages: [] } satisfies IByokLmChatRequest, + CancellationToken.None, + ); + + assert.strictEqual(result.content, ''); + assert.ok(result.error?.includes('acme/missing'), `expected error to name the model: ${result.error}`); + }); + + test('returns an error result when the LM request throws', async () => { + const service = new TestLanguageModelsService( + new Map([['id', byokModel('acme', 'claude')]]), + () => { throw new Error('provider exploded'); }, + ); + const handler = createHandler(service); + + const result = await handler.chat( + { vendor: 'acme', modelId: 'claude', messages: [{ role: 'user', content: 'hi' }] }, + CancellationToken.None, + ); + + assert.deepStrictEqual(result, { content: '', error: 'provider exploded' }); + }); +}); 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 68b4643ebc9608..e0af82eac9f8b6 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 @@ -15,30 +15,37 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { timeout } from '../../../../../../base/common/async.js'; import { Range } from '../../../../../../editor/common/core/range.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; +import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { ActionType, isSessionAction, isChatAction, type ActionEnvelope, type IRootConfigChangedAction, type SessionAction, type ChatAction, type TerminalAction, type INotification, type IToolCallConfirmedAction, type ITurnStartedAction, type ClientAnnotationsAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { CustomizationType, type ClientPluginCustomization, type ToolDefinition } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ChatInputAnswerState, ChatInputAnswerValueKind, ChatInputQuestionKind, ChatInputResponseKind, SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, ToolCallContributorKind, createSessionState, createChatState, createDefaultChatSummary, buildChatUri, buildDefaultChatUri, parseDefaultChatUri, isAhpChatChannel, createActiveTurn, isAhpRootChannel, PolicyState, ResponsePartKind, ROOT_STATE_URI, StateComponents, buildSubagentChatUri, ToolResultContentType, MessageAttachmentKind, MessageKind, type SessionState, type SessionSummary, type ChatState, type ISessionWithDefaultChat, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { CompletionItemKind as AhpCompletionItemKind, type CompletionsParams, type CompletionsResult } from '../../../../../../platform/agentHost/common/state/protocol/commands.js'; import { sessionReducer, chatReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IProgress, IProgressNotificationOptions, IProgressService, IProgressStep } from '../../../../../../platform/progress/common/progress.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { ChatEntitlement, IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { IChatAgentData, IChatAgentImplementation, IChatAgentRequest, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; import { ChatRequestQueueKind, ElicitationState, IChatService, IChatMarkdownContent, IChatProgress, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatToolInputInvocationData, IChatToolInvocation, IChatToolInvocationSerialized, IChatUsage, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IChatDebugService } from '../../../common/chatDebugService.js'; import { IChatEditingService } from '../../../common/editing/chatEditingService.js'; import { IChatResponseFileChangesService } from '../../../browser/chatResponseFileChangesService.js'; import { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { IChatSessionsService, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; +import { IChatSessionsService, type IChatSession, type IChatSessionItemController, type IChatSessionRequestHistoryItem, type IChatSessionsExtensionPoint } from '../../../common/chatSessionsService.js'; import { ILanguageModelsService, type ILanguageModelChatMetadata } from '../../../common/languageModels.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IOutputService } from '../../../../../services/output/common/output.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; @@ -53,7 +60,9 @@ import { TestFileService } from '../../../../../test/common/workbenchTestService import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; +import { IRemoteAgentHostService } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { IWorkingCopyService } from '../../../../../services/workingCopy/common/workingCopyService.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; @@ -62,6 +71,7 @@ import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; import { IAgentHostTerminalService } from '../../../../terminal/browser/agentHostTerminalService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { AgentHostNewSessionFolderService, IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { OpenAgentHostFolderPickerAction } from '../../../browser/agentSessions/agentHost/agentHostChatInputPicker.contribution.js'; import { MenuId, MenuRegistry, isIMenuItem, type IMenuItem } from '../../../../../../platform/actions/common/actions.js'; @@ -69,7 +79,7 @@ import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { type ContextKeyValue } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IAgentHostActiveClientService } from '../../../browser/agentSessions/agentHost/agentHostActiveClientService.js'; import { IAgentHostCustomizationService, NullAgentHostCustomizationService } from '../../../browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { IChatWidgetService } from '../../../browser/chat.js'; import { ChatQuestionCarouselData } from '../../../common/model/chatProgressTypes/chatQuestionCarouselData.js'; @@ -77,8 +87,10 @@ import { ChatPlanReviewData } from '../../../common/model/chatProgressTypes/chat import { ChatElicitationRequestPart } from '../../../common/model/chatProgressTypes/chatElicitationRequestPart.js'; import type { IChatModel, IChatModelInputState, IChatPendingRequest, IChatRequestModel, IInputModel } from '../../../common/model/chatModel.js'; import { convertBufferToScreenshotVariable } from '../../../browser/attachments/chatScreenshotContext.js'; -import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, toAgentHostCompletionVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; +import { AgentHostCompletionReferenceKind, ChatPasteAttachmentMetadata, toAgentHostCompletionVariableEntry, type IChatRequestVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { messageAttachmentsToVariableData } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { AgentHostSessionReferenceAttachmentDisplayKind, AgentHostSessionReferenceAttachmentMetadataKey, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, toSessionReferenceModelRepresentation } from '../../../browser/agentSessions/agentHost/agentHostSessionReferenceAttachment.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; // ---- Mock agent host service ------------------------------------------------ @@ -476,13 +488,14 @@ class MockChatWidgetService extends mock<IChatWidgetService>() { readonly clearPlanReviewCalls: { sessionResource: URI; responseId: string | undefined; resolveId: string | undefined }[] = []; private readonly _widgets = new Map<string, ReturnType<IChatWidgetService['getWidgetBySessionResource']>>(); - setWidgetForSession(sessionResource: URI): void { + setWidgetForSession(sessionResource: URI, implicitContextValues?: readonly Partial<IChatRequestVariableEntry>[]): void { // eslint-disable-next-line local/code-no-any-casts this._widgets.set(sessionResource.toString(), { input: { clearQuestionCarousel: (responseId?: string, resolveId?: string) => { this.clearQuestionCarouselCalls.push({ sessionResource, responseId, resolveId }); }, + implicitContext: implicitContextValues ? { values: implicitContextValues } : undefined, clearPlanReview: (responseId?: string, resolveId?: string) => { this.clearPlanReviewCalls.push({ sessionResource, responseId, resolveId }); }, @@ -495,9 +508,41 @@ class MockChatWidgetService extends mock<IChatWidgetService>() { } } +class MockModelService extends mock<IModelService>() { + private readonly _models = new Map<string, ITextModel>(); + + constructor(private readonly _disposables: DisposableStore) { + super(); + } + + setModelContent(uri: URI, content: string): void { + this._models.set(uri.toString(), this._disposables.add(createTextModel(content, null, undefined, uri))); + } + + override getModel(uri: URI): ITextModel | null { + return this._models.get(uri.toString()) ?? null; + } +} + +class MockWorkingCopyService extends mock<IWorkingCopyService>() { + private readonly _dirty = new Set<string>(); + + setDirty(uri: URI, dirty: boolean): void { + if (dirty) { + this._dirty.add(uri.toString()); + } else { + this._dirty.delete(uri.toString()); + } + } + + override isDirty(resource: URI): boolean { + return this._dirty.has(resource.toString()); + } +} + // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>, isSessionsWindow = false) { +function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }, authServiceOverride?: Partial<IAuthenticationService>, languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>, provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>, isSessionsWindow = false, languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService>, configOverrides?: Record<string, unknown>, chatSessionsServiceOverride?: Partial<IChatSessionsService>, chatDebugServiceOverride?: Partial<IChatDebugService>, remoteAgentHostServiceOverride?: Partial<IRemoteAgentHostService>) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -528,6 +573,19 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IChatWidgetService, chatWidgetService); instantiationService.stub(IFileService, TestFileService); instantiationService.stub(ILabelService, MockLabelService); + instantiationService.stub(IPathService, new class extends mock<IPathService>() { + override userHome(options: { preferLocal: true }): URI; + override userHome(options?: { preferLocal: boolean }): Promise<URI>; + override userHome(options?: { preferLocal: boolean }): URI | Promise<URI> { + const userHome = URI.file('/home/test-user'); + return options?.preferLocal ? userHome : Promise.resolve(userHome); + } + }()); + instantiationService.stub(IRemoteAgentHostService, { connections: [], ...remoteAgentHostServiceOverride }); + const modelService = new MockModelService(disposables); + const workingCopyService = new MockWorkingCopyService(); + instantiationService.stub(IModelService, modelService); + instantiationService.stub(IWorkingCopyService, workingCopyService); instantiationService.stub(IChatSessionsService, { registerChatSessionItemController: (type, controller) => { const entry = { type, controller }; @@ -544,6 +602,19 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv chatSessionContributions.push(contribution); return toDisposable(() => { }); }, + getOrCreateChatSession: async sessionResource => upcastPartial<IChatSession>({ + sessionResource, + onWillDispose: Event.None, + history: [], + dispose: () => { }, + }), + ...chatSessionsServiceOverride, + }); + instantiationService.stub(IChatDebugService, { + getEvents: () => [], + invokeProviders: async () => { }, + resolveEvent: async () => undefined, + ...chatDebugServiceOverride, }); instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None, ...authServiceOverride }); @@ -554,15 +625,29 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); instantiationService.stub(IConfigurationService, { onDidChangeConfiguration: Event.None, - getValue: (...args: any[]) => typeof args[0] === 'string' && args[0] === 'chat.agentHost.clientTools' ? [] : true, + getValue: (...args: any[]) => { + const key = args[0]; + if (typeof key === 'string') { + if (configOverrides && Object.hasOwn(configOverrides, key)) { + return configOverrides[key]; + } + if (key === 'chat.agentHost.clientTools') { + return []; + } + } + return true; + }, }); instantiationService.stub(ILanguageModelToolsService, { observeTools: () => observableValue('tools', []), onDidChangeTools: Event.None, getTools: () => [], _serviceBrand: undefined, + ...languageModelToolsServiceOverride, }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: true }); + instantiationService.stub(IProgressService, { withProgress: <R,>(_options: IProgressNotificationOptions, task: (progress: IProgress<IProgressStep>) => Promise<R>) => task({ report: () => { } }) }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null, onDidChangeWorkspaceFolders: Event.None }); const trustController: { result: boolean | undefined; workspaceTrustCalls: number; resourcesTrustCalls: number } = { result: true, workspaceTrustCalls: 0, resourcesTrustCalls: 0 }; instantiationService.stub(IWorkspaceTrustRequestService, new class extends mock<IWorkspaceTrustRequestService>() { @@ -613,6 +698,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeSkills = Event.None; override readonly onDidChangeInstructions = Event.None; + override readonly onDidChangeAgentInstructions = Event.None; override async listPromptFilesForStorage() { return []; @@ -646,6 +732,11 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv disposeSession: async () => { }, ...provisionalServiceOverride, } as Partial<IAgentHostUntitledProvisionalSessionService> as IAgentHostUntitledProvisionalSessionService); + instantiationService.stub(IAgentHostImportConversationStore, { + set: () => { }, + take: () => undefined, + rename: () => { }, + } as Partial<IAgentHostImportConversationStore> as IAgentHostImportConversationStore); const newSessionFolderService = disposables.add(new AgentHostNewSessionFolderService(chatService as Partial<IChatService> as IChatService, instantiationService.get(IWorkspaceContextService))); instantiationService.stub(IAgentHostNewSessionFolderService, newSessionFolderService); const customizationsByType = new Map<string, IObservable<readonly ClientPluginCustomization[]>>(); @@ -686,7 +777,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv instantiationService.stub(IAgentHostActiveClientService, activeClientService); instantiationService.stub(IOpenerService, openerService as IOpenerService); - return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController }; + return { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, activeClientService, seedActiveClient, chatSessionContributions, chatSessionItemControllers, newSessionFolderService, trustController, modelService, workingCopyService }; } function createSessionListStore(disposables: DisposableStore, instantiationService: TestInstantiationService, connection: IAgentHostSessionListConnection): AgentHostSessionListStore { @@ -698,23 +789,23 @@ function createSessionListController(disposables: DisposableStore, instantiation return disposables.add(instantiationService.createInstance(AgentHostSessionListController, sessionType, provider, sessionListStore, description, 'local')); } -function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial<IAuthenticationService>; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>; provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService> }) { - const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride); +function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial<IAuthenticationService>; workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined; isNewSession?: (sessionResource: URI) => boolean }; languageModels?: ReadonlyMap<string, ILanguageModelChatMetadata>; provisionalServiceOverride?: Partial<IAgentHostUntitledProvisionalSessionService>; languageModelToolsServiceOverride?: Partial<ILanguageModelToolsService>; configOverrides?: Record<string, unknown>; provider?: string; chatSessionsServiceOverride?: Partial<IChatSessionsService>; chatDebugServiceOverride?: Partial<IChatDebugService>; remoteAgentHostServiceOverride?: Partial<IRemoteAgentHostService> }) { + const { instantiationService, agentHostService, chatAgentService, chatWidgetService, chatService, openerService, trustController, modelService, workingCopyService } = createTestServices(disposables, opts?.workingDirectoryResolver, opts?.authServiceOverride, opts?.languageModels, opts?.provisionalServiceOverride, false, opts?.languageModelToolsServiceOverride, opts?.configOverrides, opts?.chatSessionsServiceOverride, opts?.chatDebugServiceOverride, opts?.remoteAgentHostServiceOverride); const listController = createSessionListController(disposables, instantiationService, agentHostService); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { - provider: 'copilot' as const, + provider: opts?.provider ?? 'copilot', agentId: 'agent-host-copilot', sessionType: 'agent-host-copilot', fullName: 'Agent Host - Copilot', - description: 'Copilot SDK agent running in a dedicated process', + description: 'Copilot SDK agent running in the local agent host process', connection: agentHostService, connectionAuthority: 'local', isNewSession: sessionResource => listController.isNewSession(sessionResource), })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); - return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService, instantiationService, openerService, trustController }; + return { contribution, listController, sessionHandler, agentHostService, chatAgentService, chatWidgetService, chatService, instantiationService, openerService, trustController, modelService, workingCopyService }; } function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; modelConfiguration: Record<string, unknown>; agentHostSessionConfig: Record<string, string>; agentId: string }> = {}): IChatAgentRequest { @@ -891,6 +982,43 @@ suite('AgentHostChatContribution', () => { }); }); + // ---- Download progress notification (editor window) ----------------- + + suite('download progress', () => { + + function createWithProgressRecorder(isSessionsWindow: boolean) { + // AI features must be enabled (AIDisabled === false) or the download + // progress handler suppresses the notification; the default config + // stub returns `true` for every key, so override just this one. + const services = createTestServices(disposables, undefined, undefined, undefined, undefined, isSessionsWindow, undefined, { [ChatConfiguration.AIDisabled]: false }); + const openedTitles: (string | undefined)[] = []; + services.instantiationService.stub(IProgressService, { + withProgress: <R,>(options: IProgressNotificationOptions, task: (progress: IProgress<IProgressStep>) => Promise<R>) => { + openedTitles.push(options.title); + return task({ report: () => { } }); + }, + }); + disposables.add(services.instantiationService.createInstance(AgentHostContribution)); + return { agentHostService: services.agentHostService, openedTitles }; + } + + test('editor window renders SDK download progress from root/progress notifications', () => { + const { agentHostService, openedTitles } = createWithProgressRecorder(false); + + agentHostService.fireNotification({ type: 'root/progress', channel: 'ahp-root://root', progressToken: 'claude', progress: 0, total: 1000, message: 'Downloading Claude agent' }); + + assert.deepStrictEqual(openedTitles, ['Downloading Claude agent']); + }); + + test('sessions window does not render download progress via the chat contribution', () => { + const { agentHostService, openedTitles } = createWithProgressRecorder(true); + + agentHostService.fireNotification({ type: 'root/progress', channel: 'ahp-root://root', progressToken: 'claude', progress: 0, total: 1000, message: 'Downloading Claude agent' }); + + assert.strictEqual(openedTitles.length, 0); + }); + }); + // ---- Request attachments -------------------------------------------- suite('request attachments', () => { @@ -984,6 +1112,46 @@ suite('AgentHostChatContribution', () => { await turnPromise; }); + test('preserves workspace context as a hidden workspace variable on history replay', async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue', + variables: { + variables: [{ + kind: 'workspace', + id: 'workspace', + name: 'workspace', + value: 'Workspace context', + }], + }, + }); + + const turnStarted = agentHostService.turnActions[0].action as ITurnStartedAction; + const attachments = turnStarted.message.attachments; + const replayedVariables = messageAttachmentsToVariableData(attachments, 'test')?.variables; + assert.deepStrictEqual({ + attachments, + replayedVariables, + }, { + attachments: [{ + type: MessageAttachmentKind.Simple, + label: 'workspace', + modelRepresentation: 'Workspace context', + displayKind: 'workspace', + }], + replayedVariables: [{ + kind: 'workspace', + id: 'workspace', + name: 'workspace', + value: 'Workspace context', + _meta: undefined, + }], + }); + + fire({ type: 'chat/turnComplete', session: session!, turnId: turnId! } as ChatAction); + await turnPromise; + }); + test('sends agent feedback variables as annotations attachments referencing each comment', async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/feedback-send' }); @@ -1175,6 +1343,64 @@ suite('AgentHostChatContribution', () => { fileName: undefined, }]); }); + + test('restores session reference simple attachments as session reference variable entries', () => { + const sessionResource = URI.from({ scheme: 'copilotcli', path: '/session-123' }); + const variableData = messageAttachmentsToVariableData([{ + type: MessageAttachmentKind.Simple, + label: 'Review session', + displayKind: AgentHostSessionReferenceAttachmentDisplayKind, + modelRepresentation: toSessionReferenceModelRepresentation('Review session', sessionResource), + _meta: { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionResource.toString(), + sessionID: sessionResource.toString(), + }, + }, + }, { + type: MessageAttachmentKind.Resource, + label: 'Review session trajectory', + displayKind: AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, + uri: URI.file('/home/test-user/.copilot/session-state/session-123/events.jsonl').toString(), + _meta: { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionResource.toString(), + sessionID: sessionResource.toString(), + }, + }, + }], 'test'); + + assert.deepStrictEqual(variableData?.variables.map(variable => ({ + kind: variable.kind, + id: variable.id, + name: variable.name, + value: URI.isUri(variable.value) ? variable.value.toString() : variable.value, + })), [{ + kind: 'sessionReference', + id: sessionResource.toString(), + name: 'Review session', + value: sessionResource.toString(), + }]); + }); + + test('session reference simple attachments without metadata restore as generic variable entries', () => { + const variableData = messageAttachmentsToVariableData([{ + type: MessageAttachmentKind.Simple, + label: 'Review session', + displayKind: AgentHostSessionReferenceAttachmentDisplayKind, + modelRepresentation: 'session text', + }], 'test'); + + assert.deepStrictEqual(variableData?.variables.map(variable => ({ + kind: variable.kind, + name: variable.name, + value: variable.value, + })), [{ + kind: 'generic', + name: 'Review session', + value: 'session text', + }]); + }); }); suite('draft', () => { @@ -2502,6 +2728,23 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(totalContent, 'hello world'); })); + test('system notification response parts become live system notifications', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + + fire({ + type: 'chat/responsePart', + session, + turnId, + part: { kind: ResponsePartKind.SystemNotification, content: 'Background command completed' }, + } as ChatAction); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + const notifications = collected.flat().filter(part => part.kind === 'systemNotification'); + assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']); + })); + test('live turn marks chat session complete after turnComplete', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -2649,6 +2892,10 @@ suite('AgentHostChatContribution', () => { toolCallId: parentToolCallId, content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], } as ChatAction); + fire({ + type: 'chat/toolCallComplete', session, turnId, toolCallId: parentToolCallId, + result: { success: true, pastTenseMessage: 'Spawned subagent' }, + } as ChatAction); await timeout(50); @@ -4686,6 +4933,52 @@ suite('AgentHostChatContribution', () => { ]); })); + test('browser view variable becomes a model-readable browser attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const browserUri = URI.from({ scheme: 'vscode-browser', path: '/page-1' }); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'inspect this page', + variables: { + variables: [ + upcastPartial({ + kind: 'browserView', + id: browserUri.toString(), + name: 'Example page', + value: browserUri, + browserId: 'page-1', + modelDescription: 'Browser page: Example. The pageId is "page-1".', + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [{ + type: MessageAttachmentKind.Simple, + label: 'Example page', + modelRepresentation: 'Browser page: Example. The pageId is "page-1".', + displayKind: BrowserViewAttachmentDisplayKind, + _meta: { [BrowserViewAttachmentMetadataKey]: { browserId: 'page-1', browserUri: browserUri.toString() } }, + }]); + + const replayedVariables = messageAttachmentsToVariableData(turnAction.message.attachments, 'test')?.variables; + assert.strictEqual(replayedVariables?.length, 1); + assert.strictEqual(replayedVariables?.[0].value?.toString(), browserUri.toString()); + assert.deepStrictEqual({ ...replayedVariables?.[0], value: undefined }, { + kind: 'browserView', + id: browserUri.toString(), + name: 'Example page', + value: undefined, + browserId: 'page-1', + modelDescription: 'Browser page: Example. The pageId is "page-1".', + _meta: { [BrowserViewAttachmentMetadataKey]: { browserId: 'page-1', browserUri: browserUri.toString() } }, + }); + })); + test('preserves _meta from variable entry on outgoing attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -4707,6 +5000,187 @@ suite('AgentHostChatContribution', () => { ]); })); + test('local agent host Copilot CLI session reference becomes simple and trajectory attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const sessionReference = URI.from({ scheme: 'agent-host-copilotcli', path: '/session-123' }); + const trajectoryUri = URI.file('/home/test-user/.copilot/session-state/session-123/events.jsonl'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue #review', + variables: { + variables: [ + upcastPartial({ + kind: 'sessionReference', + id: sessionReference.toString(), + name: 'Review session', + value: sessionReference, + range: { start: 9, endExclusive: 16 }, + _meta: { source: 'test' }, + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [{ + type: MessageAttachmentKind.Simple, + label: 'Review session', + modelRepresentation: toSessionReferenceModelRepresentation('Review session', sessionReference, trajectoryUri.fsPath), + displayKind: AgentHostSessionReferenceAttachmentDisplayKind, + range: { + start: { line: 0, character: 9 }, + end: { line: 0, character: 16 }, + }, + _meta: { + source: 'test', + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionReference.toString(), + sessionID: sessionReference.toString(), + }, + }, + }, { + type: MessageAttachmentKind.Resource, + uri: trajectoryUri.toString(), + label: 'Review session trajectory', + displayKind: AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, + _meta: { + source: 'test', + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionReference.toString(), + sessionID: sessionReference.toString(), + }, + }, + }]); + })); + + test('extension host Copilot CLI session reference becomes simple and trajectory attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const sessionReference = URI.from({ scheme: 'copilotcli', path: '/session-123' }); + const trajectoryUri = URI.file('/home/test-user/.copilot/session-state/session-123/events.jsonl'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue #review', + variables: { + variables: [ + upcastPartial({ + kind: 'sessionReference', + id: sessionReference.toString(), + name: 'Review session', + value: sessionReference, + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [{ + type: MessageAttachmentKind.Simple, + label: 'Review session', + modelRepresentation: toSessionReferenceModelRepresentation('Review session', sessionReference, trajectoryUri.fsPath), + displayKind: AgentHostSessionReferenceAttachmentDisplayKind, + _meta: { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionReference.toString(), + sessionID: sessionReference.toString(), + }, + }, + }, { + type: MessageAttachmentKind.Resource, + uri: trajectoryUri.toString(), + label: 'Review session trajectory', + displayKind: AgentHostSessionReferenceTrajectoryAttachmentDisplayKind, + _meta: { + [AgentHostSessionReferenceAttachmentMetadataKey]: { + sessionResource: sessionReference.toString(), + sessionID: sessionReference.toString(), + }, + }, + }]); + })); + + test('remote agent host Copilot CLI session reference becomes simple and trajectory attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + remoteAgentHostServiceOverride: { + connections: [upcastPartial({ address: 'remote.example', name: 'remote.example', clientId: 'remote-client', status: { kind: 'connected' }, defaultDirectory: '/home/remote-user' })], + }, + }); + const sessionReference = URI.from({ scheme: 'remote-remote.example-copilotcli', path: '/session-123' }); + const trajectoryPath = '/home/remote-user/.copilot/session-state/session-123/events.jsonl'; + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue #review', + variables: { + variables: [ + upcastPartial({ + kind: 'sessionReference', + id: sessionReference.toString(), + name: 'Remote review session', + value: sessionReference, + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + const attachment = turnAction.message.attachments?.[0]; + assert.strictEqual(attachment?.type, MessageAttachmentKind.Simple); + assert.strictEqual(attachment.modelRepresentation, toSessionReferenceModelRepresentation('Remote review session', sessionReference, trajectoryPath)); + const trajectoryAttachment = turnAction.message.attachments?.[1]; + assert.strictEqual(trajectoryAttachment?.type, MessageAttachmentKind.Resource); + assert.strictEqual(trajectoryAttachment.uri, URI.file(trajectoryPath).toString()); + assert.strictEqual(trajectoryAttachment.displayKind, AgentHostSessionReferenceTrajectoryAttachmentDisplayKind); + })); + + test('unsupported session reference variable is dropped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const sessionReference = URI.from({ scheme: 'claude-code', path: '/session-123' }); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue #review', + variables: { + variables: [ + upcastPartial({ + kind: 'sessionReference', + id: sessionReference.toString(), + name: 'Review session', + value: sessionReference, + }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + + test('malformed session reference variable is dropped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'continue this', + variables: { + variables: [ + upcastPartial({ kind: 'sessionReference', id: 'bad-session', name: 'Bad session', value: 'not a uri' as never }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + test('agent feedback variable becomes a simple attachment when no annotations channel is known', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/feedback-test' }); @@ -4854,7 +5328,7 @@ suite('AgentHostChatContribution', () => { ]); })); - test('implicit visible code location does not become selection attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + test('implicit visible code location becomes document attachment without selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { @@ -4879,20 +5353,285 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(agentHostService.turnActions.length, 1); const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; - assert.strictEqual(turnAction.message.attachments, undefined); + assert.deepStrictEqual(turnAction.message.attachments, [ + { + type: MessageAttachmentKind.Resource, + uri: URI.file('/workspace/foo.ts').toString(), + label: 'visible code', + displayKind: 'document', + }, + ]); })); - test('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const uri = URI.from({ scheme: 'untitled', path: '/foo' }); + test('active editor implicit context is not forwarded when the setting is disabled', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables, { + configOverrides: { [ChatConfiguration.ImplicitContextActiveEditor]: false }, + }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-disabled' }); + const fileUri = URI.file('/workspace/foo.ts'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { - message: 'check this', - variables: { - variables: [ - upcastPartial({ kind: 'file', id: 'v-file', name: 'untitled', value: uri }), - ], - }, + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + + test('active editor implicit context is forwarded as a document attachment for agent sessions', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-active-editor' }); + const fileUri = URI.file('/workspace/foo.ts'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('active editor implicit context is not duplicated when also attached explicitly', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-dedup' }); + const fileUri = URI.file('/workspace/foo.ts'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('active editor implicit selection is forwarded even when the same file is attached as a document', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-selection-dedup' }); + const fileUri = URI.file('/workspace/foo.ts'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.selection', name: 'foo.ts', isSelection: true, uri: fileUri, value: { uri: fileUri, range: new Range(2, 1, 4, 10) } }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this selection?', + sessionResource, + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'selection', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } } }, + ]); + })); + + test('active editor implicit context is not forwarded for untitled editors on non-Copilot-CLI backends', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService } = createContribution(disposables); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/new-implicit-untitled' }); + const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + + test('active editor implicit context for an untitled editor is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-untitled' }); + const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); + modelService.setModelContent(untitledUri, 'console.log("draft")'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'Untitled-1', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('console.log("draft")')), contentType: 'text/plain' }, + ]); + })); + + test('active editor implicit context for a dirty saved file is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-dirty' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'edited but not saved'); + workingCopyService.setDirty(fileUri, true); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('edited but not saved')), contentType: 'text/plain' }, + ]); + })); + + test('active editor implicit selection in an unsaved editor preserves the selection range when inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-dirty-selection' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); + workingCopyService.setDirty(fileUri, true); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.selection', name: 'foo.ts', isSelection: true, uri: fileUri, value: { uri: fileUri, range: new Range(2, 1, 4, 10) } }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this selection?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } } }, + ]); + })); + + test('active editor implicit context for a clean saved file is forwarded as a path for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-clean' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'saved content'); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('active editor implicit context over the size cap is dropped for an untitled editor on the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-untitled-toolarge' }); + const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); + modelService.setModelContent(untitledUri, 'x'.repeat(1024 * 1024 + 1)); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'Untitled-1', isSelection: false, uri: untitledUri, value: untitledUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + + test('active editor implicit context over the size cap falls back to a path for a dirty saved file on the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-implicit-dirty-toolarge' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'x'.repeat(1024 * 1024 + 1)); + workingCopyService.setDirty(fileUri, true); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('non-file URI variables (e.g. untitled documents) are forwarded as attachments', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); + const uri = URI.from({ scheme: 'untitled', path: '/foo' }); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'untitled', value: uri }), + ], + }, }); fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); await turnPromise; @@ -4904,6 +5643,152 @@ suite('AgentHostChatContribution', () => { ]); })); + test('explicitly attached untitled editor is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const untitledUri = URI.from({ scheme: 'untitled', path: '/Untitled-1' }); + modelService.setModelContent(untitledUri, 'console.log("draft")'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'Untitled-1', value: untitledUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'Untitled-1', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('console.log("draft")')), contentType: 'text/plain' }, + ]); + })); + + test('explicitly attached dirty saved file is inlined for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'edited but not saved'); + workingCopyService.setDirty(fileUri, true); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('edited but not saved')), contentType: 'text/plain' }, + ]); + })); + + test('explicitly attached clean saved file is forwarded as a path for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService } = createContribution(disposables, { provider: 'copilotcli' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'saved content'); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.Resource, uri: fileUri.toString(), label: 'foo.ts', displayKind: 'document' }, + ]); + })); + + test('explicitly attached unsaved file is not duplicated by the active editor for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, chatWidgetService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/copilot-explicit-implicit-dedup' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'edited but not saved'); + workingCopyService.setDirty(fileUri, true); + chatWidgetService.setWidgetForSession(sessionResource, [ + { kind: 'implicit', id: 'vscode.implicit.file', name: 'foo.ts', isSelection: false, uri: fileUri, value: fileUri }, + ]); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'what\'s in this file?', + sessionResource, + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: fileUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'document', data: encodeBase64(VSBuffer.fromString('edited but not saved')), contentType: 'text/plain' }, + ]); + })); + + test('inlined unsaved attachment preserves _meta and selection for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, modelService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + const fileUri = URI.file('/workspace/foo.ts'); + modelService.setModelContent(fileUri, 'first line\nsecond line\nthird line\nfourth line content'); + workingCopyService.setDirty(fileUri, true); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: { uri: fileUri, range: new Range(2, 1, 4, 10) }, _meta: { provider: 'fs', score: 0.42 } }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.deepStrictEqual(turnAction.message.attachments, [ + { type: MessageAttachmentKind.EmbeddedResource, label: 'foo.ts', displayKind: 'selection', data: encodeBase64(VSBuffer.fromString('second line\nthird line\nfourth li')), contentType: 'text/plain', selection: { range: { start: { line: 1, character: 0 }, end: { line: 3, character: 9 } } }, _meta: { provider: 'fs', score: 0.42 } }, + ]); + })); + + test('dirty non-file resource that cannot be inlined is dropped for the Copilot CLI backend', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { sessionHandler, agentHostService, chatAgentService, workingCopyService } = createContribution(disposables, { provider: 'copilotcli' }); + // A dirty resource with no loaded text model (e.g. a notebook cell or remote URI) can't be inlined; for the + // CLI a non-file path is unreadable, so it must be dropped rather than forwarded as a broken path. + const remoteUri = URI.from({ scheme: 'vscode-remote', authority: 'ssh-remote+host', path: '/remote/foo.ts' }); + workingCopyService.setDirty(remoteUri, true); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'check this', + variables: { + variables: [ + upcastPartial({ kind: 'file', id: 'v-file', name: 'foo.ts', value: remoteUri }), + ], + }, + }); + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + + assert.strictEqual(agentHostService.turnActions.length, 1); + const turnAction = agentHostService.turnActions[0].action as ITurnStartedAction; + assert.strictEqual(turnAction.message.attachments, undefined); + })); + test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); @@ -5114,7 +5999,7 @@ suite('AgentHostChatContribution', () => { test('setting gate prevents registration', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService } = createTestServices(disposables); - instantiationService.stub(IConfigurationService, { getValue: () => false }); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: false }); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); // Contribution should exist but not have registered any agents @@ -5545,7 +6430,7 @@ suite('AgentHostChatContribution', () => { suite('reconnection to active turn', () => { - function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string }>): SeededSessionState { + function makeSessionStateWithActiveTurn(sessionUri: string, overrides?: Partial<{ streamingText: string; reasoning: string; systemNotification: string }>): SeededSessionState { const summary: SessionSummary = { resource: sessionUri, provider: 'copilot', @@ -5559,6 +6444,9 @@ suite('AgentHostChatContribution', () => { if (reasoningText) { activeTurnParts.push({ kind: ResponsePartKind.Reasoning as const, id: 'reasoning-1', content: reasoningText }); } + if (overrides?.systemNotification) { + activeTurnParts.push({ kind: ResponsePartKind.SystemNotification as const, content: overrides.systemNotification }); + } activeTurnParts.push({ kind: ResponsePartKind.Markdown as const, id: 'md-active', content: overrides?.streamingText ?? 'Partial response so far' }); return { ...createSessionState(summary), @@ -5623,6 +6511,21 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(markdownPart!.content.value, 'Partial response so far'); }); + test('does not duplicate system notification progress when reconnecting', async () => { + const { sessionHandler, agentHostService } = createContribution(disposables); + const sessionUri = AgentSession.uri('copilot', 'reconnect-system-notification'); + agentHostService.sessionStates.set(sessionUri.toString(), makeSessionStateWithActiveTurn(sessionUri.toString(), { + systemNotification: 'Background command completed', + })); + + const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/reconnect-system-notification' }); + const session = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); + disposables.add(toDisposable(() => session.dispose())); + + const notifications = (session.progressObs?.get() ?? []).filter(part => part.kind === 'systemNotification'); + assert.deepStrictEqual(notifications.map(part => part.content.value), ['Background command completed']); + }); + test('provides interruptActiveResponseCallback when reconnecting', async () => { const { sessionHandler, agentHostService } = createContribution(disposables); @@ -6535,7 +7438,7 @@ suite('AgentHostChatContribution', () => { /** * Build a child session state containing a single inner tool call in the running state. */ - function makeChildState(childUri: string, innerToolCallId: string): SeededSessionState { + function makeChildState(childUri: string, innerToolCallId: string, contributor?: ToolCallState['contributor']): SeededSessionState { const summary: SessionSummary = { resource: childUri, provider: 'copilot', @@ -6552,6 +7455,7 @@ suite('AgentHostChatContribution', () => { invocationMessage: 'Reading file', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + contributor, } as ToolCallState; const activeTurn = createActiveTurn('child-turn-1', { text: 'do work', origin: { kind: MessageKind.User } }); activeTurn.responseParts.push({ kind: ResponsePartKind.ToolCall, toolCall: innerTool }); @@ -6596,9 +7500,6 @@ suite('AgentHostChatContribution', () => { // Allow the throttler/observation flow to flush. await timeout(50); - fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); - await turnPromise; - // Flatten all progress emissions and find tool invocations. const allParts = collected.flat(); const toolInvocations = allParts.filter((p): p is IChatToolInvocation => p.kind === 'toolInvocation'); @@ -6609,9 +7510,22 @@ suite('AgentHostChatContribution', () => { assert.ok(parent, 'parent task tool invocation should be emitted'); assert.strictEqual(parent!.toolSpecificData?.kind, 'subagent', 'parent should have subagent toolSpecificData'); assert.strictEqual(parent!.subAgentInvocationId, undefined, 'parent should not have a subAgentInvocationId'); + assert.strictEqual((parent!.toolSpecificData as IChatSubagentToolInvocationData).isActive, true); assert.ok(child, 'inner child tool invocation should be forwarded into parent session progress'); assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged with parent tool call id for grouping'); + + agentHostService.fireAction({ + channel: childSessionUri, + action: { type: 'chat/turnComplete', turnId: 'child-turn-1' } as ChatAction, + serverSeq: 1001, + origin: undefined, + }); + await timeout(50); + assert.strictEqual((parent!.toolSpecificData as IChatSubagentToolInvocationData).isActive, false); + + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; })); test('inner subagent tool calls fired AFTER parent observation are also grouped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { @@ -6686,6 +7600,50 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(child!.subAgentInvocationId, parentToolCallId, 'child should be tagged for grouping'); })); + test('client-provided subagent tool calls retain the parent grouping id', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + let capturedSubagentInvocationId: string | undefined; + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables, { + languageModelToolsServiceOverride: { + getToolByName: () => ({ id: 'vscode_readSkill', source: ToolDataSource.Internal, displayName: 'Read skill', modelDescription: 'Reads a skill' }), + beginToolCall: options => { + capturedSubagentInvocationId = options.subagentInvocationId; + return undefined; + }, + }, + }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); + const parentToolCallId = 'tc-parent-task'; + const parentSession = parseDefaultChatUri(session); + assert.ok(parentSession); + const childSessionUri = buildSubagentChatUri(parentSession, parentToolCallId); + agentHostService.sessionStates.set(childSessionUri, makeChildState(childSessionUri, 'tc-child-skill', { + kind: ToolCallContributorKind.Client, + clientId: agentHostService.clientId, + })); + + fire({ + type: 'chat/toolCallStart', session, turnId, + toolCallId: parentToolCallId, toolName: 'task', displayName: 'Task', + _meta: { toolKind: 'subagent', subagentDescription: 'review code' }, + } as ChatAction); + fire({ + type: 'chat/toolCallReady', session, turnId, + toolCallId: parentToolCallId, invocationMessage: 'Spawning subagent', + confirmed: 'not-needed', + } as ChatAction); + fire({ + type: 'chat/toolCallContentChanged', session, turnId, + toolCallId: parentToolCallId, + content: [{ type: ToolResultContentType.Subagent, resource: childSessionUri, title: 'Subagent' }], + } as ChatAction); + + await timeout(50); + assert.strictEqual(capturedSubagentInvocationId, parentToolCallId); + + fire({ type: 'chat/turnComplete', session, turnId } as ChatAction); + await turnPromise; + })); + test('parent subagent agentName is updated when subagent content arrives later', () => runWithFakedTimers({ useFakeTimers: true }, async () => { // Repro for the missing-agent-name bug: when the parent task tool // fires without `subagentAgentName` in `_meta` (e.g. the agent host 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 41f8c5dd721dc2..42a13716778869 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 @@ -7,10 +7,11 @@ import assert from 'assert'; import { timeout } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { DisposableStore, IReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { constObservable, observableValue, autorun } from '../../../../../../base/common/observable.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -38,6 +39,7 @@ import { TestFileService } from '../../../../../test/common/workbenchTestService import { ILabelService } from '../../../../../../platform/label/common/label.js'; import { MockLabelService } from '../../../../../services/label/test/common/mockLabelService.js'; import { IAgentHostFileSystemService } from '../../../../../services/agentHost/common/agentHostFileSystemService.js'; +import { IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; import { IStorageService, InMemoryStorageService } from '../../../../../../platform/storage/common/storage.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import { ITerminalChatService } from '../../../../terminal/browser/terminal.js'; @@ -241,11 +243,12 @@ suite('AgentHostClientTools', () => { suite('client tools registration', () => { - function createMockToolsService(disposables: DisposableStore, tools: IToolData[], options?: { requireConfirmation?: boolean }) { + function createMockToolsService(disposables: DisposableStore, tools: IToolData[], options?: { requireConfirmation?: boolean; throwBeforeConfirmation?: Error }) { const onDidChangeTools = disposables.add(new Emitter<void>()); const pendingToolCalls = new Map<string, ChatToolInvocation>(); const begunToolCalls: ChatToolInvocation[] = []; const invokedToolCalls: IToolInvocation[] = []; + const recordedStateKinds = new Map<string, IChatToolInvocation.StateKind[]>(); return { onDidChangeTools: onDidChangeTools.event, getToolByName: (name: string) => tools.find(t => t.toolReferenceName === name), @@ -260,14 +263,21 @@ suite('AgentHostClientTools', () => { invokedToolCalls.push(invocation); const toolInvocation = pendingToolCalls.get(invocation.chatStreamToolCallId ?? invocation.callId); pendingToolCalls.delete(invocation.chatStreamToolCallId ?? invocation.callId); + if (options?.throwBeforeConfirmation) { + throw options.throwBeforeConfirmation; + } if (options?.requireConfirmation && toolInvocation) { + // Mirror the real service: a caller-provided `preApproved` + // reason is treated as auto-confirmation so the invocation + // transitions straight to executing without ever entering + // `WaitingForConfirmation`. toolInvocation.transitionFromStreaming({ invocationMessage: 'Run Task', confirmationMessages: { title: 'Confirm tool execution', message: 'Run the task?', }, - }, invocation.parameters, undefined); + }, invocation.parameters, invocation.preApproved); await IChatToolInvocation.awaitConfirmation(toolInvocation, CancellationToken.None); } else { toolInvocation?.transitionFromStreaming(undefined, invocation.parameters, { type: ToolConfirmKind.ConfirmationNotNeeded }); @@ -285,9 +295,18 @@ suite('AgentHostClientTools', () => { toolCallId: options.toolCallId, toolId: options.toolId, toolData, + subagentInvocationId: options.subagentInvocationId, }); pendingToolCalls.set(options.toolCallId, invocation); begunToolCalls.push(invocation); + // Record every state the invocation passes through so tests can + // assert it never flickers into `WaitingForConfirmation` when + // the call is auto-approved. + const stateKinds: IChatToolInvocation.StateKind[] = []; + recordedStateKinds.set(options.toolCallId, stateKinds); + disposables.add(autorun(reader => { + stateKinds.push(invocation.state.read(reader).type); + })); return invocation; }, updateToolStream: async () => { }, @@ -316,7 +335,8 @@ suite('AgentHostClientTools', () => { fireOnDidChangeTools: () => onDidChangeTools.fire(), begunToolCalls, invokedToolCalls, - } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void; begunToolCalls: ChatToolInvocation[]; invokedToolCalls: IToolInvocation[] }; + recordedStateKinds, + } satisfies ILanguageModelToolsService & { fireOnDidChangeTools: () => void; begunToolCalls: ChatToolInvocation[]; invokedToolCalls: IToolInvocation[]; recordedStateKinds: Map<string, IChatToolInvocation.StateKind[]> }; } class MockAgentHostConnection extends mock<IAgentHostService>() { @@ -418,7 +438,7 @@ suite('AgentHostClientTools', () => { function createHandlerWithMocks( disposables: DisposableStore, tools: IToolData[], - toolServiceOptions?: { requireConfirmation?: boolean }, + toolServiceOptions?: { requireConfirmation?: boolean; throwBeforeConfirmation?: Error }, ) { const instantiationService = disposables.add(new TestInstantiationService()); const connection = new MockAgentHostConnection(); @@ -469,6 +489,11 @@ suite('AgentHostClientTools', () => { ensureSyncedCustomizationProvider: () => { }, }); instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(IAgentHostImportConversationStore, { + set: () => { }, + take: () => undefined, + rename: () => { }, + } as Partial<IAgentHostImportConversationStore> as IAgentHostImportConversationStore); instantiationService.stub(ICustomizationHarnessService, { registerExternalHarness: () => toDisposable(() => { }), }); @@ -480,6 +505,7 @@ suite('AgentHostClientTools', () => { override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeSkills = Event.None; override readonly onDidChangeInstructions = Event.None; + override readonly onDidChangeAgentInstructions = Event.None; override async listPromptFilesForStorage() { return []; @@ -554,6 +580,63 @@ suite('AgentHostClientTools', () => { source: ToolDataSource.Internal, }; + async function provideSessionWithReadyRunTaskTool(handler: AgentHostSessionHandler, connection: MockAgentHostConnection): Promise<void> { + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'run the task', origin: { kind: MessageKind.User } }, + } as ChatAction); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + } as ChatAction); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmationTitle: 'Run Task', + } as ChatAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + } + + function getToolCallConfirmationAndCompletionActions(connection: MockAgentHostConnection) { + return connection.dispatchedActions + .filter(entry => isChatAction(entry.action) + && (entry.action.type === ActionType.ChatToolCallConfirmed || entry.action.type === ActionType.ChatToolCallComplete) + && entry.action.toolCallId === 'tool-call-1') + .map(entry => { + if (entry.action.type === ActionType.ChatToolCallConfirmed) { + return { + type: entry.action.type, + approved: entry.action.approved, + success: undefined, + error: undefined, + }; + } + if (entry.action.type === ActionType.ChatToolCallComplete) { + return { + type: entry.action.type, + approved: undefined, + success: entry.action.result.success, + error: entry.action.result.error?.message, + }; + } + throw new Error(`Unexpected action type: ${entry.action.type}`); + }); + } + test('maps tool data to protocol definitions', async () => { const { connection } = createHandlerWithMocks(disposables, [testRunTestsTool, testRunTaskTool, testUnlistedTool]); @@ -627,6 +710,32 @@ suite('AgentHostClientTools', () => { && entry.action.toolCallId === 'tool-call-1')); }); + test('reports client tool prepare failures before confirmation as failed completion', async () => { + const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool], { throwBeforeConfirmation: new Error('prepare failed') }); + + await provideSessionWithReadyRunTaskTool(handler, connection); + + assert.deepStrictEqual(getToolCallConfirmationAndCompletionActions(connection), [{ + type: ActionType.ChatToolCallComplete, + approved: undefined, + success: false, + error: 'prepare failed', + }]); + }); + + test('reports client tool cancellation before confirmation as failed completion when protocol call is not terminal', async () => { + const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool], { throwBeforeConfirmation: new CancellationError() }); + + await provideSessionWithReadyRunTaskTool(handler, connection); + + assert.deepStrictEqual(getToolCallConfirmationAndCompletionActions(connection), [{ + type: ActionType.ChatToolCallComplete, + approved: undefined, + success: false, + error: 'Canceled', + }]); + }); + test('auto-approves client tool confirmation as a setting when the agent host marks the call', async () => { const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool], { requireConfirmation: true }); const sessionResource = URI.parse('agent-host-copilot:/session-1'); @@ -698,6 +807,54 @@ suite('AgentHostClientTools', () => { ]); }); + test('auto-approved client tool never enters WaitingForConfirmation (no needs-input flicker)', async () => { + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool], { requireConfirmation: true }); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + message: { text: 'run the task', origin: { kind: MessageKind.User } }, + } as ChatAction); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallStart, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + toolName: 'runTask', + displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + } as ChatAction); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallReady, + turnId: 'turn-1', + toolCallId: 'tool-call-1', + invocationMessage: 'Run Task', + toolInput: '{"task":"build"}', + confirmationTitle: 'Run Task', + _meta: { autoApproveBySetting: true }, + } as ChatAction); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + await timeout(0); + await timeout(0); + await timeout(0); + + // The invocation carries the pre-resolved approval, and it transitions + // straight from streaming to executing without ever surfacing a pending + // confirmation (which would flicker "needs input" in the sessions list). + assert.deepStrictEqual( + { + preApprovedKind: toolsService.invokedToolCalls[0]?.preApproved?.type, + sawWaitingForConfirmation: (toolsService.recordedStateKinds.get('tool-call-1') ?? []).includes(IChatToolInvocation.StateKind.WaitingForConfirmation), + }, + { + preApprovedKind: ToolConfirmKind.Setting, + sawWaitingForConfirmation: false, + }, + ); + }); + test('reconnecting to an active turn with owned client tool completes the initial snapshot invocation', async () => { const { handler, connection } = createHandlerWithMocks(disposables, [testRunTaskTool]); const sessionResource = URI.parse('agent-host-copilot:/session-1'); @@ -841,5 +998,172 @@ suite('AgentHostClientTools', () => { 'completion should target the subagent default chat URI' ); }); + + test('invokes a client tool inside a nested (level-2) subagent and groups it under the root', async () => { + // Regression: a subagent spawned by another subagent was not + // observed (observation stopped at the first level), so a client + // tool deep in the tree never ran. With recursive observation the + // level-2 client tool is invoked locally, its completion is + // dispatched against the level-2 subagent chat, and it is grouped + // under the ROOT subagent invocation so the renderer nests the + // whole tree under one container. + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + const rootToolCallId = 'tc-l1-task'; + const nestedToolCallId = 'tc-l2-task'; + const subagentChat1 = buildSubagentChatUri(backendSession, rootToolCallId); + const subagentChat2 = buildSubagentChatUri(backendSession, nestedToolCallId); + + // Default turn spawns the level-1 subagent. + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatTurnStarted, turnId: 'turn-1', + message: { text: 'do work', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: rootToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent' }, + }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: rootToolCallId, invocationMessage: 'Spawning subagent', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallContentChanged, turnId: 'turn-1', + toolCallId: rootToolCallId, content: [{ type: ToolResultContentType.Subagent, resource: subagentChat1, title: 'Subagent L1' }], + }); + + // Level-1 subagent spawns the level-2 subagent. + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', + message: { text: '', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatToolCallStart, turnId: 'sub-turn-1', + toolCallId: nestedToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent' }, + }); + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatToolCallReady, turnId: 'sub-turn-1', + toolCallId: nestedToolCallId, invocationMessage: 'Spawning nested subagent', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatToolCallContentChanged, turnId: 'sub-turn-1', + toolCallId: nestedToolCallId, content: [{ type: ToolResultContentType.Subagent, resource: subagentChat2, title: 'Subagent L2' }], + }); + + // Level-2 subagent runs a client-provided tool. + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', + message: { text: '', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatToolCallStart, turnId: 'sub-turn-2', + toolCallId: 'deep-tool-call', toolName: 'runTask', displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + }); + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatToolCallReady, turnId: 'sub-turn-2', + toolCallId: 'deep-tool-call', invocationMessage: 'Run Task', toolInput: '{"task":"build"}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + for (let i = 0; i < 200 && !connection.dispatchedActions.some(e => isChatAction(e.action) && e.action.type === ActionType.ChatToolCallComplete && e.action.toolCallId === 'deep-tool-call'); i++) { + await timeout(1); + } + + const deepInvocation = toolsService.invokedToolCalls.find(call => call.callId === 'deep-tool-call'); + assert.ok(deepInvocation, 'client tool inside a nested subagent should be invoked locally'); + assert.deepStrictEqual(deepInvocation!.parameters, { task: 'build' }); + + const completionEntry = connection.dispatchedActions.find(entry => + isChatAction(entry.action) + && entry.action.type === ActionType.ChatToolCallComplete + && entry.action.toolCallId === 'deep-tool-call' + ); + assert.ok(completionEntry, 'completion for the nested client tool should be dispatched'); + assert.strictEqual(completionEntry.channel.toString(), subagentChat2, 'completion should target the level-2 subagent chat URI'); + + const deepBegun = toolsService.begunToolCalls.find(c => c.toolCallId === 'deep-tool-call'); + assert.strictEqual(deepBegun?.subAgentInvocationId, rootToolCallId, 'descendant tools should be grouped under the root subagent invocation'); + }); + + test('observes a nested subagent without a discovery content block (agent-host misroutes it)', async () => { + // Regression for the logged stall: the agent host emits the + // subagent-discovery `ChatToolCallContentChanged` block on the + // top-level chat rather than the immediate parent subagent chat + // (the `subagent_started` signal carries no parent tool call id), + // so a nested subagent's parent chat only ever sees + // start + ready (Running) with `_meta.toolKind === 'subagent'`. + // Observation must therefore proceed from `_meta` alone — without + // it the level-2 subagent (and its client tool) is never observed + // and the session hangs in "Input Needed" with nothing to act on. + const { handler, connection, toolsService } = createHandlerWithMocks(disposables, [testRunTaskTool]); + const sessionResource = URI.parse('agent-host-copilot:/session-1'); + const backendSession = AgentSession.uri('copilot', 'session-1').toString(); + const rootToolCallId = 'tc-l1-task'; + const nestedToolCallId = 'tc-l2-task'; + const subagentChat1 = buildSubagentChatUri(backendSession, rootToolCallId); + const subagentChat2 = buildSubagentChatUri(backendSession, nestedToolCallId); + + // Default turn spawns the level-1 subagent (no content block). + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatTurnStarted, turnId: 'turn-1', + message: { text: 'do work', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallStart, turnId: 'turn-1', + toolCallId: rootToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent' }, + }); + connection.applySessionAction(URI.parse(buildDefaultChatUri(backendSession)), { + type: ActionType.ChatToolCallReady, turnId: 'turn-1', + toolCallId: rootToolCallId, invocationMessage: 'Spawning subagent', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + // Level-1 subagent spawns the level-2 subagent (no content block). + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-1', + message: { text: '', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatToolCallStart, turnId: 'sub-turn-1', + toolCallId: nestedToolCallId, toolName: 'task', displayName: 'Task', _meta: { toolKind: 'subagent' }, + }); + connection.applySessionAction(URI.parse(subagentChat1), { + type: ActionType.ChatToolCallReady, turnId: 'sub-turn-1', + toolCallId: nestedToolCallId, invocationMessage: 'Spawning nested subagent', toolInput: '{}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + // Level-2 subagent runs a client-provided tool. + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatTurnStarted, turnId: 'sub-turn-2', + message: { text: '', origin: { kind: MessageKind.User } }, + }); + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatToolCallStart, turnId: 'sub-turn-2', + toolCallId: 'deep-tool-call', toolName: 'runTask', displayName: 'Run Task', + contributor: { kind: ToolCallContributorKind.Client, clientId: connection.clientId }, + }); + connection.applySessionAction(URI.parse(subagentChat2), { + type: ActionType.ChatToolCallReady, turnId: 'sub-turn-2', + toolCallId: 'deep-tool-call', invocationMessage: 'Run Task', toolInput: '{"task":"build"}', confirmed: ToolCallConfirmationReason.NotNeeded, + }); + + await handler.provideChatSessionContent(sessionResource, CancellationToken.None); + for (let i = 0; i < 200 && !connection.dispatchedActions.some(e => isChatAction(e.action) && e.action.type === ActionType.ChatToolCallComplete && e.action.toolCallId === 'deep-tool-call'); i++) { + await timeout(1); + } + + const deepInvocation = toolsService.invokedToolCalls.find(call => call.callId === 'deep-tool-call'); + assert.ok(deepInvocation, 'client tool inside a content-block-less nested subagent should still be invoked locally'); + assert.deepStrictEqual(deepInvocation!.parameters, { task: 'build' }); + + const completionEntry = connection.dispatchedActions.find(entry => + isChatAction(entry.action) + && entry.action.type === ActionType.ChatToolCallComplete + && entry.action.toolCallId === 'deep-tool-call' + ); + assert.ok(completionEntry, 'completion for the nested client tool should be dispatched'); + assert.strictEqual(completionEntry.channel.toString(), subagentChat2, 'completion should target the level-2 subagent chat URI'); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotPromptContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts similarity index 53% rename from src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotPromptContribution.test.ts rename to src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts index 706cf4e5e7ce8b..65aae6d27c4b2a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotPromptContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostCopilotCliSettingsContribution.test.ts @@ -11,12 +11,13 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { AgentHostEnabledSettingId, AgentHostOpus48PromptEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; -import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostModelCapabilityOverridesSettingId, AgentHostOpus48PromptEnabledSettingId, AgentHostReasoningEffortOverrideSettingId, CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; import type { ClientAnnotationsAction, INotification, IRootConfigChangedAction, SessionAction, TerminalAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { ConfigPropertySchema, RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { AgentHostCopilotPromptContribution } from '../../../browser/agentSessions/agentHost/agentHostCopilotPromptContribution.js'; +import { AgentHostCopilotCliSettingsContribution } from '../../../browser/agentSessions/agentHost/agentHostCopilotCliSettingsContribution.js'; class MockAgentHostService extends mock<IAgentHostService>() { declare readonly _serviceBrand: undefined; @@ -57,61 +58,107 @@ class MockAgentHostService extends mock<IAgentHostService>() { } } -function makeRootStateWithSchema(properties: Record<string, ConfigPropertySchema>): RootState { +function makeRootStateWithSchema(properties: Record<string, ConfigPropertySchema>, values: Record<string, unknown> = {}): RootState { return { agents: [], config: { schema: { type: 'object', properties }, - values: {}, + values, }, }; } +/** The full schema an up-to-date host advertises for the forwarded keys. */ +const fullSchema: Record<string, ConfigPropertySchema> = { + [CopilotCliConfigKey.Opus48Prompt]: { type: 'boolean', title: 'Opus 4.8 Agent Prompt' }, + [CopilotCliConfigKey.ReasoningEffortOverride]: { type: 'string', title: 'Reasoning Effort Override' }, + [CopilotCliConfigKey.ModelCapabilityOverrides]: { type: 'object', title: 'Model Capability Overrides' }, +}; + /** Two microtask hops: one for the await on computeValue, one for the dispatch. */ async function flush(): Promise<void> { await Promise.resolve(); await Promise.resolve(); } -function setup(disposables: DisposableStore, opus48Enabled: boolean) { +function setup(disposables: DisposableStore, settings: Record<string, unknown>) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); disposables.add({ dispose: () => agentHostService.dispose() }); - const configurationService = new TestConfigurationService({ - [AgentHostEnabledSettingId]: true, - [AgentHostOpus48PromptEnabledSettingId]: opus48Enabled, - }); + const configurationService = new TestConfigurationService(settings); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); - disposables.add(instantiationService.createInstance(AgentHostCopilotPromptContribution)); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: true }); + disposables.add(instantiationService.createInstance(AgentHostCopilotCliSettingsContribution)); return { agentHostService }; } -suite('AgentHostCopilotPromptContribution', () => { +suite('AgentHostCopilotCliSettingsContribution', () => { const disposables = new DisposableStore(); teardown(() => disposables.clear()); ensureNoDisposablesAreLeakedInTestSuite(); - test('forwards the Opus 4.8 prompt setting into root config once the schema advertises it', async () => { - const { agentHostService } = setup(disposables, /*opus48Enabled*/ true); + test('forwards the experimentation settings into root config once the schema advertises them', async () => { + const { agentHostService } = setup(disposables, { + [AgentHostOpus48PromptEnabledSettingId]: true, + [AgentHostReasoningEffortOverrideSettingId]: 'xhigh', + [AgentHostModelCapabilityOverridesSettingId]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, + }); + agentHostService.setRootState(makeRootStateWithSchema(fullSchema)); + await flush(); + + // The shared forwarder dispatches one RootConfigChanged per key; merge them + // and assert the full forwarded set (order-independent). + assert.strictEqual(agentHostService.dispatchedActions.length, 3); + const merged = Object.assign({}, ...agentHostService.dispatchedActions.map(a => (a.action as IRootConfigChangedAction).config)); + assert.deepStrictEqual(merged, { + [CopilotCliConfigKey.Opus48Prompt]: true, + [CopilotCliConfigKey.ReasoningEffortOverride]: 'xhigh', + [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, + }); + }); + + test('forwards only the keys an older host advertises', async () => { + const { agentHostService } = setup(disposables, { + [AgentHostOpus48PromptEnabledSettingId]: true, + [AgentHostReasoningEffortOverrideSettingId]: 'xhigh', + }); agentHostService.setRootState(makeRootStateWithSchema({ - [AgentHostConfigKey.Opus48Prompt]: { type: 'boolean', title: 'Opus 4.8 Agent Prompt' }, + [CopilotCliConfigKey.Opus48Prompt]: { type: 'boolean', title: 'Opus 4.8 Agent Prompt' }, })); await flush(); assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.Opus48Prompt]: true, + [CopilotCliConfigKey.Opus48Prompt]: true, }); }); - test('does not dispatch to a host whose schema does not advertise the key', async () => { - const { agentHostService } = setup(disposables, /*opus48Enabled*/ true); + test('does not dispatch to a host whose schema does not advertise any key', async () => { + const { agentHostService } = setup(disposables, { + [AgentHostOpus48PromptEnabledSettingId]: true, + }); agentHostService.setRootState(makeRootStateWithSchema({})); await flush(); assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); }); + + test('does not re-dispatch when the root config already carries structurally equal values', async () => { + const { agentHostService } = setup(disposables, { + [AgentHostOpus48PromptEnabledSettingId]: true, + [AgentHostReasoningEffortOverrideSettingId]: 'xhigh', + [AgentHostModelCapabilityOverridesSettingId]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, + }); + agentHostService.setRootState(makeRootStateWithSchema(fullSchema, { + [CopilotCliConfigKey.Opus48Prompt]: true, + [CopilotCliConfigKey.ReasoningEffortOverride]: 'xhigh', + [CopilotCliConfigKey.ModelCapabilityOverrides]: { 'preview-model-x': { family: 'claude-opus-4-8' } }, + })); + await flush(); + + assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts new file mode 100644 index 00000000000000..460bcd05392143 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostDownloadProgress.test.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { type ProgressParams } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; +import { IProgress, IProgressNotificationOptions, IProgressService, IProgressStep } from '../../../../../../platform/progress/common/progress.js'; +import { ChatConfiguration } from '../../../common/constants.js'; +import { AgentHostDownloadProgress } from '../../../browser/agentSessions/agentHost/agentHostDownloadProgress.js'; + +interface IRecordedProgress { + title: string | undefined; + readonly steps: IProgressStep[]; + dismissed: boolean; + /** Resolves once the backing notification promise settles (i.e. is dismissed). */ + settled: Promise<void>; +} + +/** Records every `withProgress` invocation, the steps reported into it, and whether it resolved. */ +class RecordingProgressService { + readonly opened: IRecordedProgress[] = []; + + withProgress(options: IProgressNotificationOptions, task: (progress: IProgress<IProgressStep>) => Promise<unknown>): Promise<unknown> { + const record: IRecordedProgress = { title: options.title, steps: [], dismissed: false, settled: Promise.resolve() }; + this.opened.push(record); + const result = task({ report: step => { record.steps.push(step); } }); + record.settled = result.then(() => { record.dismissed = true; }, () => { record.dismissed = true; }); + return result; + } +} + +class FakeConfigurationService { + constructor(private readonly _aiDisabled: boolean) { } + getValue(key: string): unknown { + return key === ChatConfiguration.AIDisabled ? this._aiDisabled : undefined; + } +} + +function frame(partial: Partial<ProgressParams> & Pick<ProgressParams, 'progressToken' | 'progress'>): ProgressParams { + return { channel: 'ahp-root://root', ...partial }; +} + +suite('AgentHostDownloadProgress', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function create(aiDisabled = false) { + const progressService = new RecordingProgressService(); + const configurationService = new FakeConfigurationService(aiDisabled); + const controller = store.add(new AgentHostDownloadProgress( + progressService as unknown as IProgressService, + configurationService as unknown as IConfigurationService, + )); + return { controller, progressService }; + } + + test('determinate download opens one notification, reports percent, dismisses on terminal frame', async () => { + const { controller, progressService } = create(); + + controller.handleProgress(frame({ progressToken: 'claude', progress: 0, total: 1000, message: 'Downloading Claude agent' })); + controller.handleProgress(frame({ progressToken: 'claude', progress: 500, total: 1000, message: 'Downloading Claude agent' })); + controller.handleProgress(frame({ progressToken: 'claude', progress: 1000, total: 1000, message: 'Downloading Claude agent' })); + + // The terminal frame resolves the notification promise asynchronously. + await progressService.opened[0].settled; + + assert.deepStrictEqual( + progressService.opened.map(o => ({ title: o.title, steps: o.steps.map(s => s.message), dismissed: o.dismissed })), + [{ title: 'Downloading Claude agent', steps: ['0%', '50%'], dismissed: true }], + ); + }); + + test('indeterminate download (no total) reports megabytes received', () => { + const { controller, progressService } = create(); + + controller.handleProgress(frame({ progressToken: 'codex', progress: 5 * 1024 * 1024, message: 'Downloading Codex agent' })); + + assert.deepStrictEqual( + progressService.opened.map(o => ({ title: o.title, steps: o.steps.map(s => s.message) })), + [{ title: 'Downloading Codex agent', steps: ['5.0 MB'] }], + ); + }); + + test('no notification when AI features are disabled', () => { + const { controller, progressService } = create(true); + + controller.handleProgress(frame({ progressToken: 'claude', progress: 0, total: 1000, message: 'Downloading Claude agent' })); + + assert.strictEqual(progressService.opened.length, 0); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostImportConversationStore.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostImportConversationStore.test.ts new file mode 100644 index 00000000000000..1088b4eb5efd80 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostImportConversationStore.test.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { MessageKind, TurnState, type Turn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { AgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; + +suite('AgentHostImportConversationStore', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const untitled = URI.parse('agent-host-copilotcli:/untitled-1'); + const real = URI.parse('agent-host-copilotcli:/real-1'); + + function turn(text: string): Turn { + return { id: text, message: { text, origin: { kind: MessageKind.User } }, responseParts: [], usage: undefined, state: TurnState.Complete }; + } + + test('set/take round-trips the conversation and consumes it exactly once', () => { + const store = new AgentHostImportConversationStore(); + const conversation = { turns: [turn('a')], model: { id: 'gpt-x' } }; + + store.set(untitled, conversation); + + assert.deepStrictEqual({ + first: store.take(untitled), + second: store.take(untitled), + unknown: store.take(real), + }, { + first: conversation, + second: undefined, + unknown: undefined, + }); + }); + + test('set with no turns is a no-op', () => { + const store = new AgentHostImportConversationStore(); + + store.set(untitled, { turns: [] }); + + assert.strictEqual(store.take(untitled), undefined); + }); + + test('rename moves the stash from the untitled resource to the real one', () => { + const store = new AgentHostImportConversationStore(); + const conversation = { turns: [turn('a')], model: { id: 'gpt-x' } }; + store.set(untitled, conversation); + + store.rename(untitled, real); + + assert.deepStrictEqual({ + old: store.take(untitled), + renamed: store.take(real), + }, { + old: undefined, + renamed: conversation, + }); + }); + + test('rename of a missing entry is a no-op', () => { + const store = new AgentHostImportConversationStore(); + + store.rename(untitled, real); + + assert.strictEqual(store.take(real), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts index 39d892da34e9cc..02a68674cdc78a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostLanguageModelProvider.test.ts @@ -29,7 +29,8 @@ suite('AgentHostLanguageModelProvider', () => { const concrete = infos.find(m => m.metadata.id === 'gpt-5'); assert.strictEqual(auto?.metadata.detail, '10% discount'); - assert.ok(auto?.metadata.tooltip && auto.metadata.tooltip.length > 0, 'Auto should have a tooltip'); + assert.ok(auto?.metadata.tooltip?.includes('10% discount'), 'Auto tooltip should mention the discount'); + assert.ok(auto?.metadata.tooltip?.includes('Learn More'), 'Auto tooltip should include the Learn More link'); // Concrete models get neither the discount detail nor the Auto tooltip. assert.strictEqual(concrete?.metadata.detail, undefined); @@ -44,10 +45,43 @@ suite('AgentHostLanguageModelProvider', () => { let auto = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None)).find(m => m.metadata.id === 'auto'); assert.strictEqual(auto?.metadata.detail, undefined, 'absent discount → no detail'); assert.ok(auto?.metadata.tooltip && auto.metadata.tooltip.length > 0, 'Auto still has a tooltip'); + assert.ok(!auto?.metadata.tooltip?.includes('discount'), 'no discount → tooltip omits the discount sentence'); // Guard: a literal 0 must not render a misleading "0% discount". provider.updateModels([makeModel('auto', { discountPercent: 0 })]); auto = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None)).find(m => m.metadata.id === 'auto'); assert.strictEqual(auto?.metadata.detail, undefined, 'discountPercent 0 → no detail'); }); + + test('derives the picker group from the model-id prefix, not the harness provider', async () => { + const provider = createProvider(); + // The agent host reports every model under the harness provider (`copilotcli`); + // the upstream provider lives in the id prefix. Native models have no prefix. + provider.updateModels([ + { id: 'claude-haiku-4.5', provider: 'copilotcli', name: 'Claude Haiku 4.5' }, + { id: 'openai/gpt-5-nano', provider: 'copilotcli', name: 'GPT-5 nano' }, + { id: 'huggingface/allenai/Olmo-3-7B-Instruct:cheapest', provider: 'copilotcli', name: 'Olmo 3' }, + { id: 'acme/model', provider: 'copilotcli', name: 'Acme' }, + ]); + + const infos = await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None); + const groups = Object.fromEntries(infos.map(m => [m.metadata.id, m.metadata.modelGroup])); + + // The group carries only the vendor id — native (no prefix) → harness `provider`, + // BYOK-routed → id prefix. The picker resolves the display name from the vendor registry. + assert.deepStrictEqual(groups, { + 'claude-haiku-4.5': { id: 'copilotcli' }, + 'openai/gpt-5-nano': { id: 'openai' }, + 'huggingface/allenai/Olmo-3-7B-Instruct:cheapest': { id: 'huggingface' }, + 'acme/model': { id: 'acme' }, + }); + }); + + test('omits the model group when the provider is empty', async () => { + const provider = createProvider(); + provider.updateModels([{ id: 'x', provider: '', name: 'X' }]); + + const info = (await provider.provideLanguageModelChatInfo(undefined, CancellationToken.None))[0]; + assert.strictEqual(info.metadata.modelGroup, undefined); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts new file mode 100644 index 00000000000000..981429b5dc70a7 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostModeSynchronizer.test.ts @@ -0,0 +1,156 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { timeout } from '../../../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { agentHostAgentPickerStorageKey } from '../../../../../../platform/agentHost/common/customAgents.js'; +import { StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; +import { TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { AgentHostModeSynchronizer } from '../../../browser/agentSessions/agentHost/agentHostModeSynchronizer.js'; +import { IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; +import { ChatMode, IChatMode, IChatModes } from '../../../common/chatModes.js'; +import { ChatModeKind } from '../../../common/constants.js'; +import type { IChatModeChangeEvent } from '../../../browser/widget/input/chatInputPart.js'; + +suite('AgentHostModeSynchronizer', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + const sessionResource = URI.parse('agent-host-claude:/session-1'); + const agentUri = 'file:///agent.md'; + + function createCustomMode(): IChatMode { + return { + id: agentUri, + kind: ChatModeKind.Agent, + isBuiltin: false, + } as IChatMode; + } + + function createModes(customModes: () => readonly IChatMode[], onDidChange: Event<void>): IChatModes { + return { + onDidChange, + builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], + get custom() { + return customModes(); + }, + findModeById: id => [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit, ...customModes()].find(mode => mode.id === id), + findModeByName: name => [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit, ...customModes()].find(mode => mode.name?.get() === name), + waitForPendingUpdates: async () => { }, + }; + } + + function createSynchronizer(initialMode: IChatMode, initialCustomModes: readonly IChatMode[] = [], resource: URI = sessionResource) { + let customModes = [...initialCustomModes]; + const modeChanges = store.add(new Emitter<IChatModeChangeEvent>()); + const modesChanges = store.add(new Emitter<void>()); + const mode = observableValue<IChatMode>('mode', initialMode); + const modes = createModes(() => customModes, modesChanges.event); + const modesObservable = observableValue<IChatModes>('modes', modes); + const setChatModeCalls: string[] = []; + + const widget = { + viewModel: { sessionResource: resource }, + input: { + onDidChangeCurrentChatMode: modeChanges.event, + currentModeObs: mode, + currentChatModesObs: modesObservable, + setChatMode: (modeId: string) => { + setChatModeCalls.push(modeId); + const next = modes.findModeById(modeId); + if (next) { + mode.set(next, undefined); + } + }, + }, + onDidChangeViewModel: Event.None, + } as unknown as IChatWidget; + + const widgetService = { + getAllWidgets: () => [widget], + onDidAddWidget: Event.None, + onDidChangeFocusedSession: Event.None, + getWidgetBySessionResource: (r: URI) => r.toString() === resource.toString() ? widget : undefined, + lastFocusedWidget: widget, + } as unknown as IChatWidgetService; + + const provisionalSessionService = { + onDidChange: Event.None, + get: () => undefined, + } as unknown as IAgentHostUntitledProvisionalSessionService; + + const storageService = store.add(new TestStorageService()); + const environmentService = { isSessionsWindow: false } as IWorkbenchEnvironmentService; + const synchronizer = store.add(new AgentHostModeSynchronizer(widgetService, provisionalSessionService, storageService, environmentService)); + + return { + modeChanges, + modesChanges, + setCustomModes: (next: readonly IChatMode[]) => { + customModes = [...next]; + }, + setChatModeCalls, + storageService, + synchronizer, + }; + } + + test('persists only user initiated custom agent changes', () => { + const { modeChanges, storageService } = createSynchronizer(createCustomMode(), [createCustomMode()]); + const key = agentHostAgentPickerStorageKey(sessionResource.scheme); + + modeChanges.fire({ isUserInitiated: false }); + assert.strictEqual(storageService.get(key, StorageScope.PROFILE), undefined); + + modeChanges.fire({ isUserInitiated: true }); + assert.strictEqual(storageService.get(key, StorageScope.PROFILE), agentUri); + }); + + test('does not force default Agent when storage is empty', async () => { + const { setChatModeCalls } = createSynchronizer(ChatMode.Ask); + + await timeout(0); + + assert.deepStrictEqual(setChatModeCalls, []); + }); + + test('retries restore when custom modes load late', async () => { + // The synchronizer only SEEDS the shared per-scheme agent into untitled (new) sessions; + // established sessions restore their own persisted mode elsewhere (ChatInputPart). Use an + // untitled resource so this exercises the seed-retry path when custom modes load late. + const untitledResource = URI.parse('agent-host-claude:/untitled-session-1'); + const { modesChanges, setChatModeCalls, setCustomModes, storageService } = createSynchronizer(ChatMode.Agent, [], untitledResource); + storageService.store(agentHostAgentPickerStorageKey(untitledResource.scheme), agentUri, StorageScope.PROFILE, StorageTarget.MACHINE); + + await timeout(0); + assert.deepStrictEqual(setChatModeCalls, []); + + setCustomModes([createCustomMode()]); + modesChanges.fire(); + await timeout(0); + + assert.deepStrictEqual(setChatModeCalls, [agentUri]); + }); + + test('does not restore the shared per-scheme agent to an established (non-untitled) session', async () => { + // Regression for the "custom agent picker flips to a stale agent after send" bug: the + // shared per-scheme agent is a seed for NEW sessions only, so an established/restored + // session must never have it applied — even when its custom modes load late. + const { modesChanges, setChatModeCalls, setCustomModes, storageService } = createSynchronizer(ChatMode.Agent); + storageService.store(agentHostAgentPickerStorageKey(sessionResource.scheme), agentUri, StorageScope.PROFILE, StorageTarget.MACHINE); + + await timeout(0); + setCustomModes([createCustomMode()]); + modesChanges.fire(); + await timeout(0); + + assert.deepStrictEqual(setChatModeCalls, []); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts index e3d669eb2c5db7..fbd83f2dc77b9c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostTerminalContribution.test.ts @@ -12,8 +12,12 @@ import { mock } from '../../../../../../base/test/common/mock.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 { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; +import type { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../../../../base/common/defaultAccount.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { AgentHostCustomTerminalToolEnabledSettingId, AgentHostEnabledSettingId, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostCustomTerminalToolEnabledSettingId, CopilotCliConfigKey } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { AgentHostConfigKey } from '../../../../../../platform/agentHost/common/agentHostCustomizationConfig.js'; import { ActionType } from '../../../../../../platform/agentHost/common/state/protocol/actions.js'; import { IAgentSubscription } from '../../../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -121,6 +125,34 @@ class MockTerminalProfileService extends mock<ITerminalProfileService>() { } } +// ---- Mock default account service (enterprise state + GitHub base URL) ---- + +class MockDefaultAccountService extends mock<IDefaultAccountService>() { + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeDefaultAccount = new Emitter<IDefaultAccount | null>(); + override readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; + + public enterprise = false; + public gitHubBaseUrl = 'https://github.com'; + + override getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return { id: 'github', name: 'GitHub', enterprise: this.enterprise }; + } + + override resolveGitHubUrl(path: string): string { + return `${this.gitHubBaseUrl}/${path}`; + } + + fireChange(): void { + this._onDidChangeDefaultAccount.fire(null); + } + + dispose(): void { + this._onDidChangeDefaultAccount.dispose(); + } +} + // ---- Helpers ---- function makeRootStateWithSchema(properties: Record<string, unknown>): RootState { @@ -149,7 +181,13 @@ function rootStateWithoutDefaultShellKey(): RootState { function rootStateWithEnableCustomTerminalToolKey(): RootState { return makeRootStateWithSchema({ - [AgentHostConfigKey.EnableCustomTerminalTool]: { type: 'boolean', title: 'Use Agent Host Terminal Tool' }, + [CopilotCliConfigKey.EnableCustomTerminalTool]: { type: 'boolean', title: 'Use Agent Host Terminal Tool' }, + }); +} + +function rootStateWithGithubEnterpriseUriKey(): RootState { + return makeRootStateWithSchema({ + [AgentHostConfigKey.GithubEnterpriseUri]: { type: 'string', title: 'GitHub Enterprise URI' }, }); } @@ -159,6 +197,7 @@ interface ITestSetup { resolver: MockTerminalProfileResolverService; profileService: MockTerminalProfileService; configurationService: TestConfigurationService; + defaultAccountService: MockDefaultAccountService; } function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): ITestSetup { @@ -168,22 +207,25 @@ function setup(disposables: DisposableStore, agentHostEnabled: boolean = true): const resolver = new MockTerminalProfileResolverService(); const profileService = new MockTerminalProfileService(); disposables.add({ dispose: () => profileService.dispose() }); + const defaultAccountService = new MockDefaultAccountService(); + disposables.add({ dispose: () => defaultAccountService.dispose() }); const configurationService = new TestConfigurationService({ - [AgentHostEnabledSettingId]: agentHostEnabled, [AgentHostCustomTerminalToolEnabledSettingId]: true, }); instantiationService.stub(IAgentHostService, agentHostService); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IAgentHostEnablementService, { _serviceBrand: undefined, enabled: agentHostEnabled }); instantiationService.stub(ITerminalProfileResolverService, resolver); instantiationService.stub(ITerminalProfileService, profileService); + instantiationService.stub(IDefaultAccountService, defaultAccountService); instantiationService.stub(IAgentHostTerminalService, { registerEntry: (): IDisposable => ({ dispose() { } }), profiles: observableValue('test', []), }); const contribution = disposables.add(instantiationService.createInstance(AgentHostTerminalContribution)); - return { contribution, agentHostService, resolver, profileService, configurationService }; + return { contribution, agentHostService, resolver, profileService, configurationService, defaultAccountService }; } /** Wait for any in-flight `_pushDefaultShell` promises to settle. */ @@ -363,7 +405,7 @@ suite('AgentHostTerminalContribution', () => { assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.EnableCustomTerminalTool]: false, + [CopilotCliConfigKey.EnableCustomTerminalTool]: false, }); }); @@ -375,14 +417,14 @@ suite('AgentHostTerminalContribution', () => { assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.EnableCustomTerminalTool]: true, + [CopilotCliConfigKey.EnableCustomTerminalTool]: true, }); }); test('re-dispatches enableCustomTerminalTool when the enabled setting changes', async () => { const { agentHostService, configurationService } = setup(disposables); const rootState = rootStateWithEnableCustomTerminalToolKey(); - rootState.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = true; + rootState.config!.values[CopilotCliConfigKey.EnableCustomTerminalTool] = true; agentHostService.setRootState(rootState); await flush(); assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); @@ -398,7 +440,7 @@ suite('AgentHostTerminalContribution', () => { assert.strictEqual(agentHostService.dispatchedActions.length, 1); assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { - [AgentHostConfigKey.EnableCustomTerminalTool]: false, + [CopilotCliConfigKey.EnableCustomTerminalTool]: false, }); }); @@ -427,18 +469,61 @@ suite('AgentHostTerminalContribution', () => { // Schema hydrates with our preferred value already present → no push. const rootState = rootStateWithEnableCustomTerminalToolKey(); - rootState.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = true; + rootState.config!.values[CopilotCliConfigKey.EnableCustomTerminalTool] = true; agentHostService.setRootState(rootState); await flush(); assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); // Another window flips the shared value. Schema unchanged → no fight. const updated = rootStateWithEnableCustomTerminalToolKey(); - updated.config!.values[AgentHostConfigKey.EnableCustomTerminalTool] = false; + updated.config!.values[CopilotCliConfigKey.EnableCustomTerminalTool] = false; agentHostService.setRootState(updated); await flush(); assert.deepStrictEqual(agentHostService.dispatchedActions as readonly unknown[], []); }); + + test('dispatches the enterprise base when signed in via a GHE provider', async () => { + const { agentHostService, defaultAccountService } = setup(disposables); + defaultAccountService.enterprise = true; + defaultAccountService.gitHubBaseUrl = 'https://acme.ghe.com'; + + agentHostService.setRootState(rootStateWithGithubEnterpriseUriKey()); + await flush(); + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { + [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com', + }); + }); + + test('dispatches an empty enterprise URI for a github.com account', async () => { + const { agentHostService } = setup(disposables); // default account is not enterprise + + agentHostService.setRootState(rootStateWithGithubEnterpriseUriKey()); + await flush(); + + assert.strictEqual(agentHostService.dispatchedActions.length, 1); + assert.deepStrictEqual((agentHostService.dispatchedActions[0].action as IRootConfigChangedAction).config, { + [AgentHostConfigKey.GithubEnterpriseUri]: '', + }); + }); + + test('re-dispatches the enterprise URI when the default account changes', async () => { + const { agentHostService, defaultAccountService } = setup(disposables); + agentHostService.setRootState(rootStateWithGithubEnterpriseUriKey()); + await flush(); + assert.strictEqual(agentHostService.dispatchedActions.length, 1); // initial '' push + + defaultAccountService.enterprise = true; + defaultAccountService.gitHubBaseUrl = 'https://acme.ghe.com'; + defaultAccountService.fireChange(); + await flush(); + + assert.strictEqual(agentHostService.dispatchedActions.length, 2); + assert.deepStrictEqual((agentHostService.dispatchedActions[1].action as IRootConfigChangedAction).config, { + [AgentHostConfigKey.GithubEnterpriseUri]: 'https://acme.ghe.com', + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts index 5a505852355ae5..1c6eaf1e913a73 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostUntitledProvisionalSessionService.test.ts @@ -23,6 +23,7 @@ import { IWorkspaceTrustManagementService } from '../../../../../../platform/wor import { IChatService } from '../../../common/chatService/chatService.js'; import { AgentHostUntitledProvisionalSessionService, IAgentHostUntitledProvisionalSessionService } from '../../../browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { AgentHostNewSessionFolderService, IAgentHostNewSessionFolderService } from '../../../browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; +import { AgentHostImportConversationStore, IAgentHostImportConversationStore } from '../../../browser/agentSessions/agentHost/agentHostImportConversationStore.js'; // ---- Mocks ----------------------------------------------------------------- @@ -133,6 +134,7 @@ suite('AgentHostUntitledProvisionalSessionService', () => { }); folderService = ds.add(insta.createInstance(AgentHostNewSessionFolderService)); insta.stub(IAgentHostNewSessionFolderService, folderService); + insta.stub(IAgentHostImportConversationStore, new AgentHostImportConversationStore()); provisional = ds.add(insta.createInstance(AgentHostUntitledProvisionalSessionService)); cleanup = ds.add(new DisposableStore()); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ff1ee861755bed..726f4a8e425a6a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -2171,7 +2171,7 @@ suite('AgentSessions', () => { test('should return correct icon for AgentHostCopilot provider', () => { const icon = getAgentSessionProviderIcon(AgentSessionProviders.AgentHostCopilot); - assert.strictEqual(icon.id, Codicon.copilot.id); + assert.strictEqual(icon.id, Codicon.vm.id); }); test('should return simplified AgentHostCopilot name', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts new file mode 100644 index 00000000000000..f886db7aa57a5e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/importLocalConversationToAgentSession.test.ts @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ResponsePartKind, ToolResultContentType, TurnState, type ResponsePart, type ToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import type { IChatProgressResponseContent, IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; +import { importedTurnsFromChatModel } from '../../../browser/agentSessions/agentHost/importLocalConversationToAgentSession.js'; + +suite('importedTurnsFromChatModel', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function markdown(value: string): IChatProgressResponseContent { + return { kind: 'markdownContent', content: new MarkdownString(value) } as IChatProgressResponseContent; + } + + function thinking(value: string): IChatProgressResponseContent { + return { kind: 'thinking', value } as IChatProgressResponseContent; + } + + function inlineReference(uri: URI, name?: string): IChatProgressResponseContent { + return { kind: 'inlineReference', inlineReference: uri, name } as IChatProgressResponseContent; + } + + function subagentTool(toolCallId: string, agentName: string, description: string, result: string): IChatProgressResponseContent { + return { + kind: 'toolInvocationSerialized', + toolId: 'delegate', + toolCallId, + invocationMessage: 'Delegating', + pastTenseMessage: 'Delegated', + resultDetails: undefined, + toolSpecificData: { kind: 'subagent', agentName, description, prompt: 'go', result }, + } as unknown as IChatProgressResponseContent; + } + + function response(parts: IChatProgressResponseContent[], opts?: { canceled?: boolean; error?: { message: string; code?: string } }): IChatResponseModel { + return { + entireResponse: { value: parts }, + isCanceled: !!opts?.canceled, + result: opts?.error ? { errorDetails: opts.error } : undefined, + } as unknown as IChatResponseModel; + } + + function request(text: string, response?: IChatResponseModel, opts?: { systemInitiated?: boolean }): IChatRequestModel { + return { message: { text }, response, isSystemInitiated: opts?.systemInitiated } as unknown as IChatRequestModel; + } + + function model(requests: IChatRequestModel[]): IChatModel { + return { getRequests: () => requests } as unknown as IChatModel; + } + + function subagentOf(part: ResponsePart) { + if (part.kind !== ResponsePartKind.ToolCall) { + return undefined; + } + const sub = (part.toolCall as ToolCallCompletedState).content?.find(c => c.type === ToolResultContentType.Subagent); + return sub && sub.type === ToolResultContentType.Subagent ? { agentName: sub.agentName, description: sub.description } : undefined; + } + + function project(model: IChatModel) { + return importedTurnsFromChatModel(model).map(turn => ({ + text: turn.message.text, + state: turn.state, + error: turn.error, + parts: turn.responseParts.map(part => + part.kind === ResponsePartKind.Markdown || part.kind === ResponsePartKind.Reasoning + ? { kind: part.kind, content: part.content } + : { kind: part.kind, subagent: subagentOf(part) }), + })); + } + + test('maps markdown, reasoning and inline references in stream order', () => { + const result = project(model([request('q', response([ + markdown('Found in '), + inlineReference(URI.file('/repo/a.ts')), + markdown(' — done'), + thinking('let me check'), + ]))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Complete, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'Found in ' }, + { kind: ResponsePartKind.Markdown, content: `[a.ts](${URI.file('/repo/a.ts').toString()})` }, + { kind: ResponsePartKind.Markdown, content: ' — done' }, + { kind: ResponsePartKind.Reasoning, content: 'let me check' }, + ], + }]); + }); + + test('maps a cancelled response to a cancelled turn', () => { + const result = project(model([request('q', response([markdown('partial')], { canceled: true }))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Cancelled, + error: undefined, + parts: [{ kind: ResponsePartKind.Markdown, content: 'partial' }], + }]); + }); + + test('maps an errored response to an error turn carrying the message and code', () => { + const result = project(model([request('q', response([], { error: { message: 'boom', code: 'E1' } }))])); + + assert.deepStrictEqual(result, [{ + text: 'q', + state: TurnState.Error, + error: { errorType: 'E1', message: 'boom' }, + parts: [], + }]); + }); + + test('folds a system-initiated continuation into the previous turn and supersedes its outcome', () => { + const result = project(model([ + request('real question', response([markdown('working')])), + request('[Terminal notification]', response([markdown('continued')], { canceled: true }), { systemInitiated: true }), + ])); + + assert.deepStrictEqual(result, [{ + text: 'real question', + state: TurnState.Cancelled, + error: undefined, + parts: [ + { kind: ResponsePartKind.Markdown, content: 'working' }, + { kind: ResponsePartKind.Markdown, content: 'continued' }, + ], + }]); + }); + + test('maps a sub-agent tool invocation preserving its identity as structured content', () => { + const result = project(model([request('delegate', response([subagentTool('tc-1', 'explore', 'Explores the codebase', 'done')]))])); + + assert.deepStrictEqual(result, [{ + text: 'delegate', + state: TurnState.Complete, + error: undefined, + parts: [{ kind: ResponsePartKind.ToolCall, subagent: { agentName: 'explore', description: 'Explores the codebase' } }], + }]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index eebe64cbe24797..9ace81a0c43e5f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -9,6 +9,7 @@ import { Emitter, Event } from '../../../../../../base/common/event.js'; import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ConfigurationTarget } from '../../../../../../platform/configuration/common/configuration.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { resolveCustomizationRefs } from '../../../browser/agentSessions/agentHost/agentHostLocalCustomizations.js'; @@ -19,13 +20,39 @@ import { ContributionEnablementState } from '../../../common/enablement.js'; import { type IAgentPlugin, type IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { type IPromptPath, type IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; -import { type IMcpServer, type IMcpService, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { type IMcpServer, type IMcpService, McpCollectionDefinition, McpServerLaunch, McpServerTransportType } from '../../../../mcp/common/mcpTypes.js'; +import { IConfigurationResolverService } from '../../../../../services/configurationResolver/common/configurationResolver.js'; +import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { SessionType } from '../../../common/chatSessionsService.js'; function makePromptPath(uri: URI, type: PromptsType, storage: PromptsStorage): IPromptPath { return { uri, type, storage } as IPromptPath; } +/** + * A fake {@link IConfigurationResolverService} whose `resolveAsync` mirrors the + * real service: it resolves the given `${...}` variables from `resolutions` and + * leaves any others (e.g. `${input:…}`) untouched so they remain unresolved. + */ +function makeConfigurationResolverService(resolutions: Record<string, string> = {}): IConfigurationResolverService { + return { + async resolveAsync(_folder: unknown, config: unknown) { + const expr = ConfigurationResolverExpression.parse(config as object); + for (const replacement of expr.unresolved()) { + if (Object.prototype.hasOwnProperty.call(resolutions, replacement.id)) { + expr.resolve(replacement, resolutions[replacement.id]); + } else if (replacement.name === 'input' || replacement.name === 'command') { + // Mirror the real resolver: without a value mapping, interactive + // variables "resolve" to their own literal text, dropping out of + // `unresolved()`. + expr.resolve(replacement, replacement.id); + } + } + return expr.toObject(); + }, + } as unknown as IConfigurationResolverService; +} + function makePromptsService(files: ReadonlyMap<string, readonly IPromptPath[]>): IPromptsService { return { async listPromptFilesForStorage(type: PromptsType, storage: PromptsStorage): Promise<readonly IPromptPath[]> { @@ -72,9 +99,10 @@ function makeFileService(stats: ReadonlyMap<string, { mtime: number }> = new Map } as unknown as IFileService; } -function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined }): IMcpServer { - const { id, collectionId, label = id, enabled = true, launch } = options; - const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection: undefined }); +function makeMcpServer(options: { id: string; collectionId: string; label?: string; enabled?: boolean; launch?: McpServerLaunch | undefined; configTarget?: ConfigurationTarget }): IMcpServer { + const { id, collectionId, label = id, enabled = true, launch, configTarget = ConfigurationTarget.USER } = options; + const collection = { id: collectionId, label: collectionId, order: 0, configTarget } as unknown as McpCollectionDefinition; + const definitions = observableValue('definitions', { server: launch ? { launch } : undefined, collection }); return { definition: { id, label }, collection: { id: collectionId, label: collectionId, order: 0 }, @@ -100,6 +128,26 @@ const stdioLaunch: McpServerLaunch = { sandbox: undefined, }; +const stdioLaunchWithInput: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--token', '${input:token}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + +const stdioLaunchWithFolder: McpServerLaunch = { + type: McpServerTransportType.Stdio, + command: 'my-server', + args: ['--root', '${workspaceFolder}'], + env: {}, + envFile: undefined, + cwd: undefined, + sandbox: undefined, +}; + type LocalSyncableFile = { readonly uri: URI; readonly type: PromptsType }; class FakeBundler { @@ -133,6 +181,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -162,6 +211,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([disabled.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -184,6 +234,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -211,6 +262,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([builtin.toString()])), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -230,6 +282,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'MCP Only', mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -248,6 +301,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false, mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -265,6 +319,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { enabled: false })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -279,6 +334,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(new Set([pluginUri.toString()])), makeAgentPluginService([makePlugin(pluginUri, { mcpServers: 1 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -297,6 +353,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService([makePlugin(pluginUri, { label: 'Combined', mcpServers: 2 })]), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -313,6 +370,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), makeMcpService(), + makeConfigurationResolverService(), new FakeBundler() as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -333,6 +391,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -357,6 +416,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); @@ -378,10 +438,165 @@ suite('resolveCustomizationRefs - built-in skills', () => { new FakeSyncProvider(), makeAgentPluginService(), mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('excludes workspace-discovered `.mcp.json` servers (the agent host discovers those itself)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wsdot.srv', collectionId: 'workspace-dot-mcp.0', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), bundler as unknown as SyncedCustomizationBundler, SessionType.CopilotCLI, ); assert.strictEqual(bundler.received.length, 0); }); + + test('excludes `.code-workspace` configured servers', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'wscfg.srv', collectionId: 'mcp.config.workspace', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers that resolve without user interaction', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.my-server', collectionId: 'mcp.config.ws0', label: 'my-server', launch: stdioLaunch, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'my-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + assert.strictEqual(refs[0].name, 'Open Plugin'); + }); + + test('excludes `.vscode/mcp.json` servers with variables that require interaction (e.g. ${input:…})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.needs-input', collectionId: 'mcp.config.ws0', label: 'needs-input', launch: stdioLaunchWithInput, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('syncs `.vscode/mcp.json` servers after resolving non-interactive variables (e.g. ${workspaceFolder})', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService({ '${workspaceFolder}': '/ws' }), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0], [ + { name: 'folder-server', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--root', '/ws'], env: undefined, envFile: undefined, cwd: undefined } }, + ]); + assert.strictEqual(refs.length, 1); + }); + + test('excludes `.vscode/mcp.json` servers when variable resolution throws', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'mcp.config.ws0.folder', collectionId: 'mcp.config.ws0', label: 'folder-server', launch: stdioLaunchWithFolder, configTarget: ConfigurationTarget.WORKSPACE_FOLDER }), + ]); + const throwingResolver = { + async resolveAsync() { throw new Error('no workspace folder'); }, + } as unknown as IConfigurationResolverService; + + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + throwingResolver, + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 0); + }); + + test('still syncs extension-contributed servers (workspace scope, user config target)', async () => { + const bundler = new FakeBundler(); + const mcpService = makeMcpService([ + makeMcpServer({ id: 'ext.foo.srv', collectionId: 'ext.foo', label: 'srv', launch: stdioLaunch, configTarget: ConfigurationTarget.USER }), + ]); + + const refs = await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + mcpService, + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + SessionType.CopilotCLI, + ); + + assert.strictEqual(bundler.received.length, 1); + assert.deepStrictEqual(bundler.receivedMcp[0].map(s => s.name), ['srv']); + assert.strictEqual(refs.length, 1); + }); }); 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 988709e248827e..f1f98fdcfa2e4d 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 @@ -6,11 +6,13 @@ import assert from 'assert'; import { autorun } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; +import type { IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; -import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatProgressMessage, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; +import { fromAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; +import { buildSubagentChatUri, MessageKind, ToolCallStatus, ToolCallConfirmationReason, ToolResultContentType, TurnState, ResponsePartKind, type ActiveTurn, type ICompletedToolCall, type ToolCallRunningState, type Turn, type ToolCallResponsePart, ToolCallCancellationReason, type Message } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { IChatToolInvocation, IChatToolInvocationSerialized, type IChatMarkdownContent, type IChatThinkingPart, type IChatUsage } from '../../../common/chatService/chatService.js'; import { isToolResultInputOutputDetails, type IToolResultInputOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; -import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas, formatTurnResponseDetails } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; +import { turnsToHistory as rawTurnsToHistory, activeTurnToProgress as rawActiveTurnToProgress, toolCallStateToInvocation as rawToolCallStateToInvocation, finalizeToolInvocation as rawFinalizeToolInvocation, updateRunningToolSpecificData as rawUpdateRunningToolSpecificData, usageInfoToQuotas, formatTurnResponseDetails, rewriteAgentHostLinkTarget, rewriteMarkdownLinks } from '../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; // ---- Helper factories ------------------------------------------------------- @@ -105,6 +107,63 @@ suite('stateToProgressAdapter', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('rewriteAgentHostLinkTarget', () => { + test('supports absolute paths and file URIs with validated locations', () => { + const unwrap = (href: string) => fromAgentHostUri(URI.parse(rewriteAgentHostLinkTarget(href, 'my-host'))).toString(); + assert.deepStrictEqual( + [ + unwrap('C:\\remote\\windows.ts:42'), + unwrap('\\\\server\\share\\unc.ts:42'), + unwrap('FILE:///remote/upper.ts:42'), + unwrap('/remote/zero.ts:0'), + unwrap('/remote/zero-column.ts:42:0'), + unwrap('/remote/numeric-segment.ts:42:name.ts'), + unwrap('/remote/scientific.ts:1e2'), + unwrap('/remote/encoded%3A42'), + unwrap('/remote/encoded%3A42:10'), + unwrap('file:///remote/encoded%3A42'), + unwrap('file:///remote/encoded%3A42:10'), + unwrap('file:///remote/queried.ts?rev=1:42'), + unwrap('/remote/range.ts:42-48'), + ], + [ + URI.file('C:/remote/windows.ts').with({ fragment: 'L42' }).toString(), + URI.file('//server/share/unc.ts').with({ fragment: 'L42' }).toString(), + URI.file('/remote/upper.ts').with({ fragment: 'L42' }).toString(), + URI.file('/remote/zero.ts:0').toString(), + URI.file('/remote/zero-column.ts:42:0').toString(), + URI.file('/remote/numeric-segment.ts:42:name.ts').toString(), + URI.file('/remote/scientific.ts:1e2').toString(), + URI.file('/remote/encoded:42').toString(), + URI.file('/remote/encoded:42').with({ fragment: 'L10' }).toString(), + URI.file('/remote/encoded:42').toString(), + URI.file('/remote/encoded:42').with({ fragment: 'L10' }).toString(), + URI.file('/remote/queried.ts').with({ query: 'rev=1:42' }).toString(), + URI.file('/remote/range.ts:42-48').toString(), + ], + ); + }); + + test('preserves client-handled link schemes', () => { + assert.deepStrictEqual( + [ + rewriteAgentHostLinkTarget('vscode-browser://example.com', 'my-host'), + rewriteAgentHostLinkTarget('copilot-skill:/plan', 'my-host'), + rewriteAgentHostLinkTarget('C:relative', 'my-host'), + rewriteAgentHostLinkTarget('git:foo', 'my-host'), + rewriteAgentHostLinkTarget('urn:isbn:123', 'my-host'), + ], + [ + 'vscode-browser://example.com', + 'copilot-skill:/plan', + 'C:relative', + 'git:foo', + 'urn:isbn:123', + ], + ); + }); + }); + suite('turnsToHistory', () => { test('empty turns produces empty history', () => { @@ -151,7 +210,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(history[0].systemInitiatedLabel, undefined); }); - test('system notification response part restores as progress message', () => { + test('system notification response part restores as system notification', () => { const turn = createTurn({ responseParts: [{ kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }], }); @@ -160,8 +219,9 @@ suite('stateToProgressAdapter', () => { const response = history[1]; assert.strictEqual(response.type, 'response'); if (response.type !== 'response') { return; } - const progress = response.parts[0] as IChatProgressMessage; - assert.strictEqual(progress.kind, 'progressMessage'); + const progress = response.parts[0]; + assert.strictEqual(progress.kind, 'systemNotification'); + if (progress.kind !== 'systemNotification') { return; } assert.strictEqual(progress.content.value, 'Shell command completed'); }); @@ -373,6 +433,31 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandState.exitCode, 0); }); + test('terminal tool call in history carries the LM intention', () => { + const turn = createTurn({ + responseParts: [{ + kind: ResponsePartKind.ToolCall, toolCall: createCompletedToolCall({ + intention: 'List files in the repo root', + toolInput: 'ls', + content: [ + { type: ToolResultContentType.Terminal, resource: 'agenthost-terminal:///intent', title: 'Terminal' }, + { type: ToolResultContentType.Text, text: 'a\nb' }, + ], + success: true, + }) + } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; intention?: string }; + assert.strictEqual(termData.intention, 'List files in the repo root'); + }); + test('terminal tool call in history does not set pastTenseMessage (avoids duplicate render)', () => { const turn = createTurn({ responseParts: [{ @@ -450,6 +535,8 @@ suite('stateToProgressAdapter', () => { // description is the TASK description from _meta, not the agent description assert.strictEqual(serialized.toolSpecificData.description, 'Find related files'); assert.strictEqual(serialized.toolSpecificData.result, 'Agent result'); + // The subagent chat resource is carried so the UI can offer "Open chat". + assert.strictEqual(serialized.toolSpecificData.chatResource, 'copilot://session/subagent/tc-1'); } }); @@ -499,12 +586,13 @@ 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', () => { + test('markdown links in response content stay raw until rendering', () => { + const content = 'See [local](file:///a/b.ts), [external](https://example.com) and [rel](./foo.md).'; 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).', + content, }], }); @@ -513,12 +601,7 @@ suite('stateToProgressAdapter', () => { 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/a/b.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0), ' + - '[](vscode-agent-host://my-host/s/x?_ah%3DeyJzY2hlbWUiOiJhZ2VudGhvc3QtY29udGVudCJ9), ' + - '[external](https://example.com) and ' + - '[rel](./foo.md).' - ); + assert.strictEqual(part.content.value, content); }); test('markdown link syntax inside fenced code blocks is preserved verbatim', () => { @@ -531,15 +614,7 @@ suite('stateToProgressAdapter', () => { '', '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; + const value = rewriteMarkdownLinks(input, 'my-host'); assert.ok(value.includes('[](vscode-agent-host://my-host/a.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)')); assert.ok(value.includes('[](vscode-agent-host://my-host/c.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0)')); // The link inside the fenced code block must NOT be rewritten. @@ -549,34 +624,14 @@ suite('stateToProgressAdapter', () => { 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; + const value = rewriteMarkdownLinks(input, 'my-host'); assert.strictEqual(value, 'Real [](vscode-agent-host://my-host/a.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0) and literal `[two](file:///b.ts)` here.' ); }); test('preserves label and tags vscodeLinkType=skill for SKILL.md links', () => { - const turn = createTurn({ - responseParts: [{ - kind: ResponsePartKind.Markdown, - id: 'md-skill', - content: 'Loaded [plan](file:///abs/repo/skills/plan/SKILL.md) and [other](file:///abs/repo/foo.ts).', - }], - }); - - 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; + const value = rewriteMarkdownLinks('Loaded [plan](file:///abs/repo/skills/plan/SKILL.md) and [other](file:///abs/repo/foo.ts).', 'my-host'); assert.strictEqual(value, 'Loaded [plan](vscode-agent-host://my-host/abs/repo/skills/plan/SKILL.md?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0%26vscodeLinkType%3Dskill) ' + 'and [](vscode-agent-host://my-host/abs/repo/foo.ts?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0).' @@ -584,19 +639,7 @@ suite('stateToProgressAdapter', () => { }); test('preserves alt text for image tokens', () => { - const turn = createTurn({ - responseParts: [{ - kind: ResponsePartKind.Markdown, - id: 'md-image', - content: 'See ![diagram](file:///a/b.png).', - }], - }); - - 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; + const value = rewriteMarkdownLinks('See ![diagram](file:///a/b.png).', 'my-host'); assert.strictEqual(value, 'See ![diagram](vscode-agent-host://my-host/a/b.png?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0).'); }); @@ -789,6 +832,24 @@ suite('stateToProgressAdapter', () => { } }); + test('synthesizes subagent chatResource from the tool call id when no discovery content block is present', () => { + // A background subagent's `subagent_started` can arrive after its + // spawning tool call has already completed, so the running-only + // discovery content update is dropped and the child chat resource + // never lands on the tool call. The chat resource must still be + // derivable from the session + tool call id so the inline subagent + // pill remains linkable. + const tc = createToolCallState({ + _meta: { toolKind: 'subagent', subagentDescription: 'Map aux bar + editor part creation' }, + }); + + const invocation = toolCallStateToInvocation(tc); + assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); + if (invocation.toolSpecificData?.kind === 'subagent') { + assert.strictEqual(invocation.toolSpecificData.chatResource, buildSubagentChatUri(URI.file('/').toString(), 'tc-1')); + } + }); + test('passes subAgentInvocationId to ChatToolInvocation', () => { const tc = createToolCallState({}); @@ -797,6 +858,69 @@ suite('stateToProgressAdapter', () => { }); }); + suite('addComment reference', () => { + + const commentRange = { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 5 }; + + function addCommentInput(text: string): string { + return JSON.stringify({ resourceUri: 'file:///workspace/a.ts', range: commentRange, text }); + } + + function markdown(message: string | IMarkdownString | undefined): IMarkdownString { + assert.ok(message && typeof message !== 'string', 'expected a markdown reference'); + return message; + } + + test('renders tool name, truncated quoted preview and a reveal command link', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('This comment is quite long and should be truncated') }); + const message = markdown(toolCallStateToInvocation(tc).invocationMessage); + + assert.deepStrictEqual( + { + value: message.value, + supportThemeIcons: message.supportThemeIcons, + isTrusted: message.isTrusted, + }, + { + value: `[addComment "This comment is quite long and should be…"](command:_agentFeedbackReview.revealAt?${encodeURIComponent(JSON.stringify(['file:///workspace/a.ts', commentRange]))})`, + supportThemeIcons: true, + isTrusted: { enabledCommands: ['_agentFeedbackReview.revealAt'] }, + }, + ); + }); + + test('does not truncate a short comment', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('Short note') }); + const message = markdown(toolCallStateToInvocation(tc).invocationMessage); + assert.ok(message.value.includes('addComment "Short note"'), message.value); + assert.ok(!message.value.includes('…'), message.value); + }); + + test('sets the same reference as the past-tense message on completion', () => { + const running = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: addCommentInput('Short note') }); + const invocation = toolCallStateToInvocation(running); + const completed = createCompletedToolCall({ toolName: 'addComment', toolInput: addCommentInput('Short note'), pastTenseMessage: 'Added comment' }); + finalizeToolInvocation(invocation, completed); + assert.strictEqual(markdown(invocation.pastTenseMessage).value, markdown(invocation.invocationMessage).value); + }); + + test('falls back to the server message when the input cannot be parsed', () => { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: 'not json' }); + assert.strictEqual(toolCallStateToInvocation(tc).invocationMessage, 'Adding comment'); + }); + + test('falls back to the server message when the range is not a valid 1-based range', () => { + for (const range of [ + { startLineNumber: 0, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + { startLineNumber: 1, startColumn: 1.5, endLineNumber: 1, endColumn: 2 }, + { startLineNumber: -1, startColumn: 1, endLineNumber: 1, endColumn: 1 }, + ]) { + const tc = createToolCallState({ toolName: 'addComment', invocationMessage: 'Adding comment', toolInput: JSON.stringify({ resourceUri: 'file:///workspace/a.ts', range, text: 'hi' }) }); + assert.strictEqual(toolCallStateToInvocation(tc).invocationMessage, 'Adding comment', JSON.stringify(range)); + } + }); + }); + suite('finalizeToolInvocation', () => { test('rewrites markdown links in pastTenseMessage through the agent host scheme', () => { @@ -1121,6 +1245,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); if (invocation.toolSpecificData?.kind === 'subagent') { invocation.toolSpecificData.credits = 2.5; + invocation.toolSpecificData.isActive = true; } finalizeToolInvocation(invocation, { @@ -1146,7 +1271,13 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(invocation.toolSpecificData?.kind, 'subagent'); if (invocation.toolSpecificData?.kind === 'subagent') { - assert.strictEqual(invocation.toolSpecificData.credits, 2.5, 'credits should survive finalization'); + assert.deepStrictEqual({ + credits: invocation.toolSpecificData.credits, + isActive: invocation.toolSpecificData.isActive, + }, { + credits: 2.5, + isActive: true, + }); } }); }); @@ -1188,13 +1319,14 @@ suite('stateToProgressAdapter', () => { assert.strictEqual((result[0] as IChatMarkdownContent).content.value, 'Hello world'); }); - test('produces progress message for system notification', () => { + test('produces system notification for system notification response part', () => { const result = activeTurnToProgress(URI.file('/'), createActiveTurnState([ { kind: ResponsePartKind.SystemNotification, content: 'Shell command completed' }, ]), undefined); assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].kind, 'progressMessage'); - assert.strictEqual((result[0] as IChatProgressMessage).content.value, 'Shell command completed'); + assert.strictEqual(result[0].kind, 'systemNotification'); + if (result[0].kind !== 'systemNotification') { return; } + assert.strictEqual(result[0].content.value, 'Shell command completed'); }); test('produces thinking progress for reasoning', () => { @@ -1361,6 +1493,82 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandOutput?.text, 'text-output'); }); + test('uses terminal completion exit code for completed SDK shell tool history', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'gti status', + content: [ + { type: ToolResultContentType.Text, text: 'command not found\n' }, + { type: ToolResultContentType.TerminalComplete, exitCode: 127, cwd: URI.file('/repo').toString(), preview: 'preview only\n' }, + ], + success: true, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandOutput?: { text: string }; terminalCommandState?: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandState?.exitCode, 127); + assert.strictEqual(termData.terminalCommandOutput?.text, 'command not found\r\n'); + }); + + test('keeps zero terminal completion exit code as success for completed SDK shell tool history', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'pwd', + content: [ + { type: ToolResultContentType.Text, text: '/repo\n' }, + { type: ToolResultContentType.TerminalComplete, exitCode: 0, cwd: URI.file('/repo').toString() }, + ], + success: true, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + }); + + test('falls back to tool success when terminal completion has no exit code', () => { + const tc = createCompletedToolCall({ + _meta: { toolKind: 'terminal' }, + toolInput: 'pwd', + content: [ + { type: ToolResultContentType.Text, text: '/repo\n' }, + { type: ToolResultContentType.TerminalComplete, cwd: URI.file('/repo').toString() }, + ], + success: true, + }); + + const turn = createTurn({ + responseParts: [{ kind: ResponsePartKind.ToolCall, toolCall: tc } as ToolCallResponsePart], + }); + + const history = turnsToHistory(URI.file('/'), [turn], 'p'); + const response = history[1]; + assert.strictEqual(response.type, 'response'); + if (response.type !== 'response') { return; } + const serialized = response.parts[0] as IChatToolInvocationSerialized; + assert.strictEqual(serialized.toolSpecificData?.kind, 'terminal'); + const termData = serialized.toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandState?.exitCode, 0); + }); + test('running tool call with terminal content block sets terminalCommandUri', () => { const tc = createToolCallState({ _meta: { toolKind: 'terminal' }, @@ -1412,6 +1620,36 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(termData.terminalCommandState?.exitCode, 0); }); + test('finalize uses terminal completion exit code over SDK tool success', () => { + const tc = createToolCallState({ + _meta: { toolKind: 'terminal' }, + toolInput: 'false', + status: ToolCallStatus.Running, + }); + const invocation = toolCallStateToInvocation(tc); + + finalizeToolInvocation(invocation, { + status: ToolCallStatus.Completed, + toolCallId: 'tc-1', + toolName: 'bash', + displayName: 'Run Shell Command', + invocationMessage: 'Running shell command', + _meta: { toolKind: 'terminal' }, + toolInput: 'false', + confirmed: ToolCallConfirmationReason.NotNeeded, + success: true, + pastTenseMessage: 'Ran false', + content: [ + { type: ToolResultContentType.Text, text: '' }, + { type: ToolResultContentType.TerminalComplete, exitCode: 1, cwd: URI.file('/repo').toString() }, + ], + }); + + assert.strictEqual(invocation.toolSpecificData?.kind, 'terminal'); + const termData = invocation.toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode: number } }; + assert.strictEqual(termData.terminalCommandState?.exitCode, 1); + }); + }); suite('updateRunningToolSpecificData', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts index de3dc7d0342c51..3f780412e220d8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationItemsModel.test.ts @@ -16,7 +16,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/ import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; import { AICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; -import { AICustomizationManagementSection, AICustomizationSources, BUILTIN_STORAGE, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, AICustomizationSources, BUILTIN_STORAGE, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, ICustomizationSyncProvider, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { IAgentPluginService, type IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; @@ -49,7 +49,6 @@ suite('AICustomizationItemsModel', () => { id, label: id, icon: Codicon.settingsGear, - getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: provider, syncProvider, }; @@ -104,6 +103,7 @@ suite('AICustomizationItemsModel', () => { onDidChangeSkills: Event.None, onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, + onDidChangeAgentInstructions: Event.None, listPromptFiles: async (type: PromptsType) => listPromptFilesResult.filter(f => f.type === type), getCustomAgents: async () => listPromptFilesResult.filter(f => f.type === PromptsType.agent).map(customAgentFromPromptPath), findAgentSkills: async () => [], @@ -538,7 +538,6 @@ suite('AICustomizationItemsModel', () => { id: 'A', label: 'A', icon: Codicon.settingsGear, - getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: provider, }; const sessionResource = URI.parse('A:///active-session'); @@ -551,6 +550,7 @@ suite('AICustomizationItemsModel', () => { onDidChangeSkills: Event.None, onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, + onDidChangeAgentInstructions: Event.None, listPromptFiles: async () => [], getCustomAgents: async () => [], findAgentSkills: async () => [], @@ -778,7 +778,6 @@ suite('AICustomizationItemsModel', () => { id: sessionType, label: 'Agent Host Test', icon: Codicon.settingsGear, - getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [] }), itemProvider: provider, }; const sessionResource = URI.parse(`${sessionType}:///active-session`); @@ -791,6 +790,7 @@ suite('AICustomizationItemsModel', () => { onDidChangeSkills: Event.None, onDidChangeHooks: Event.None, onDidChangeInstructions: Event.None, + onDidChangeAgentInstructions: Event.None, listPromptFiles: async () => [], getCustomAgents: async () => [], findAgentSkills: async () => [], diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts index a863b1026ef193..4f04bb2c641b0d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/aiCustomizationListWidget.test.ts @@ -16,12 +16,12 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { AICustomizationListWidget } from '../../../browser/aiCustomization/aiCustomizationListWidget.js'; import { IAICustomizationItemsModel } from '../../../browser/aiCustomization/aiCustomizationItemsModel.js'; import { extractExtensionIdFromPath, getCustomizationSecondaryText, truncateToFirstLine } from '../../../browser/aiCustomization/aiCustomizationListWidgetUtils.js'; -import { AICustomizationManagementSection, IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, IAICustomizationWorkspaceService } from '../../../common/aiCustomizationWorkspaceService.js'; import { ICustomizationHarnessService, IHarnessDescriptor } from '../../../common/customizationHarnessService.js'; import { ContributionEnablementState } from '../../../common/enablement.js'; import { getChatSessionType } from '../../../common/model/chatUri.js'; import { IAgentPluginService } from '../../../common/plugins/agentPluginService.js'; -import { IPromptsService, PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; @@ -164,7 +164,6 @@ suite('aiCustomizationListWidget', () => { id: 'test', label: 'Test', icon: Codicon.settingsGear, - getStorageSourceFilter: (): IStorageSourceFilter => ({ sources: [PromptsStorage.local, PromptsStorage.user] }), itemProvider: { onDidChange: Event.None, provideChatSessionCustomizations: (sessionResource: URI, token: CancellationToken) => Promise.resolve(undefined), diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts deleted file mode 100644 index 038ac28eaf88b8..00000000000000 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/applyStorageSourceFilter.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { AICustomizationSource, AICustomizationSources, applySourceFilter, IStorageSourceFilter } from '../../../common/aiCustomizationWorkspaceService.js'; - -function item(path: string, source: AICustomizationSource): { uri: URI; source: AICustomizationSource } { - return { uri: URI.file(path), source }; -} - -suite('applyStorageSourceFilter', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - suite('source filtering', () => { - test('keeps items matching sources', () => { - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/u/b.md', AICustomizationSources.user), - item('/e/c.md', AICustomizationSources.extension), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.extension], - }; - assert.strictEqual(applySourceFilter(items, filter).length, 3); - }); - - test('removes items not in sources', () => { - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/u/b.md', AICustomizationSources.user), - item('/e/c.md', AICustomizationSources.extension), - item('/p/d.md', AICustomizationSources.plugin), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local], - }; - const result = applySourceFilter(items, filter); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].uri.toString(), URI.file('/w/a.md').toString()); - }); - - test('empty sources removes everything', () => { - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/u/b.md', AICustomizationSources.user), - ]; - const filter: IStorageSourceFilter = { sources: [] }; - assert.strictEqual(applySourceFilter(items, filter).length, 0); - }); - - test('empty items returns empty', () => { - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local, AICustomizationSources.user], - }; - assert.strictEqual(applySourceFilter([], filter).length, 0); - }); - }); - - suite('combined filtering', () => { - test('sessions-like filter: hooks show only local', () => { - const items = [ - item('/w/.github/hooks/pre.json', AICustomizationSources.local), - item('/home/.claude/settings.json', AICustomizationSources.user), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local], - }; - const result = applySourceFilter(items, filter); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].source, AICustomizationSources.local); - }); - - test('show multiple sources together', () => { - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/home/.copilot/b.md', AICustomizationSources.user), - item('/home/.vscode/c.md', AICustomizationSources.user), - item('/e/d.md', AICustomizationSources.extension), - item('/p/e.md', AICustomizationSources.plugin), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local, AICustomizationSources.user], - }; - const result = applySourceFilter(items, filter); - // local + all users kept, extension + plugin excluded - assert.strictEqual(result.length, 3); - }); - - test('core-like filter: show everything', () => { - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/u/b.md', AICustomizationSources.user), - item('/e/c.md', AICustomizationSources.extension), - item('/p/d.md', AICustomizationSources.plugin), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local, AICustomizationSources.user, AICustomizationSources.extension, AICustomizationSources.plugin], - }; - assert.strictEqual(applySourceFilter(items, filter).length, 4); - }); - - test('core-like filter with builtin: extension items pass when both extension and builtin are in sources', () => { - // Items from the chat extension have storage=extension but groupKey=builtin. - // The filter operates on storage, so extension items pass through regardless of groupKey. - const items = [ - item('/w/a.md', AICustomizationSources.local), - item('/e/builtin-agent.md', AICustomizationSources.extension), - item('/e/third-party.md', AICustomizationSources.extension), - item('/b/sessions-builtin.md', AICustomizationSources.builtin), - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local, AICustomizationSources.extension, AICustomizationSources.builtin], - }; - const result = applySourceFilter(items, filter); - assert.strictEqual(result.length, 4); - }); - - test('builtin source is respected independently', () => { - const items = [ - item('/e/from-extension.md', AICustomizationSources.extension), - item('/b/from-sessions.md', AICustomizationSources.builtin), - ]; - // Only builtin in sources — extension items excluded - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.builtin], - }; - const result = applySourceFilter(items, filter); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].source, AICustomizationSources.builtin); - }); - }); - - suite('type safety', () => { - test('works with objects that have extra properties', () => { - const items = [ - { uri: URI.file('/w/a.md'), source: AICustomizationSources.local, name: 'A', extra: true }, - { uri: URI.file('/u/b.md'), source: AICustomizationSources.user, name: 'B', extra: false }, - ]; - const filter: IStorageSourceFilter = { - sources: [AICustomizationSources.local], - }; - const result = applySourceFilter(items, filter); - assert.strictEqual(result.length, 1); - assert.strictEqual(result[0].name, 'A'); - }); - }); -}); diff --git a/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts b/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts new file mode 100644 index 00000000000000..f952ac4283f74f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/automations/automationsAccessibilityHelp.test.ts @@ -0,0 +1,67 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../../../platform/accessibility/browser/accessibleView.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { AutomationsAccessibilityHelp, buildAutomationsHelpContent } from '../../../browser/aiCustomization/automationsAccessibilityHelp.js'; + +class FakeKeybindingService extends mock<IKeybindingService>() { + override lookupKeybinding(): undefined { + return undefined; + } +} + +suite('AutomationsAccessibilityHelp', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + test('implementation declares the Help type and a when clause', () => { + const impl = new AutomationsAccessibilityHelp(); + assert.strictEqual(impl.type, AccessibleViewType.Help); + assert.strictEqual(impl.name, 'automations'); + assert.ok(impl.when, 'expected a when clause so the help only activates in the Automations section'); + }); + + test('getProvider returns an AccessibleContentProvider with the Automations id and verbosity setting', () => { + // Regression: the provider must be an actual `AccessibleContentProvider` + // instance so the accessible-view service's `instanceof` branches + // (e.g. `_updateLastProvider`, `showAccessibleViewHelp`) take the + // correct path and propagate `verbositySettingKey` properly. + const instantiationService = teardown.add(new TestInstantiationService()); + instantiationService.stub(IKeybindingService, new FakeKeybindingService()); + + const impl = new AutomationsAccessibilityHelp(); + const provider = instantiationService.invokeFunction(accessor => impl.getProvider(accessor)); + teardown.add(provider); + + assert.ok(provider instanceof AccessibleContentProvider, 'provider must be an AccessibleContentProvider instance'); + assert.strictEqual(provider.id, AccessibleViewProviderId.Automations); + assert.strictEqual(provider.verbositySettingKey, AccessibilityVerbositySettingId.Automations); + assert.strictEqual(provider.options.type, AccessibleViewType.Help); + }); + + test('help content covers actions, dialog, history and settings', () => { + const content = buildAutomationsHelpContent(new FakeKeybindingService()); + assert.match(content, /Automations/); + assert.match(content, /Run now/); + assert.match(content, /Show history/); + assert.match(content, /Create\/Edit Dialog/); + assert.match(content, /Workspace folder/); + assert.match(content, /Run History/); + assert.match(content, /accessibility\.verbosity\.automations/); + }); + + test('help content does not contain unresolved placeholders', () => { + const content = buildAutomationsHelpContent(upcastPartial<IKeybindingService>({ lookupKeybinding: () => undefined })); + // The string template uses {0} for keybinding insertion; if any + // placeholder leaks through unresolved that is a content bug. + assert.doesNotMatch(content, /\{0\}/); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts new file mode 100644 index 00000000000000..c9b52ea8f3fdb4 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/automations/automationsListWidget.test.ts @@ -0,0 +1,514 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { derived, IObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IConfirmation, IConfirmationResult, IDialogService, IFileDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { NullHoverService } from '../../../../../../platform/hover/test/browser/nullHoverService.js'; +import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { IListService, ListService } from '../../../../../../platform/list/browser/listService.js'; +import { ILayoutService } from '../../../../../../platform/layout/browser/layoutService.js'; +import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IHostService } from '../../../../../services/host/browser/host.js'; +import { IWorkspaceContextService, IWorkspace, IWorkspaceFolder, IWorkspaceFoldersChangeEvent } from '../../../../../../platform/workspace/common/workspace.js'; +import { AutomationsListWidget } from '../../../browser/aiCustomization/automationsListWidget.js'; +import { IAutomation, IAutomationRun, IAutomationSchedule, AutomationRunTrigger } from '../../../common/automations/automation.js'; +import { IAutomationRunner } from '../../../common/automations/automationRunner.js'; +import { IAutomationService, ICreateAutomationOptions, IUpdateAutomationOptions, IUpdateAutomationRunOptions } from '../../../common/automations/automationService.js'; +import { IAutomationDialogResult, IAutomationDialogService, IShowAutomationDialogOptions } from '../../../common/automations/automationDialogService.js'; + +const FOLDER = URI.parse('file:///workspace'); + +function hourly(): IAutomationSchedule { + return { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; +} + +/** + * In-memory IAutomationService for the widget tests. Replaces the concrete + * AutomationService, which now lives in the sessions layer. Importing it from + * a workbench-layer test trips the `code-import-patterns` rule. The widget only + * reads the `automations`/`runs` observables and drives create/update/delete, + * so this fake keeps an unpersisted reactive store with just those mutations + * plus the run-recording the tests exercise directly. + */ +class FakeAutomationService extends mock<IAutomationService>() { + + private readonly _automations = observableValue<readonly IAutomation[]>(this, []); + private readonly _runs = observableValue<readonly IAutomationRun[]>(this, []); + private readonly _runsForCache = new Map<string, IObservable<readonly IAutomationRun[]>>(); + createError: Error | undefined; + updateError: Error | undefined; + + override readonly automations: IObservable<readonly IAutomation[]> = this._automations; + override readonly runs: IObservable<readonly IAutomationRun[]> = this._runs; + + override getAutomation(id: string): IAutomation | undefined { + return this._automations.get().find(a => a.id === id); + } + + override runsFor(automationId: string): IObservable<readonly IAutomationRun[]> { + let cached = this._runsForCache.get(automationId); + if (!cached) { + cached = derived(this, reader => this._runs.read(reader).filter(r => r.automationId === automationId)); + this._runsForCache.set(automationId, cached); + } + return cached; + } + + override async createAutomation(options: ICreateAutomationOptions): Promise<IAutomation> { + if (this.createError) { + throw this.createError; + } + const now = new Date().toISOString(); + const automation: IAutomation = Object.freeze({ + id: generateUuid(), + name: options.name, + prompt: options.prompt, + schedule: options.schedule, + folderUri: options.folderUri, + providerId: options.providerId, + sessionTypeId: options.sessionTypeId, + modelId: options.modelId, + mode: options.mode, + permissionLevel: options.permissionLevel, + enabled: options.enabled ?? true, + createdAt: now, + updatedAt: now, + lastRunAt: undefined, + nextRunAt: undefined, + }); + this._automations.set([automation, ...this._automations.get()], undefined); + return automation; + } + + override async updateAutomation(id: string, patch: IUpdateAutomationOptions): Promise<IAutomation> { + if (this.updateError) { + throw this.updateError; + } + const current = this.getAutomation(id); + if (!current) { + throw new Error(`Automation not found: ${id}`); + } + const updated: IAutomation = Object.freeze({ + ...current, + name: patch.name ?? current.name, + prompt: patch.prompt ?? current.prompt, + schedule: patch.schedule ?? current.schedule, + enabled: patch.enabled ?? current.enabled, + updatedAt: new Date().toISOString(), + }); + this._automations.set(this._automations.get().map(a => a.id === id ? updated : a), undefined); + return updated; + } + + override async deleteAutomation(id: string): Promise<void> { + this._automations.set(this._automations.get().filter(a => a.id !== id), undefined); + this._runsForCache.delete(id); + } + + override async recordRunStart(automationId: string, trigger: AutomationRunTrigger, leaderWindowId: number): Promise<IAutomationRun> { + const run: IAutomationRun = Object.freeze({ + id: generateUuid(), + automationId, + status: 'pending', + trigger, + startedAt: new Date().toISOString(), + leaderWindowId, + }); + this._runs.set([run, ...this._runs.get()], undefined); + return run; + } + + override async updateRun(runId: string, patch: IUpdateAutomationRunOptions): Promise<IAutomationRun | undefined> { + const current = this._runs.get().find(r => r.id === runId); + if (!current) { + return undefined; + } + const merged: IAutomationRun = Object.freeze({ + ...current, + status: patch.status ?? current.status, + sessionResource: patch.sessionResource ?? current.sessionResource, + completedAt: patch.completedAt ?? current.completedAt, + errorMessage: patch.errorMessage ?? current.errorMessage, + }); + this._runs.set(this._runs.get().map(r => r.id === runId ? merged : r), undefined); + return merged; + } +} + +class RecordingRunner extends mock<IAutomationRunner>() { + readonly calls: { automationId: string; trigger: AutomationRunTrigger }[] = []; + error: Error | undefined; + + override async runOnce( + automation: IAutomation, + trigger: AutomationRunTrigger, + _leaderWindowId: number, + _token?: CancellationToken, + ): Promise<void> { + this.calls.push({ automationId: automation.id, trigger }); + if (this.error) { + throw this.error; + } + } +} + +class FakeDialogService extends mock<IDialogService>() { + confirmResult = true; + readonly confirmations: IConfirmation[] = []; + readonly errors: { message: string; detail: string }[] = []; + + override async confirm(confirmation: IConfirmation): Promise<IConfirmationResult> { + this.confirmations.push(confirmation); + return { confirmed: this.confirmResult }; + } + + override async error(message: string, detail?: string): Promise<void> { + this.errors.push({ message, detail: detail ?? '' }); + } + + override async info(): Promise<void> { /* no-op */ } +} + +class FakeAutomationDialogService extends mock<IAutomationDialogService>() { + result: IAutomationDialogResult | undefined; + lastOptions: IShowAutomationDialogOptions | undefined; + + override async showAutomationDialog(options: IShowAutomationDialogOptions): Promise<IAutomationDialogResult | undefined> { + this.lastOptions = options; + return this.result; + } +} + +class FakeWorkspaceContextService extends mock<IWorkspaceContextService>() { + + private readonly _onDidChangeWorkspaceFolders = new Emitter<IWorkspaceFoldersChangeEvent>(); + override readonly onDidChangeWorkspaceFolders: Event<IWorkspaceFoldersChangeEvent> = this._onDidChangeWorkspaceFolders.event; + + private _folders: IWorkspaceFolder[]; + + constructor(folders: readonly URI[] = [FOLDER]) { + super(); + this._folders = folders.map((uri, i) => upcastPartial<IWorkspaceFolder>({ uri, name: `folder-${i}`, index: i })); + } + + override getWorkspace(): IWorkspace { + return upcastPartial<IWorkspace>({ folders: this._folders }); + } + + setFolders(uris: readonly URI[]): void { + this._folders = uris.map((uri, i) => upcastPartial<IWorkspaceFolder>({ uri, name: `folder-${i}`, index: i })); + this._onDidChangeWorkspaceFolders.fire({ added: [], removed: [], changed: [] }); + } + + dispose(): void { + this._onDidChangeWorkspaceFolders.dispose(); + } +} + +suite('AutomationsListWidget', () => { + + const teardown = ensureNoDisposablesAreLeakedInTestSuite(); + + function setup() { + const log = new NullLogService(); + const service = new FakeAutomationService(); + const runner = new RecordingRunner(); + const dialog = new FakeDialogService(); + const automationDialogService = new FakeAutomationDialogService(); + + const instantiation = teardown.add(new TestInstantiationService()); + instantiation.stub(IAutomationService, service); + instantiation.stub(IAutomationRunner, runner); + instantiation.stub(IDialogService, dialog); + instantiation.stub(IFileDialogService, upcastPartial<IFileDialogService>({ showOpenDialog: async () => undefined })); + instantiation.stub(IAutomationDialogService, automationDialogService); + instantiation.stub(IHoverService, NullHoverService); + const workspace = new FakeWorkspaceContextService(); + teardown.add({ dispose: () => workspace.dispose() }); + instantiation.stub(IWorkspaceContextService, workspace); + instantiation.stub(IKeybindingService, upcastPartial<IKeybindingService>({})); + instantiation.stub(IContextKeyService, new MockContextKeyService()); + instantiation.stub(IListService, teardown.add(new ListService())); + instantiation.stub(ILayoutService, upcastPartial<ILayoutService>({ activeContainer: document.createElement('div') })); + instantiation.stub(IHostService, upcastPartial<IHostService>({})); + instantiation.stub(ILogService, log); + instantiation.stub(IQuickInputService, upcastPartial<IQuickInputService>({ pick: async () => undefined })); + // Enable the Automations feature so mutation handlers don't + // short-circuit with the "feature disabled" toast. The runtime + // gating is exercised in a dedicated test below. + const configService = new TestConfigurationService({ chat: { automations: { enabled: true } } }); + instantiation.stub(IConfigurationService, configService); + + const widget = teardown.add(instantiation.createInstance(AutomationsListWidget)); + return { widget, service, runner, dialog, workspace, configService, automationDialogService }; + } + + test('renders empty state when there are no automations', () => { + const { widget } = setup(); + const empty = widget.element.querySelector('.automations-empty-state'); + assert.ok(empty, 'expected empty-state element to be present'); + const rows = widget.element.querySelectorAll('.automations-row'); + assert.strictEqual(rows.length, 0); + }); + + // The Automations list is a virtualized WorkbenchList, which does not lay + // out rows in a unit-test DOM (no height). Mirroring the sibling + // aiCustomizationListWidget test, these cases assert the widget's public + // API and view-model (via getDisplayEntriesForTest / itemCount) rather than + // querying or clicking virtualized row elements. + + test('exposes one display entry per automation', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'First', prompt: 'p1', schedule: hourly(), folderUri: FOLDER }); + await service.createAutomation({ name: 'Second', prompt: 'p2', schedule: hourly(), folderUri: FOLDER }); + + assert.strictEqual(widget.itemCount, 2); + + const entries = widget.getDisplayEntriesForTest(); + assert.strictEqual(entries.length, 2); + const names = entries.map(e => e.automation.name).sort(); + assert.deepStrictEqual(names, ['First', 'Second']); + }); + + test('disabled automations surface in the view-model as not enabled', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'D', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: false }); + + const entries = widget.getDisplayEntriesForTest(); + assert.strictEqual(entries.length, 1); + assert.strictEqual(entries[0].automation.enabled, false, 'disabled badge is rendered from this flag'); + }); + + test('runNow invokes the runner with trigger=manual', async () => { + const { widget, service, runner } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + await widget.runNow(a); + + assert.strictEqual(runner.calls.length, 1); + assert.strictEqual(runner.calls[0].automationId, a.id); + assert.strictEqual(runner.calls[0].trigger, 'manual'); + }); + + test('runNow clears inFlight when the runner fails', async () => { + const { widget, service, runner } = setup(); + runner.error = new Error('boom'); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + await widget.runNow(automation); + + assert.strictEqual(runner.calls.length, 1); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].inFlight, false); + }); + + test('mutating actions short-circuit when chat.automations.enabled is off', async () => { + const { widget, service, runner, configService, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: true }); + + // Flip the setting off, then drive each mutating action through the + // public API. None of them should reach the service / runner. + configService.setUserConfiguration('chat.automations.enabled', false); + dialog.confirmResult = true; + + await widget.runNow(a); + await widget.toggleEnabled(a); + await widget.deleteAutomation(a); + + assert.strictEqual(runner.calls.length, 0, 'runNow must not call the runner when disabled'); + const reloaded = service.getAutomation(a.id); + assert.ok(reloaded, 'automation must not be deleted'); + assert.strictEqual(reloaded?.enabled, true, 'toggle must not mutate enabled flag'); + }); + + test('toggleEnabled flips the enabled state', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER, enabled: true }); + + await widget.toggleEnabled(a); + + const updated = service.getAutomation(a.id); + assert.ok(updated); + assert.strictEqual(updated.enabled, false); + }); + + test('openEditDialog surfaces update errors without crashing', async () => { + const { widget, service, dialog, automationDialogService } = setup(); + const automation = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + automationDialogService.result = { kind: 'update', id: automation.id, value: { name: 'Updated' } }; + service.updateError = new Error('update failed'); + + await widget.openEditDialog(automation); + + assert.strictEqual(service.getAutomation(automation.id)?.name, 'A'); + assert.deepStrictEqual(dialog.errors, [{ + message: 'Failed to update automation.', + detail: 'update failed', + }]); + }); + + test('openCreateDialog creates an automation when the dialog returns a create result', async () => { + const { widget, automationDialogService } = setup(); + automationDialogService.result = { + kind: 'create', + value: { name: 'Created', prompt: 'p', schedule: hourly(), folderUri: FOLDER } + }; + + const openCreateDialog = Reflect.get(widget, 'openCreateDialog') as (() => Promise<void>) | undefined; + assert.ok(openCreateDialog); + await Reflect.apply(openCreateDialog, widget, []); + + assert.strictEqual(widget.itemCount, 1); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].automation.name, 'Created'); + }); + + test('openCreateDialog surfaces creation errors without crashing', async () => { + const { widget, service, dialog, automationDialogService } = setup(); + automationDialogService.result = { + kind: 'create', + value: { name: 'Created', prompt: 'p', schedule: hourly(), folderUri: FOLDER } + }; + service.createError = new Error('create failed'); + + const openCreateDialog = Reflect.get(widget, 'openCreateDialog') as (() => Promise<void>) | undefined; + assert.ok(openCreateDialog); + await Reflect.apply(openCreateDialog, widget, []); + + assert.strictEqual(widget.itemCount, 0); + assert.deepStrictEqual(dialog.errors, [{ + message: 'Failed to create automation.', + detail: 'create failed', + }]); + }); + + test('deleteAutomation only deletes when the confirmation is accepted', async () => { + const { widget, service, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + dialog.confirmResult = false; + await widget.deleteAutomation(a); + + assert.strictEqual(dialog.confirmations.length, 1); + assert.ok(service.getAutomation(a.id), 'expected automation to still exist after declined delete'); + }); + + test('deleteAutomation removes the automation when the confirmation is accepted', async () => { + const { widget, service, dialog } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + dialog.confirmResult = true; + await widget.deleteAutomation(a); + + assert.strictEqual(service.getAutomation(a.id), undefined); + assert.strictEqual(widget.itemCount, 0); + assert.strictEqual(widget.getDisplayEntriesForTest().length, 0); + }); + + test('fires onDidChangeItemCount when automations change', async () => { + const { widget, service } = setup(); + const seen: number[] = []; + teardown.add(widget.onDidChangeItemCount(c => seen.push(c))); + + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + await service.createAutomation({ name: 'B', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + assert.ok(seen.length >= 2, `expected at least 2 emissions, got ${seen.length}`); + assert.strictEqual(seen[seen.length - 1], 2); + }); + + test('fireItemCount reflects current service size', async () => { + const { widget, service } = setup(); + await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + let captured = -1; + teardown.add(widget.onDidChangeItemCount(c => { captured = c; })); + widget.fireItemCount(); + + assert.strictEqual(captured, 1); + }); + + test('history is collapsed by default and toggleExpanded flips the row expansion', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, false); + + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, true); + + // Collapse again. + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].expanded, false); + }); + + test('expanded row exposes no runs when there are none', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + widget.toggleExpanded(a.id); + + const entry = widget.getDisplayEntriesForTest()[0]; + assert.strictEqual(entry.expanded, true); + assert.strictEqual(entry.runs.length, 0, 'history empty-state is rendered from an empty runs list'); + }); + + test('expanded row exposes runs newest-first with status and error message', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + // Record three runs in different states. + const r1 = await service.recordRunStart(a.id, 'schedule', 1); + await service.updateRun(r1.id, { status: 'completed', completedAt: new Date().toISOString() }); + + const r2 = await service.recordRunStart(a.id, 'manual', 1); + await service.updateRun(r2.id, { status: 'failed', errorMessage: 'boom', completedAt: new Date().toISOString() }); + + await service.recordRunStart(a.id, 'catch_up', 1); + + widget.toggleExpanded(a.id); + + const runs = widget.getDisplayEntriesForTest()[0].runs; + assert.strictEqual(runs.length, 3); + + // Newest-first: catch_up pending, manual failed, schedule completed. + const statuses = runs.map(r => r.status); + assert.deepStrictEqual(statuses, ['pending', 'failed', 'completed']); + + const triggers = runs.map(r => r.trigger); + assert.deepStrictEqual(triggers, ['catch_up', 'manual', 'schedule']); + + // The failed run surfaces the error message. + const failed = runs.find(r => r.status === 'failed'); + assert.strictEqual(failed?.errorMessage, 'boom'); + }); + + test('expanded row re-derives its runs when a run is added', async () => { + const { widget, service } = setup(); + const a = await service.createAutomation({ name: 'A', prompt: 'p', schedule: hourly(), folderUri: FOLDER }); + + widget.toggleExpanded(a.id); + assert.strictEqual(widget.getDisplayEntriesForTest()[0].runs.length, 0); + + await service.recordRunStart(a.id, 'schedule', 1); + await Promise.resolve(); + + const entry = widget.getDisplayEntriesForTest()[0]; + assert.strictEqual(entry.expanded, true); + assert.strictEqual(entry.runs.length, 1); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts index 7fd8c449c99e8f..10e10754143340 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts @@ -129,6 +129,16 @@ suite('ChatImageCarouselService helpers', () => { assert.strictEqual(findClickedImageIndex(sections, secondUri, identicalData), 1); }); + test('prefers the current input section when the same URI appeared earlier', () => { + const repeatedUri = URI.file('/repeated.png'); + const sections: ICarouselSection[] = [ + { title: 'History', images: [{ id: repeatedUri.toString(), name: 'historical.png', mimeType: 'image/png', data: new Uint8Array([1]) }] }, + { title: 'Current Input', images: [{ id: repeatedUri.toString(), name: 'current.png', mimeType: 'image/png', data: new Uint8Array([1]) }] }, + ]; + + assert.strictEqual(findClickedImageIndex(sections, repeatedUri, new Uint8Array([1]), 1), 1); + }); + test('returns -1 for empty sections', () => { assert.strictEqual(findClickedImageIndex([], URI.file('/x.png')), -1); }); @@ -222,6 +232,28 @@ suite('ChatImageCarouselService helpers', () => { }); }); + test('collects all current input image attachments', async () => { + const attachments = [ + makeImageVariableEntry({ id: 'img-1', name: 'first.png', value: new Uint8Array([1]) }), + makeImageVariableEntry({ id: 'img-2', name: 'second.png', value: new Uint8Array([2]) }), + makeImageVariableEntry({ id: 'img-3', name: 'third.png', value: new Uint8Array([3]) }), + ]; + + const result = await collectCarouselSections([], async () => new Uint8Array(), { text: '', attachments }); + + assert.deepStrictEqual(result.map(section => ({ + ...section, + images: section.images.map(image => ({ ...image, data: [...image.data] })), + })), [{ + title: 'Current Input', + images: [ + { id: 'data:img-1/first.png', name: 'first.png', mimeType: 'image/png', data: [1], caption: undefined }, + { id: 'data:img-2/second.png', name: 'second.png', mimeType: 'image/png', data: [2], caption: undefined }, + { id: 'data:img-3/third.png', name: 'third.png', mimeType: 'image/png', data: [3], caption: undefined }, + ], + }]); + }); + test('collects request attachment images restored as plain objects', async () => { const request = makeRequest('req-1', [ makeImageVariableEntry({ value: { 0: 4, 1: 5, 2: 6 } }), diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts index 2197699820d352..b88f73a647dafd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaExceededPart.test.ts @@ -33,7 +33,6 @@ function createMockEntitlementService(entitlement: ChatEntitlement): IChatEntitl isInternal: false, sku: undefined, copilotTrackingId: undefined, - previewFeaturesDisabled: false, clientByokEnabled: false, hasByokModels: false, onDidChangeSentiment: Event.None, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.integrationTest.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.integrationTest.ts index 3a31f629d7e920..6fc929700427e4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.integrationTest.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.integrationTest.ts @@ -90,7 +90,6 @@ function createEntitlementService(opts?: { isInternal: false, sku: undefined, copilotTrackingId: undefined, - previewFeaturesDisabled: false, clientByokEnabled: false, hasByokModels: false, onDidChangeSentiment: Event.None, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index ee156aff6f5dbd..420ffec5037d1c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -70,7 +70,6 @@ function createMockEntitlementService(opts?: { isInternal: false, sku: undefined, copilotTrackingId: undefined, - previewFeaturesDisabled: false, clientByokEnabled: false, hasByokModels: false, onDidChangeSentiment: Event.None, diff --git a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts index 47ed0537924179..5c357a02c8d505 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatStatusDashboard.test.ts @@ -24,6 +24,7 @@ interface IQuotaConfig { usageBasedBilling?: boolean; resetAt?: number; entitlement?: number; + creditsUsed?: number; } function createEntitlementService(opts: { @@ -67,7 +68,6 @@ function createEntitlementService(opts: { markAnonymousRateLimited: () => { }, markSetupCompleted: () => { }, setForceHidden: () => { }, - previewFeaturesDisabled: false, clientByokEnabled: false, hasByokModels: false, } as IChatEntitlementService; @@ -315,6 +315,17 @@ suite('ChatStatusDashboard', () => { assert.deepStrictEqual(getIncludedDescriptions(dashboard.element), ['Included with your organization\'s plan.']); }); + test('Enterprise Managed — PRU with credits used: shows consumed credits', () => { + const dashboard = createDashboard(createEntitlementService({ + premiumChat: { percentRemaining: 100, unlimited: true, creditsUsed: 127 }, + completions: { percentRemaining: 100, unlimited: true }, + entitlement: ChatEntitlement.Business, + })); + + assert.deepStrictEqual(getIncludedLabels(dashboard.element), ['Premium Requests']); + assert.deepStrictEqual(getIncludedDescriptions(dashboard.element), ['127 used']); + }); + test('Business — pooled exhausted (no overages): shows exhausted indicator and callout', () => { const dashboard = createDashboard(createEntitlementService({ premiumChat: { percentRemaining: 0, unlimited: true, hasQuota: false }, diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts index a0b7f92a14e4dc..5f9b224dce4d5b 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -15,6 +15,7 @@ import { ILogService, NullLogService } from '../../../../../../platform/log/comm import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IQuickInputService } from '../../../../../../platform/quickinput/common/quickInput.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; import { PluginInstallService } from '../../../browser/pluginInstallService.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js'; @@ -84,6 +85,16 @@ suite('PluginInstallService', () => { configuredMarketplaces: string[]; /** Updated marketplace config values */ updatedMarketplaces: string[] | undefined; + /** Whether readResult resolves to a directory (IFileService.resolve) */ + resolveIsDirectory: boolean; + /** Whether the directory is a standalone plugin (isPluginDirectory) */ + isPluginDirectoryResult: boolean; + /** Current configured plugin location values */ + configuredPluginLocations: Record<string, boolean>; + /** Updated plugin location config values */ + updatedPluginLocations: Record<string, boolean> | undefined; + /** User home directory used to expand `~` paths */ + userHome: string; } function createDefaults(): MockState { @@ -109,6 +120,11 @@ suite('PluginInstallService', () => { quickInputResult: undefined, configuredMarketplaces: [], updatedMarketplaces: undefined, + resolveIsDirectory: true, + isPluginDirectoryResult: false, + configuredPluginLocations: {}, + updatedPluginLocations: undefined, + userHome: '/home/user', }; } @@ -124,6 +140,7 @@ suite('PluginInstallService', () => { } return state.fileExistsResult; }, + resolve: async (resource: URI) => ({ resource, isDirectory: state.resolveIsDirectory }), } as unknown as IFileService); // INotificationService @@ -278,6 +295,7 @@ suite('PluginInstallService', () => { }, readPluginsFromDirectory: async () => state.readPluginsResult, readSinglePluginManifest: async () => state.singlePluginManifestResult, + isPluginDirectory: async () => state.isPluginDirectoryResult, } as unknown as IPluginMarketplaceService); // IConfigurationService @@ -286,21 +304,35 @@ suite('PluginInstallService', () => { if (key === ChatConfiguration.PluginMarketplaces) { return state.configuredMarketplaces; } + if (key === ChatConfiguration.PluginLocations) { + return state.configuredPluginLocations; + } return undefined; }, inspect: (key: string) => { if (key === ChatConfiguration.PluginMarketplaces) { return { userValue: state.configuredMarketplaces, defaultValue: undefined, policyValue: undefined }; } + if (key === ChatConfiguration.PluginLocations) { + return { userValue: state.configuredPluginLocations, defaultValue: undefined, policyValue: undefined }; + } return { userValue: undefined, defaultValue: undefined, policyValue: undefined }; }, updateValue: async (key: string, value: unknown) => { if (key === ChatConfiguration.PluginMarketplaces) { state.updatedMarketplaces = value as string[]; } + if (key === ChatConfiguration.PluginLocations) { + state.updatedPluginLocations = value as Record<string, boolean>; + } }, } as unknown as IConfigurationService); + // IPathService + instantiationService.stub(IPathService, { + userHome: async () => URI.file(state.userHome), + } as unknown as IPathService); + // IQuickInputService instantiationService.stub(IQuickInputService, { input: async () => state.quickInputResult, @@ -853,16 +885,131 @@ suite('PluginInstallService', () => { test('rejects invalid source strings', async () => { const { service, state } = createService(); - await service.installPluginFromSource('not a valid source'); + const result = await service.installPluginFromSource('not a valid source'); + assert.strictEqual(result.success, false); + assert.ok(result.message); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); }); - test('rejects local file URIs', async () => { - const { service, state } = createService(); - await service.installPluginFromSource('file:///some/local/path'); + test('validatePluginSource accepts git and local sources and rejects garbage', () => { + const { service } = createService(); + assert.strictEqual(service.validatePluginSource('owner/repo'), undefined); + assert.strictEqual(service.validatePluginSource('https://github.com/owner/repo.git'), undefined); + assert.strictEqual(service.validatePluginSource('file:///some/path'), undefined); + assert.strictEqual(service.validatePluginSource('/abs/path'), undefined); + assert.strictEqual(service.validatePluginSource('~/plugins/foo'), undefined); + assert.ok(service.validatePluginSource('not a valid source')); + }); + + test('installs a local folder marketplace and registers it under chat.plugins.marketplaces', async () => { + const ref = makeMarketplaceRef('file:///some/marketplace'); + const discoveredPlugin = createPlugin({ + name: 'local-marketplace-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: ref.displayLabel, + marketplaceReference: ref, + marketplaceType: MarketplaceType.OpenPlugin, + }); + const { service, state } = createService({ + readPluginsResult: [discoveredPlugin], + }); + + await service.installPluginFromSource('file:///some/marketplace'); + + assert.strictEqual(state.notifications.length, 0); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.addedPlugins[0].plugin.name, 'local-marketplace-plugin'); + assert.deepStrictEqual(state.updatedMarketplaces, ['file:///some/marketplace']); + assert.strictEqual(state.updatedPluginLocations, undefined); + }); + + test('does not persist a local marketplace to config when trust is declined', async () => { + const ref = makeMarketplaceRef('file:///some/marketplace'); + const discoveredPlugin = createPlugin({ + name: 'local-marketplace-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: '' }, + marketplace: ref.displayLabel, + marketplaceReference: ref, + marketplaceType: MarketplaceType.OpenPlugin, + }); + const { service, state } = createService({ + readPluginsResult: [discoveredPlugin], + marketplaceTrusted: false, + dialogConfirmResult: false, + }); + + const result = await service.installPluginFromSource('file:///some/marketplace'); + + assert.strictEqual(result.success, false); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); + assert.strictEqual(state.updatedMarketplaces, undefined); + }); + + test('registers a local folder standalone plugin under chat.pluginLocations', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + }); + + await service.installPluginFromSource('/abs/my-plugin'); + + assert.strictEqual(state.notifications.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + assert.deepStrictEqual(state.updatedPluginLocations, { '/abs/my-plugin': true }); + assert.strictEqual(state.updatedMarketplaces, undefined); + }); + + test('expands ~ paths but persists the original form in chat.pluginLocations', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + userHome: '/home/user', + }); + + await service.installPluginFromSource('~/my-plugin'); + + assert.deepStrictEqual(state.updatedPluginLocations, { '~/my-plugin': true }); + }); + + test('registers a file:// standalone plugin using its filesystem path', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: true, + }); + + await service.installPluginFromSource('file:///some/plugin'); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.ok(state.updatedPluginLocations); + assert.deepStrictEqual(Object.values(state.updatedPluginLocations!), [true]); + assert.strictEqual(Object.keys(state.updatedPluginLocations!).length, 1); + }); + + test('shows error when local folder does not exist', async () => { + const { service, state } = createService({ + resolveIsDirectory: false, + }); + + const result = await service.installPluginFromSource('/abs/missing'); + + assert.strictEqual(result.success, false); + assert.ok(result.message); + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.updatedPluginLocations, undefined); + }); + + test('shows error when local folder is neither a marketplace nor a plugin', async () => { + const { service, state } = createService({ + readPluginsResult: [], + isPluginDirectoryResult: false, + }); + + const result = await service.installPluginFromSource('/abs/empty'); + + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugin or marketplace found')); + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.updatedPluginLocations, undefined); }); test('installs single plugin from GitHub shorthand with marketplace.json', async () => { @@ -892,11 +1039,11 @@ suite('PluginInstallService', () => { readPluginsResult: [], }); - await service.installPluginFromSource('owner/cool-tool'); + const result = await service.installPluginFromSource('owner/cool-tool'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); test('shows quick pick for multi-plugin repos', async () => { @@ -971,11 +1118,11 @@ suite('PluginInstallService', () => { readPluginsResult: [], }); - await service.installPluginFromSource('https://github.com/owner/my-tool.git'); + const result = await service.installPluginFromSource('https://github.com/owner/my-tool.git'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); test('shows error when clone directory does not exist', async () => { @@ -984,10 +1131,11 @@ suite('PluginInstallService', () => { fileExistsResult: false, }); - await service.installPluginFromSource('owner/missing'); + const result = await service.installPluginFromSource('owner/missing'); + assert.strictEqual(result.success, false); + assert.ok(result.message); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); }); test('adds marketplace to config after installing single plugin', async () => { @@ -1095,7 +1243,7 @@ suite('PluginInstallService', () => { singlePluginManifestResult: singlePlugin, }); - const result = await service.installPluginFromValidatedSource('owner/single-plugin-repo', { plugin: 'requested-name' }); + const result = await service.installPluginFromSource('owner/single-plugin-repo', { plugin: 'requested-name' }); assert.strictEqual(result.success, false); assert.ok(result.message?.includes('not found')); @@ -1109,11 +1257,11 @@ suite('PluginInstallService', () => { singlePluginManifestResult: undefined, }); - await service.installPluginFromSource('owner/empty-repo'); + const result = await service.installPluginFromSource('owner/empty-repo'); + assert.strictEqual(result.success, false); + assert.ok(result.message?.includes('No plugins found')); assert.strictEqual(state.addedPlugins.length, 0); - assert.strictEqual(state.notifications.length, 1); - assert.ok(state.notifications[0].message.includes('No plugins found')); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts index a4ce29e96e8c1b..e034357747d085 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginUrlHandler.test.ts @@ -13,6 +13,7 @@ import { IDialogService } from '../../../../../../platform/dialogs/common/dialog import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; import { IURLService } from '../../../../../../platform/url/common/url.js'; import { IEditorService } from '../../../../../services/editor/common/editorService.js'; import { IHostService } from '../../../../../services/host/browser/host.js'; @@ -36,7 +37,8 @@ suite('PluginUrlHandler', () => { configUpdates: { key: string; value: unknown; target: ConfigurationTarget }[]; openedEditorInputs: AgentPluginEditorInput[]; openSearchQueries: string[]; - installFromValidatedSourceResult: IInstallPluginFromSourceResult; + installFromSourceResult: IInstallPluginFromSourceResult; + notifications: { severity: number; message: string }[]; } function createHandler(stateOverrides?: Partial<MockState>): { handler: PluginUrlHandler; state: MockState } { @@ -46,7 +48,8 @@ suite('PluginUrlHandler', () => { configUpdates: [], openedEditorInputs: [], openSearchQueries: [], - installFromValidatedSourceResult: { success: false }, + installFromSourceResult: { success: true }, + notifications: [], ...stateOverrides, }; @@ -57,8 +60,10 @@ suite('PluginUrlHandler', () => { } as unknown as IURLService); instantiationService.stub(IPluginInstallService, { - installPluginFromSource: async (source: string) => { state.installedSources.push(source); }, - installPluginFromValidatedSource: async (_source: string, _options?: IInstallPluginFromSourceOptions) => state.installFromValidatedSourceResult, + installPluginFromSource: async (source: string, _options?: IInstallPluginFromSourceOptions) => { + state.installedSources.push(source); + return state.installFromSourceResult; + }, } as unknown as IPluginInstallService); instantiationService.stub(IDialogService, { @@ -97,6 +102,13 @@ suite('PluginUrlHandler', () => { instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(INotificationService, { + notify: (notification: { severity: number; message: string }) => { + state.notifications.push({ severity: notification.severity, message: notification.message }); + return undefined; + }, + } as unknown as INotificationService); + const handler = store.add(instantiationService.createInstance(PluginUrlHandler)); return { handler, state }; } @@ -257,15 +269,14 @@ suite('PluginUrlHandler', () => { }; } - test('install with plugin param delegates to installPluginFromValidatedSource and opens editor', async () => { + test('install with plugin param targets the plugin and opens editor', async () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); - // Broad install (installPluginFromSource) is not used on the targeted path. - assert.deepStrictEqual(state.installedSources, []); + assert.deepStrictEqual(state.installedSources, ['acme/plugins']); // Plugin editor was opened assert.strictEqual(state.openedEditorInputs.length, 1); assert.strictEqual(state.openedEditorInputs[0].item.name, 'my-plugin'); @@ -275,7 +286,7 @@ suite('PluginUrlHandler', () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ dialogConfirmResult: false, - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); @@ -287,7 +298,7 @@ suite('PluginUrlHandler', () => { test('install with base64-encoded plugin param opens editor', async () => { const plugin = makeMarketplacePlugin('my-plugin', 'acme/plugins'); const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true, matchedPlugin: plugin }, + installFromSourceResult: { success: true, matchedPlugin: plugin }, }); const encodedPlugin = toBase64('my-plugin'); const result = await handler.handleURL(uri('/install', `source=acme/plugins&plugin=${encodedPlugin}`)); @@ -298,7 +309,7 @@ suite('PluginUrlHandler', () => { test('install with plugin param falls back to search on failure', async () => { const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: false, message: 'Plugin not found' }, + installFromSourceResult: { success: false, message: 'Plugin not found' }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=nonexistent')); assert.strictEqual(result, true); @@ -309,7 +320,7 @@ suite('PluginUrlHandler', () => { test('install with plugin param falls back to search when no matchedPlugin', async () => { const { handler, state } = createHandler({ - installFromValidatedSourceResult: { success: true }, + installFromSourceResult: { success: true }, }); const result = await handler.handleURL(uri('/install', 'source=acme/plugins&plugin=my-plugin')); assert.strictEqual(result, true); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptCodeActions.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptCodeActions.test.ts index 2a1a20b946bb7b..2b85fd72bc32dc 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptCodeActions.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptCodeActions.test.ts @@ -14,7 +14,7 @@ import { ITextModel } from '../../../../../../../editor/common/model.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; -import { IMarkerService } from '../../../../../../../platform/markers/common/markers.js'; +import { IMarker, IMarkerService, MarkerSeverity } from '../../../../../../../platform/markers/common/markers.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { ChatConfiguration } from '../../../../common/constants.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; @@ -24,6 +24,7 @@ import { getLanguageIdForPromptsType, PromptsType } from '../../../../common/pro import { getPromptFileExtension } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; import { PromptCodeActionProvider } from '../../../../common/promptSyntax/languageProviders/promptCodeActions.js'; +import { PromptValidatorMarkerCode } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; import { IFileService } from '../../../../../../../platform/files/common/files.js'; import { CodeActionKind } from '../../../../../../../editor/contrib/codeAction/common/types.js'; @@ -33,6 +34,7 @@ suite('PromptCodeActionProvider', () => { let instaService: TestInstantiationService; let codeActionProvider: PromptCodeActionProvider; let fileService: IFileService; + let markerData: IMarker[] = []; setup(async () => { const testConfigService = new TestConfigurationService(); @@ -60,7 +62,8 @@ suite('PromptCodeActionProvider', () => { }; instaService.set(ILanguageModelToolsService, toolService); - instaService.stub(IMarkerService, { read: () => [] }); + markerData = []; + instaService.stub(IMarkerService, { read: () => markerData }); fileService = { canMove: async (source: URI, target: URI) => { @@ -222,6 +225,107 @@ suite('PromptCodeActionProvider', () => { const actions = await getCodeActions(content, 2, 1, PromptsType.agent); // Range in description, not tools assert.strictEqual(actions.length, 0); }); + + test('offers quick fix to enable built-in github mcp server', async () => { + markerData = [{ + code: { value: PromptValidatorMarkerCode.MissingGithubMcpServer, target: URI.parse('https://marketplace.visualstudio.com/items?itemName=io.github.github/github-mcp-server') }, + owner: 'prompts-diagnostics-provider', + resource: URI.parse('test:///test' + getPromptFileExtension(PromptsType.agent)), + severity: MarkerSeverity.Warning, + message: 'Missing github mcp server', + startLineNumber: 4, + startColumn: 9, + endLineNumber: 4, + endColumn: 19 + }]; + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + `tools: ['github/*']`, + '---', + ].join('\n'); + const actions = await getCodeActions(content, 4, 11, PromptsType.agent); + assert.deepStrictEqual(actions.map(action => action.title), [ + 'Enable Built-in GitHub MCP Server', + 'Install GitHub MCP Server from Marketplace' + ]); + }); + + test('offers quick fix to install playwright mcp server from marketplace', async () => { + markerData = [{ + code: { value: PromptValidatorMarkerCode.MissingPlaywrightMcpServer, target: URI.parse('https://marketplace.visualstudio.com/items?itemName=microsoft.playwright-mcp') }, + owner: 'prompts-diagnostics-provider', + resource: URI.parse('test:///test' + getPromptFileExtension(PromptsType.agent)), + severity: MarkerSeverity.Warning, + message: 'Missing playwright mcp server', + startLineNumber: 4, + startColumn: 9, + endLineNumber: 4, + endColumn: 21 + }]; + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + `tools: ['playwrite/*']`, + '---', + ].join('\n'); + const actions = await getCodeActions(content, 4, 11, PromptsType.agent); + assert.deepStrictEqual(actions.map(action => action.title), [ + 'Install Playwright MCP Server from Marketplace' + ]); + }); + + test('offers quick fix to search marketplace for an extension-style tool reference', async () => { + markerData = [{ + code: PromptValidatorMarkerCode.UnknownExtensionReference, + owner: 'prompts-diagnostics-provider', + resource: URI.parse('test:///test' + getPromptFileExtension(PromptsType.agent)), + severity: MarkerSeverity.Hint, + message: 'Unknown extension tool', + startLineNumber: 4, + startColumn: 9, + endLineNumber: 4, + endColumn: 28 + }]; + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + `tools: ['my.extension/tool']`, + '---', + ].join('\n'); + const actions = await getCodeActions(content, 4, 11, PromptsType.agent); + assert.deepStrictEqual(actions.map(action => action.title), [ + `Search Marketplace for Extension 'my.extension'` + ]); + }); + + test('offers quick fix to search marketplace for an mcp-style tool reference', async () => { + markerData = [{ + code: PromptValidatorMarkerCode.UnknownMcpServerReference, + owner: 'prompts-diagnostics-provider', + resource: URI.parse('test:///test' + getPromptFileExtension(PromptsType.agent)), + severity: MarkerSeverity.Hint, + message: 'Unknown MCP server', + startLineNumber: 4, + startColumn: 9, + endLineNumber: 4, + endColumn: 59 + }]; + const content = [ + '---', + 'description: "Test"', + 'target: vscode', + `tools: ['io.github.github/github-mcp-server/create_branch']`, + '---', + ].join('\n'); + const actions = await getCodeActions(content, 4, 11, PromptsType.agent); + assert.deepStrictEqual(actions.map(action => action.title), [ + `Search Marketplace for MCP Server 'io.github.github/github-mcp-server/create_branch'` + ]); + }); }); suite('prompt code actions', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 097d38bbba1224..cb83830cf07572 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -2062,7 +2062,7 @@ suite('PromptValidator', () => { const markers = await validate(content, PromptsType.prompt); const actual = markers.sort((a, b) => a.startLineNumber - b.startLineNumber).map(m => ({ message: m.message, startColumn: m.startColumn, endColumn: m.endColumn })); assert.deepEqual(actual, [ - { message: `Unknown tool or toolset 'ms-azuretools.vscode-azure-github-copilot/azure_recommend_custom_modes'.`, startColumn: 7, endColumn: 77 }, + { message: `Unknown extension tool 'ms-azuretools.vscode-azure-github-copilot/azure_recommend_custom_modes'. It is likely to be a missing extension, please ensure it is installed and enabled.`, startColumn: 7, endColumn: 77 }, { message: `Tool or toolset 'github.vscode-pull-request-github/suggest-fix' also needs to be enabled in the header.`, startColumn: 7, endColumn: 52 }, { message: `Tool or toolset 'openSimpleBrowser' has been renamed, use 'vscode/openIntegratedBrowser' instead.`, startColumn: 7, endColumn: 24 }, ]); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index ec5da7af3f759a..c672f63b4d55c8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -4985,4 +4985,96 @@ suite('LanguageModelToolsService', () => { assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); }); }); + + suite('preApproved (out-of-band auto-approval)', () => { + let preApprovedService: LanguageModelToolsService; + let preApprovedChatService: MockChatService; + + setup(() => { + const setup = createTestToolsService(store); + preApprovedService = setup.service; + preApprovedChatService = setup.chatService; + }); + + test('a confirmable tool with dto.preApproved never enters WaitingForConfirmation', async () => { + let invokeCompleted = false; + const tool = registerToolForTest(preApprovedService, store, 'preApprovedTool', { + invoke: async () => { + invokeCompleted = true; + return { content: [{ kind: 'text', value: 'success' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm this action?', + message: 'This tool would normally require confirmation', + allowAutoConfirm: true, + }, + }), + }); + + const capture: { invocation?: ChatToolInvocation } = {}; + stubGetSession(preApprovedChatService, 'pre-approved', { requestId: 'req1', capture }); + + const dto = tool.makeDto({ test: 1 }, { sessionId: 'pre-approved' }); + dto.preApproved = { type: ToolConfirmKind.Setting, id: 'autoApprove' }; + + // Start the invocation without awaiting so the state at publish time is + // observable: an auto-approved call must be published already executing + // and must never surface a confirmation prompt (which would flicker + // "needs input" in the sessions list). + const invokePromise = preApprovedService.invokeTool(dto, async () => 0, CancellationToken.None); + const invocation = await waitForPublishedInvocation(capture); + const publishedState = invocation.state.get().type; + + // If the fix regressed, the call would be stuck awaiting confirmation; + // confirm it so the promise resolves and the assertion below (rather + // than a test timeout) reports the failure. + if (publishedState === IChatToolInvocation.StateKind.WaitingForConfirmation) { + IChatToolInvocation.confirmWith(invocation, { type: ToolConfirmKind.UserAction }); + } + const result = await invokePromise; + + assert.deepStrictEqual( + { + invokeCompleted, + value: (result.content[0] as IToolResultTextPart).value, + publishedWaitingForConfirmation: publishedState === IChatToolInvocation.StateKind.WaitingForConfirmation, + }, + { + invokeCompleted: true, + value: 'success', + publishedWaitingForConfirmation: false, + }, + ); + }); + + test('dto.preApproved does not override a preToolUse hook that returned ask', async () => { + const tool = registerToolForTest(preApprovedService, store, 'preApprovedAskTool', { + invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }), + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm this action?', + message: 'This tool requires confirmation', + allowAutoConfirm: true, + }, + }), + }); + + const capture: { invocation?: ChatToolInvocation } = {}; + stubGetSession(preApprovedChatService, 'pre-approved-ask', { requestId: 'req1', capture }); + + const dto = tool.makeDto({ test: 1 }, { sessionId: 'pre-approved-ask' }); + dto.preApproved = { type: ToolConfirmKind.Setting, id: 'autoApprove' }; + dto.preToolUseResult = { permissionDecision: 'ask', permissionDecisionReason: 'Requires user confirmation' }; + + const invokePromise = preApprovedService.invokeTool(dto, async () => 0, CancellationToken.None); + const invocation = await waitForPublishedInvocation(capture); + + assert.strictEqual(invocation.state.get().type, IChatToolInvocation.StateKind.WaitingForConfirmation, + 'preApproved must not override an explicit hook ask'); + + IChatToolInvocation.confirmWith(invocation, { type: ToolConfirmKind.UserAction }); + await invokePromise; + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAttachmentsContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAttachmentsContentPart.test.ts index 041587600bd8ae..fdaa407bed504d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAttachmentsContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatAttachmentsContentPart.test.ts @@ -7,10 +7,13 @@ import assert from 'assert'; import { DisposableStore, toDisposable } from '../../../../../../../base/common/lifecycle.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { URI } from '../../../../../../../base/common/uri.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { getEffectiveImageOmittedState } from '../../../../browser/attachments/chatAttachmentWidgets.js'; import { ChatAttachmentsContentPart } from '../../../../browser/widget/chatContentParts/chatAttachmentsContentPart.js'; -import { AgentHostCompletionReferenceKind, IChatRequestVariableEntry, toAgentHostCompletionVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; +import { AgentHostCompletionReferenceKind, IChatRequestVariableEntry, OmittedState, toAgentHostCompletionVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; suite('ChatAttachmentsContentPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -49,6 +52,27 @@ suite('ChatAttachmentsContentPart', () => { }; } + function setModels(models: ReadonlyArray<{ identifier: string; id: string; vendor: string; vision: boolean }>): void { + instantiationService.stub(ILanguageModelsService, { + getLanguageModelIds: () => models.map(model => model.identifier), + lookupLanguageModel: identifier => { + const model = models.find(model => model.identifier === identifier); + return model ? { + extension: new ExtensionIdentifier('test.extension'), + id: model.id, + vendor: model.vendor, + name: model.id, + version: '1', + family: model.id, + maxInputTokens: 1000, + maxOutputTokens: 1000, + isDefaultForLocation: {}, + capabilities: { vision: model.vision }, + } satisfies ILanguageModelChatMetadata : undefined; + }, + } as ILanguageModelsService); + } + suite('updateVariables', () => { test('should update variables and re-render', () => { const initialVariables: IChatRequestVariableEntry[] = [ @@ -254,5 +278,74 @@ suite('ChatAttachmentsContentPart', () => { assert.ok(part.domNode!.classList.contains('chat-attached-context'), 'Should have chat-attached-context class'); }); + + test('should mark images omitted when the routed model does not support vision', () => { + setModels([ + { identifier: 'copilot/auto', id: 'auto', vendor: 'copilot', vision: false }, + { identifier: 'other/test-non-vision', id: 'test-non-vision', vendor: 'other', vision: true }, + { identifier: 'copilot/test-non-vision', id: 'test-non-vision', vendor: 'copilot', vision: false }, + ]); + const image = createImageEntry('image.png', new Uint8Array([1, 2, 3])); + + const part = store.add(instantiationService.createInstance( + ChatAttachmentsContentPart, + { variables: [image], modelId: 'copilot/auto', resolvedModelId: 'test-non-vision' } + )); + + const attachment = part.domNode!.querySelector<HTMLElement>('.image-attachment'); + assert.deepStrictEqual({ + omittedState: image.omittedState, + ariaLabel: attachment?.ariaLabel, + isWarning: attachment?.classList.contains('warning'), + }, { + omittedState: undefined, + ariaLabel: 'Image not sent because test-non-vision does not support images: image.png', + isWarning: true, + }); + }); + + test('should not mark images omitted for Auto before routing', () => { + setModels([{ identifier: 'copilot/auto', id: 'copilot/auto', vendor: 'copilot', vision: false }]); + const image = createImageEntry('image.png', new Uint8Array([1, 2, 3])); + + const part = store.add(instantiationService.createInstance( + ChatAttachmentsContentPart, + { variables: [image], modelId: 'copilot/auto' } + )); + + const attachment = part.domNode!.querySelector<HTMLElement>('.image-attachment'); + assert.deepStrictEqual({ + omittedState: image.omittedState, + ariaLabel: attachment?.ariaLabel, + isWarning: attachment?.classList.contains('warning'), + isAutoWarning: attachment?.classList.contains('auto-image-warning'), + hasWarningIcon: !!attachment?.querySelector('.codicon-warning'), + }, { + omittedState: undefined, + ariaLabel: 'Attached image, image.png. Image support depends on the model selected by Auto.', + isWarning: false, + isAutoWarning: true, + hasWarningIcon: false, + }); + }); + + test('should ignore a stale omitted state when editing with Auto', () => { + const autoModel = { + identifier: 'copilot/auto', + metadata: { + extension: new ExtensionIdentifier('test.extension'), + id: 'copilot/auto', + vendor: 'copilot', + name: 'Auto', + version: '1', + family: 'auto', + maxInputTokens: 1000, + maxOutputTokens: 1000, + isDefaultForLocation: {}, + } + } satisfies { identifier: string; metadata: ILanguageModelChatMetadata }; + + assert.strictEqual(getEffectiveImageOmittedState(OmittedState.Full, autoModel, true), OmittedState.NotOmitted); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts index 21f55713b50cff..511d5da8bcf19d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMarkdownContentPart.test.ts @@ -16,20 +16,25 @@ import { SymbolKind, SymbolTag } from '../../../../../../../editor/common/langua import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { IMarkdownRenderer, IMarkdownRendererService } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { toAgentHostUri } from '../../../../../../../platform/agentHost/common/agentHostUri.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IChatContentPartRenderContext } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { ChatMarkdownContentPart } from '../../../../browser/widget/chatContentParts/chatMarkdownContentPart.js'; +import { ChatContentMarkdownRenderer } from '../../../../browser/widget/chatContentMarkdownRenderer.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { CodeBlockPart, ICodeBlockData } from '../../../../browser/widget/chatContentParts/codeBlockPart.js'; import { IChatOutputRendererService, type RenderedOutputPart } from '../../../../browser/chatOutputItemRenderer.js'; import { IChatOutputPartStateCache, IOutputPartState } from '../../../../browser/widget/chatContentParts/chatOutputPartStateCache.js'; import { IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; import { IChatContentInlineReference } from '../../../../common/chatService/chatService.js'; +import { IChatSessionsService } from '../../../../common/chatSessionsService.js'; import { ChatConfiguration } from '../../../../common/constants.js'; +import { rewriteAgentHostLinkTarget } from '../../../../browser/agentSessions/agentHost/stateToProgressAdapter.js'; import { IAiEditTelemetryService } from '../../../../../editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; import { IViewDescriptorService } from '../../../../../../common/views.js'; import { IDisposableReference } from '../../../../browser/widget/chatContentParts/chatCollections.js'; +import { MockChatSessionsService } from '../../../common/mockChatSessionsService.js'; suite('ChatMarkdownContentPart', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -38,6 +43,7 @@ suite('ChatMarkdownContentPart', () => { let instantiationService: ReturnType<typeof workbenchInstantiationService>; let editorPool: EditorPool; let renderer: IMarkdownRenderer; + let chatSessionsService: MockChatSessionsService; /** Data captured from each CodeBlockPart.render() call */ const renderedCodeBlocks: ICodeBlockData[] = []; @@ -135,6 +141,8 @@ suite('ChatMarkdownContentPart', () => { setup(() => { disposables = store.add(new DisposableStore()); instantiationService = workbenchInstantiationService(undefined, disposables); + chatSessionsService = new MockChatSessionsService(); + instantiationService.stub(IChatSessionsService, chatSessionsService); renderedCodeBlocks.length = 0; renderedCodeBlockOutputs.length = 0; outputStateCache = new Map<string, IOutputPartState>(); @@ -210,8 +218,7 @@ suite('ChatMarkdownContentPart', () => { getViewLocationById: () => null, }); - // Use the real markdown renderer service - renderer = instantiationService.get(IMarkdownRendererService); + renderer = instantiationService.createInstance(ChatContentMarkdownRenderer); // Create a mock editor pool editorPool = createMockEditorPool(); @@ -221,6 +228,36 @@ suite('ChatMarkdownContentPart', () => { disposables.dispose(); }); + test('transforms accumulated response Markdown while preserving link text', () => { + disposables.add(chatSessionsService.registerChatSessionContentProvider('chat-session', { + provideChatSessionContent: async () => { throw new Error('Unexpected session resolution'); }, + resolveChatResponseUri: (_resource, href) => rewriteAgentHostLinkTarget(href, 'my-host'), + })); + + const part = createMarkdownPart('`[foo.ts](/code.ts)` [a[b].ts](/remote/a.ts "/remote/a.ts"), [a\\*b.ts](/remote/b.ts), [line.ts](/remote/line.ts:42), [column.ts](/remote/column.ts:42:7), [windows.ts](C:/remote/windows.ts:42), [unc.ts](//server/share/unc.ts:42), [skill](/remote/skill/SKILL.md), and [file-uri.ts](file:///remote/file-uri.ts:42). ![image](/remote/image.png)'); + const links = Array.from(part.domNode.querySelectorAll('a')); + const skillUri = toAgentHostUri(URI.file('/remote/skill/SKILL.md'), 'my-host'); + assert.deepStrictEqual( + { + links: links.map(link => ({ text: link.textContent, href: link.dataset.href })), + imageSource: part.domNode.querySelector('img')?.getAttribute('src'), + }, + { + links: [ + { text: 'a[b].ts', href: toAgentHostUri(URI.file('/remote/a.ts'), 'my-host').toString() }, + { text: 'a*b.ts', href: toAgentHostUri(URI.file('/remote/b.ts'), 'my-host').toString() }, + { text: 'line.ts', href: toAgentHostUri(URI.file('/remote/line.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'column.ts', href: toAgentHostUri(URI.file('/remote/column.ts').with({ fragment: 'L42,7' }), 'my-host').toString() }, + { text: 'windows.ts', href: toAgentHostUri(URI.file('C:/remote/windows.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'unc.ts', href: toAgentHostUri(URI.file('//server/share/unc.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + { text: 'skill', href: skillUri.with({ query: `${skillUri.query}&vscodeLinkType=skill` }).toString() }, + { text: 'file-uri.ts', href: toAgentHostUri(URI.file('/remote/file-uri.ts').with({ fragment: 'L42' }), 'my-host').toString() }, + ], + imageSource: null, + }, + ); + }); + test('renders plain markdown without code blocks', () => { const part = createMarkdownPart('Hello, world!'); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts new file mode 100644 index 00000000000000..438e54481684e9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatMcpServersStartingContentPart.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatMcpServersStartingContentPart } from '../../../../browser/widget/chatContentParts/chatMcpServersStartingContentPart.js'; +import { IChatMcpServersStartingSlow, IChatMcpStartingServer } from '../../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; + +suite('ChatMcpServersStartingContentPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + + setup(() => { + disposables = store.add(new DisposableStore()); + instantiationService = workbenchInstantiationService(undefined, disposables); + }); + + function createPart(servers: readonly IChatMcpStartingServer[]) { + const servers$ = observableValue<readonly IChatMcpStartingServer[]>('servers', servers); + const data: IChatMcpServersStartingSlow = { + kind: 'mcpServersStartingSlow', + sessionResource: URI.parse('chat-session://test/session1'), + servers: servers$, + }; + const part = disposables.add(instantiationService.createInstance(ChatMcpServersStartingContentPart, data)); + return { part, servers$ }; + } + + test('reflects the starting servers and hides when empty as the observable updates', () => { + const { part, servers$ } = createPart([{ id: 'a', name: 'alpha' }, { id: 'b', name: 'beta' }]); + + const snapshot = () => ({ hidden: part.domNode.style.display === 'none', text: part.domNode.textContent ?? '' }); + + const initial = snapshot(); + + servers$.set([{ id: 'a', name: 'alpha' }], undefined); + const afterOneFinished = snapshot(); + + servers$.set([], undefined); + const afterAllFinished = snapshot(); + + assert.deepStrictEqual({ initial, afterOneFinished, afterAllFinished }, { + initial: { hidden: false, text: 'Starting MCP servers alpha, beta...' }, + afterOneFinished: { hidden: false, text: 'Starting MCP servers alpha...' }, + afterAllFinished: { hidden: true, text: '' }, + }); + }); + + test('hasSameContent matches only the same kind', () => { + const { part } = createPart([{ id: 'a', name: 'alpha' }]); + + assert.deepStrictEqual( + [ + part.hasSameContent({ kind: 'mcpServersStartingSlow' } as IChatRendererContent, [], null!), + part.hasSameContent({ kind: 'mcpAuthenticationRequired' } as IChatRendererContent, [], null!), + ], + [true, false], + ); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 832edf81b6d990..e1d56f34139cb4 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -8,6 +8,8 @@ import { isHTMLElement } from '../../../../../../../base/browser/dom.js'; import { Event } from '../../../../../../../base/common/event.js'; import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; import { observableValue } from '../../../../../../../base/common/observable.js'; +// eslint-disable-next-line local/code-no-deep-import-of-internal +import { BaseObservable } from '../../../../../../../base/common/observableInternal/observables/baseObservable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; @@ -301,6 +303,56 @@ suite('ChatSubagentContentPart', () => { assert.ok(part.domNode.classList.contains('chat-thinking-fixed-mode'), 'Should have chat-thinking-fixed-mode class'); }); + test('should shimmer for an in-progress subagent even when the response is complete', () => { + const toolInvocation = createMockToolInvocation({ stateType: IChatToolInvocation.StateKind.Executing }); + const context = createMockRenderContext(true); + + const part = createPart(toolInvocation, context); + + assert.ok(part.domNode.querySelector('.chat-thinking-title-shimmer')); + }); + + test('should not shimmer for a completed subagent while the response is in progress', () => { + const toolInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + assert.deepStrictEqual({ + isActive: part.getIsActive(), + hasShimmer: !!part.domNode.querySelector('.chat-thinking-title-shimmer'), + }, { + isActive: false, + hasShimmer: false, + }); + }); + + test('should shimmer while Agent Host reports an active child chat after tool completion', () => { + const toolInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + isActive: true, + description: 'Running child chat', + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + assert.deepStrictEqual({ + isActive: part.getIsActive(), + hasShimmer: !!part.domNode.querySelector('.chat-thinking-title-shimmer'), + }, { + isActive: true, + hasShimmer: true, + }); + }); + test('should start collapsed', () => { const toolInvocation = createMockToolInvocation(); const context = createMockRenderContext(false); @@ -610,7 +662,7 @@ suite('ChatSubagentContentPart', () => { }); suite('hasSameContent', () => { - test('should return true for tool invocation with same subAgentInvocationId', () => { + test('should not reuse the visual part for a child tool invocation', () => { const toolInvocation = createMockToolInvocation({ subAgentInvocationId: 'subagent-123' }); const context = createMockRenderContext(false); @@ -622,7 +674,7 @@ suite('ChatSubagentContentPart', () => { }); const result = part.hasSameContent(otherInvocation, [], context.element); - assert.strictEqual(result, true, 'Should match tool invocation with same subAgentInvocationId'); + assert.strictEqual(result, false); }); test('should return false for tool invocation with different subAgentInvocationId', () => { @@ -659,19 +711,19 @@ suite('ChatSubagentContentPart', () => { assert.strictEqual(result, true, 'Should match runSubagent tool using toolCallId as effective ID'); }); - test('should return true for markdownContent (allowing grouping)', () => { - const toolInvocation = createMockToolInvocation(); + test('should not reuse the visual part for grouped markdown', () => { + const toolInvocation = createMockToolInvocation({ toolCallId: 'subagent-123' }); const context = createMockRenderContext(false); const part = createPart(toolInvocation, context); const markdownContent: IChatMarkdownContent = { kind: 'markdownContent', - content: { value: 'test' } + content: { value: '<vscode_codeblock_uri subAgentInvocationId="subagent-123">file:///test.txt</vscode_codeblock_uri>' } }; const result = part.hasSameContent(markdownContent, [], context.element); - assert.strictEqual(result, true, 'Should match markdownContent to allow grouping'); + assert.strictEqual(result, false); }); }); @@ -1281,6 +1333,39 @@ suite('ChatSubagentContentPart', () => { 'Should auto-expand when tool needs confirmation'); }); + test('should stop tracking a tool invocation once it reaches a terminal state', async () => { + const toolInvocation = createMockToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent' + } + }); + const context = createMockRenderContext(false); + + const part = createPart(toolInvocation, context); + + const stateObservable = observableValue('state', createState(IChatToolInvocation.StateKind.Executing)); + const childTool: IChatToolInvocation = { + ...createMockToolInvocation({ + toolId: 'readFile', + subAgentInvocationId: toolInvocation.subAgentInvocationId + }), + state: stateObservable, + invocationMessage: 'Reading file' + }; + + part.trackToolState(childTool); + const observerCount = () => (stateObservable as unknown as BaseObservable<IChatToolInvocation.State>).debugGetObservers().size; + assert.strictEqual(observerCount(), 1, 'Tracking autorun should observe the tool state'); + + // Complete the tool; disposal of the tracking autorun is deferred via a microtask. + stateObservable.set(createState(IChatToolInvocation.StateKind.Completed), undefined); + await Promise.resolve(); + + assert.strictEqual(observerCount(), 0, 'Tracking autorun should be disposed once the tool reaches a terminal state'); + }); + test('should auto-collapse when confirmation is addressed', () => { const toolInvocation = createMockToolInvocation({ toolSpecificData: { diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts new file mode 100644 index 00000000000000..54d20bc00bed23 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSystemNotificationContentPart.test.ts @@ -0,0 +1,47 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IRenderedMarkdown, renderAsPlaintext } from '../../../../../../../base/browser/markdownRenderer.js'; +import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { IMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IMarkdownRenderer } from '../../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatSystemNotificationContentPart } from '../../../../browser/widget/chatContentParts/chatSystemNotificationContentPart.js'; + +suite('ChatSystemNotificationContentPart', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('renders persistent checked notification content', () => { + const disposables = store.add(new DisposableStore()); + const instantiationService = workbenchInstantiationService(undefined, disposables); + const renderer: IMarkdownRenderer = { + render: (markdown: IMarkdownString): IRenderedMarkdown => { + const element = mainWindow.document.createElement('div'); + element.textContent = renderAsPlaintext(markdown); + return { element, dispose: () => { } }; + }, + }; + const part = disposables.add(instantiationService.createInstance( + ChatSystemNotificationContentPart, + { kind: 'systemNotification', content: new MarkdownString('Background command completed') }, + renderer, + )); + + assert.deepStrictEqual({ + text: part.domNode.textContent, + hasCheck: !!part.domNode.querySelector('.codicon-check'), + sameContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }), + differentContent: part.hasSameContent({ kind: 'systemNotification', content: new MarkdownString('Different') }), + }, { + text: 'Background command completed', + hasCheck: true, + sameContent: true, + differentContent: false, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index 4c1c2bbe752174..d8d8173f1a26bf 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -10,10 +10,11 @@ import { DisposableStore, toDisposable } from '../../../../../../../base/common/ import { observableValue } from '../../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { mainWindow } from '../../../../../../../base/browser/window.js'; +import { Codicon } from '../../../../../../../base/common/codicons.js'; import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ChatThinkingContentPart, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; +import { ChatThinkingContentPart, getToolInvocationIcon, maybePickFunWorkingMessage } from '../../../../browser/widget/chatContentParts/chatThinkingContentPart.js'; import { IChatMarkdownContent, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../../common/chatService/chatService.js'; import { IChatContentPartRenderContext, InlineTextModelCollection } from '../../../../browser/widget/chatContentParts/chatContentParts.js'; import { IChatRendererContent, IChatResponseViewModel } from '../../../../common/model/chatViewModel.js'; @@ -143,6 +144,40 @@ suite('ChatThinkingContentPart', () => { assert.strictEqual(maybePickFunWorkingMessage(mockConfigurationService, () => 0), undefined); }); + test('uses a search icon only when no problems were found', () => { + assert.deepStrictEqual({ + referenceName: getToolInvocationIcon('problems', Codicon.error, 'Checked files, no problems found'), + internalTool: getToolInvocationIcon('get_errors', Codicon.error, 'Checked files, no problems found'), + contributedTool: getToolInvocationIcon('copilot_getErrors', Codicon.error, 'Checked files, no problems found'), + problemsFound: getToolInvocationIcon('problems', Codicon.error, 'Checked files, 2 problems found'), + unrelatedTool: getToolInvocationIcon('terminal', Codicon.terminal, 'No problems found'), + }, { + referenceName: Codicon.search, + internalTool: Codicon.search, + contributedTool: Codicon.search, + problemsFound: Codicon.error, + unrelatedTool: Codicon.terminal, + }); + }); + + test('uses a comment icon for comment tools', () => { + assert.deepStrictEqual({ + addComment: getToolInvocationIcon('addComment'), + listComments: getToolInvocationIcon('listComments'), + deleteComments: getToolInvocationIcon('deleteComments'), + resolveComments: getToolInvocationIcon('resolveComments'), + viewUnreviewedComments: getToolInvocationIcon('viewUnreviewedComments'), + prefixedComment: getToolInvocationIcon('mcp__host__addComment'), + }, { + addComment: Codicon.comment, + listComments: Codicon.comment, + deleteComments: Codicon.comment, + resolveComments: Codicon.comment, + viewUnreviewedComments: Codicon.comment, + prefixedComment: Codicon.comment, + }); + }); + suite('ThinkingDisplayMode.Collapsed', () => { setup(() => { mockConfigurationService.setUserConfiguration('chat.agent.thinkingStyle', ThinkingDisplayMode.Collapsed); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts index 9f62aae2d497d7..cd169e190e7a1a 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatListRenderer.test.ts @@ -6,7 +6,10 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { buildPlanReviewProgressContent, shouldHideChatUserIdentity, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { buildPlanReviewProgressContent, getWorkingProgressRelevantParts, shouldHideChatUserIdentity, shouldRenderInitialProgressiveContentImmediately, shouldScheduleInitialHeightChange } from '../../../browser/widget/chatListRenderer.js'; +import { IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ToolDataSource } from '../../../common/tools/languageModelToolsService.js'; suite('ChatListRenderer', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -29,6 +32,22 @@ suite('ChatListRenderer', () => { }); }); + suite('shouldRenderInitialProgressiveContentImmediately', () => { + test('renders accumulated markdown immediately only when progressive rendering has not started', () => { + assert.deepStrictEqual([ + shouldRenderInitialProgressiveContentImmediately(false, true, false), + shouldRenderInitialProgressiveContentImmediately(false, true, true), + shouldRenderInitialProgressiveContentImmediately(true, true, false), + shouldRenderInitialProgressiveContentImmediately(false, false, false), + ], [ + true, + false, + false, + false, + ]); + }); + }); + suite('shouldHideChatUserIdentity', () => { test('hides local Copilot and Agent Host Copilot response identity', () => { assert.deepStrictEqual([ @@ -71,4 +90,37 @@ suite('ChatListRenderer', () => { assert.strictEqual(content.value, 'Approved plan\n\n## Plan summary\n\n[Open full plan file (plan.md)](file:///sessions/abc/plan.md?vscodeLinkType=file)'); }); }); + + test('working progress ignores subagent-owned response parts', () => { + const parentSubagent: IChatToolInvocationSerialized = { + kind: 'toolInvocationSerialized', + toolCallId: 'subagent-1', + toolId: 'task', + source: ToolDataSource.Internal, + invocationMessage: 'Running subagent', + originMessage: undefined, + pastTenseMessage: undefined, + isConfirmed: { type: ToolConfirmKind.ConfirmationNotNeeded }, + isComplete: true, + presentation: undefined, + toolSpecificData: { kind: 'subagent', description: 'Investigate' }, + }; + const childTool: IChatToolInvocationSerialized = { + ...parentSubagent, + toolCallId: 'child-1', + toolId: 'search', + subAgentInvocationId: 'subagent-1', + toolSpecificData: undefined, + }; + const parts: IChatRendererContent[] = [ + { kind: 'references', references: [] }, + parentSubagent, + childTool, + { kind: 'markdownContent', content: { value: '<vscode_codeblock_uri subAgentInvocationId="subagent-1">file:///test.txt</vscode_codeblock_uri>' } }, + { kind: 'hook', hookType: 'PreToolUse', subAgentInvocationId: 'subagent-1' }, + ]; + + assert.deepStrictEqual(getWorkingProgressRelevantParts(parts).map(part => part.kind), ['references']); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputStatePersistence.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputStatePersistence.test.ts new file mode 100644 index 00000000000000..6a3403efcc76f3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatInputStatePersistence.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IImageVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; +import { ChatModeKind } from '../../../../common/constants.js'; +import { IChatModelInputState } from '../../../../common/model/chatModel.js'; +import { deserializeUntitledInputAttachments, deserializeUntitledInputState, serializeUntitledInputAttachments, serializeUntitledInputState } from '../../../../browser/widget/input/chatInputStatePersistence.js'; + +suite('ChatInputStatePersistence', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('stores image payloads separately from frequently updated input state', () => { + const image: IImageVariableEntry = { + id: 'image', + kind: 'image', + name: 'image.png', + mimeType: 'image/png', + value: new Uint8Array(64 * 1024).fill(42), + references: [{ kind: 'reference', reference: URI.file('/image.png') }], + }; + const state: IChatModelInputState = { + attachments: [image], + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: undefined, + inputText: 'hello', + selections: [], + contrib: {}, + }; + + const serializedState = serializeUntitledInputState(state); + const serializedAttachments = serializeUntitledInputAttachments(state.attachments); + const restoredState = deserializeUntitledInputState(serializedState); + const restoredImage = deserializeUntitledInputAttachments(serializedAttachments)[0]; + + assert.deepStrictEqual({ + state: { + attachments: restoredState.attachments, + mode: restoredState.mode, + inputText: restoredState.inputText, + }, + attachment: { + id: restoredImage.id, + value: Array.from((restoredImage.value as Uint8Array).subarray(0, 3)), + valueLength: (restoredImage.value as Uint8Array).byteLength, + reference: restoredImage.references?.[0].reference, + }, + stateRemainsSmall: serializedState.length < 1024, + attachmentPayloadIsSeparate: !serializedState.includes('$base64') && serializedAttachments.includes('$base64'), + }, { + state: { + attachments: [], + mode: state.mode, + inputText: state.inputText, + }, + attachment: { + id: image.id, + value: [42, 42, 42], + valueLength: 64 * 1024, + reference: URI.file('/image.png'), + }, + stateRemainsSmall: true, + attachmentPayloadIsSeparate: true, + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts index c335f561693ebb..c0c34f4ecd4c45 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelPicker.test.ts @@ -12,6 +12,7 @@ import { ActionListItemKind, IActionListItem } from '../../../../../../../platfo import { IActionWidgetDropdownAction } from '../../../../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { StateType } from '../../../../../../../platform/update/common/update.js'; import { buildModelPickerItems, getControlModelsForEntitlement, getModelPickerAccessibilityProvider } from '../../../../browser/widget/input/chatModelPicker.js'; +import { getModelProviderIcon } from '../../../../browser/widget/input/modelProviderIcons.js'; import { filterModelsForSession } from '../../../../browser/widget/input/chatModelSelectionLogic.js'; import { ChatAgentLocation, ChatModeKind } from '../../../../common/constants.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService, IModelControlEntry, IModelsControlManifest } from '../../../../common/languageModels.js'; @@ -48,6 +49,58 @@ function createAutoModel(): ILanguageModelChatMetadataAndIdentifier { return createModel('auto', 'Auto', 'copilot'); } +suite('model provider icons', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses provider-specific icons', () => { + assert.deepStrictEqual([ + getModelProviderIcon(createModel('gpt-5.6-terra', 'GPT-5.6 Terra')).id, + getModelProviderIcon(createModel('claude-sonnet-5', 'Claude Sonnet 5')).id, + getModelProviderIcon(createModel('gemini-3.1-pro', 'Gemini 3.1 Pro')).id, + getModelProviderIcon(createAutoModel()).id, + getModelProviderIcon(createModel('auto', 'Auto', 'anthropic')).id, + getModelProviderIcon(createModel('auto', 'Auto', 'openai'), true).id, + getModelProviderIcon(createModel('custom', 'Custom Model', 'third-party')).id, + getModelProviderIcon(createModel('claude-sonnet-5', 'Claude Sonnet 5'), true).id, + ], [ + 'chat-model-provider-openai', + 'chat-model-provider-claude', + 'chat-model-provider-gemini', + 'chat-model-provider-copilot', + 'chat-model-provider-copilot', + 'chat-model-provider-copilot', + 'chat-model-provider-generic', + 'chat-model-provider-generic', + ]); + }); +}); + +/** + * Builds an agent-host model: all such models share a single vendor (the + * `agent-host-<type>` session type) but declare their upstream provider's + * vendor id via `modelGroup`. The picker buckets by it and resolves the + * display name from the vendor registry. + */ +function createAgentHostModel(id: string, name: string, modelGroup: { id: string }): ILanguageModelChatMetadataAndIdentifier { + const vendor = 'agent-host-copilotcli'; + return { + identifier: `${vendor}:${id}`, + metadata: { + id, + name, + vendor, + version: '1.0', + family: id, + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: {}, + targetChatSessionType: vendor, + modelGroup, + } as ILanguageModelChatMetadata, + }; +} + function getActionItems(items: IActionListItem<IActionWidgetDropdownAction>[]): IActionListItem<IActionWidgetDropdownAction>[] { return items.filter(i => i.kind === ActionListItemKind.Action); } @@ -758,6 +811,52 @@ suite('buildModelPickerItems', () => { assert.strictEqual(promoted.badge, 'OpenAI Compatible'); }); + test('Other Models splits agent-host models into sections by their modelGroup and labels copilotcli as Copilot', () => { + // Agent-host models all share one vendor but declare their upstream provider's + // vendor id via `modelGroup`; the picker resolves each group's display name from + // the vendor registry and renders one section per provider instead of collapsing + // them under the shared vendor. No BYOK config groups are registered, so grouping + // falls through to `modelGroup`. + const auto = createAutoModel(); + const cli = createAgentHostModel('claude-haiku-4.5', 'Claude Haiku 4.5', { id: 'copilotcli' }); + const openai = createAgentHostModel('openai/gpt-5-nano', 'GPT-5 nano', { id: 'openai' }); + const hf = createAgentHostModel('huggingface/gemma', 'Gemma', { id: 'huggingface' }); + const service = createLanguageModelsServiceStub([ + { vendor: 'copilotcli', displayName: 'Copilot CLI', groups: [] }, + { vendor: 'openai', displayName: 'OpenAI', groups: [] }, + { vendor: 'huggingface', displayName: 'Hugging Face', groups: [] }, + ]); + const items = callBuild([auto, cli, openai, hf], { languageModelsService: service }); + const labelledSeparators = items.filter(i => i.kind === ActionListItemKind.Separator && i.label); + // Buckets sorted alphabetically by resolved group display name. + assert.deepStrictEqual(labelledSeparators.map(s => s.label), ['Copilot', 'Hugging Face', 'OpenAI']); + }); + + test('Other Models keeps a single section when agent-host models share one modelGroup', () => { + const auto = createAutoModel(); + const a = createAgentHostModel('claude-haiku-4.5', 'Claude Haiku 4.5', { id: 'copilotcli' }); + const b = createAgentHostModel('gpt-5', 'GPT-5', { id: 'copilotcli' }); + const service = createLanguageModelsServiceStub([{ vendor: 'copilotcli', displayName: 'Copilot CLI', groups: [] }]); + const items = callBuild([auto, a, b], { languageModelsService: service }); + const labelledSeparators = items.filter(i => i.kind === ActionListItemKind.Separator && i.label); + assert.strictEqual(labelledSeparators.length, 0); + }); + + test('promoted agent-host model shows its modelGroup name as the inline badge', () => { + const auto = createAutoModel(); + const cli = createAgentHostModel('claude-haiku-4.5', 'Claude Haiku 4.5', { id: 'copilotcli' }); + const openai = createAgentHostModel('openai/gpt-5-nano', 'GPT-5 nano', { id: 'openai' }); + const service = createLanguageModelsServiceStub([ + { vendor: 'copilotcli', displayName: 'Copilot CLI', groups: [] }, + { vendor: 'openai', displayName: 'OpenAI', groups: [] }, + ]); + // More than one group is present, so promoted models surface their group inline. + const items = callBuild([auto, cli, openai], { recentModelIds: [openai.identifier], languageModelsService: service }); + const promoted = getActionItems(items).find(a => a.label === 'GPT-5 nano'); + assert.ok(promoted); + assert.strictEqual(promoted.badge, 'OpenAI'); + }); + test('onSelect callback is wired into action items', () => { const auto = createAutoModel(); const modelA = createModel('gpt-4o', 'GPT-4o'); @@ -1467,4 +1566,3 @@ suite('chat model picker - languageModelChatProvider visibility regression', () ); }); }); - diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts index c6a572fb3edf83..95178e0c651e88 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatModelSelectionLogic.test.ts @@ -21,10 +21,15 @@ import { mergeModelsWithCache, resolveConfiguredModel, resolveModelFromSyncState, + shouldDropAgnosticDraftModel, + shouldPersistModelSelection, shouldResetModelToDefault, shouldResetOnModelListChange, shouldRestoreLateArrivingModel, shouldRestorePersistedModel, + shouldRestorePerTypeModelOnSessionSwitch, + shouldSuppressModelPersistenceOnSessionSwitch, + shouldWaitForSessionModel, } from '../../../../browser/widget/input/chatModelSelectionLogic.js'; /** @@ -1841,4 +1846,227 @@ suite('ChatModelSelectionLogic', () => { assert.strictEqual(reason({ trusted: true, requiresSetup: true, pickerModels: [gpt], liveModelIds: new Set([gpt.identifier]) }), undefined); }); }); + + /** + * Regression coverage for the "new/reused agent-host chat editor reverts the model to the + * pool default (Auto/Haiku) instead of the remembered per-harness model, AND corrupts the + * persisted per-type key so it's wrong after restart too" bug. + * + * The fix's invariant: switching the input to a session must never WRITE the per-session-type + * model storage key; it may only set the model in-memory and RESTORE it from the key. Only an + * explicit user action (after the switch completes) persists. These tests lock the extracted + * decisions plus a storage-write "ledger" that replays the real session-switch sequence — + * including a NEGATIVE control that reproduces the v1 hazard (persisting during the switch) to + * prove the ledger genuinely catches it rather than being theatre. + * + * NOTE: `ChatInputPart` has 29 DI dependencies and no unit harness, so these tests exercise + * the extracted pure decisions and a faithful sequence simulation, not the DOM wiring. + * Manual repro (pick a model, open a new editor, restart) remains the final gate. + */ + suite('agent-host per-type model restore on session switch (regression)', () => { + const sessionType = 'agent-host-claude'; + const perTypeKey = `chat.currentLanguageModel.panel.${sessionType}`; + const generalKey = 'chat.currentLanguageModel.panel'; + + // Cross-pool draft model leaked from a prior general chat. + const agnosticAuto = createModel('auto', 'Auto'); // `copilot/auto`, no target + // The agent-host pool: its own default + the user's picked Opus. + const agentHostHaiku: ILanguageModelChatMetadataAndIdentifier = { + ...createSessionModel('claude-haiku-4.5', 'Claude Haiku 4.5', sessionType, { isDefaultForLocation: { [ChatAgentLocation.Chat]: true } }), + identifier: 'agent-host-claude:claude-haiku-4.5', + }; + const agentHostOpus: ILanguageModelChatMetadataAndIdentifier = { + ...createSessionModel('claude-opus-4.8', 'Claude Opus 4.8', sessionType), + identifier: 'agent-host-claude:claude-opus-4.8', + }; + const agentHostPool = [agentHostHaiku, agentHostOpus]; + const allMerged = [agnosticAuto, agentHostHaiku, agentHostOpus]; + + suite('predicates', () => { + test('suppress persistence for every empty own-pool session switch (regardless of incoming model)', () => { + assert.strictEqual(shouldSuppressModelPersistenceOnSessionSwitch(true, true), true); // fresh own-pool + assert.strictEqual(shouldSuppressModelPersistenceOnSessionSwitch(false, true), false); // non-empty (reopened) + assert.strictEqual(shouldSuppressModelPersistenceOnSessionSwitch(true, false), false); // general/local + }); + + test('restore per-type model only for a FRESH untitled own-pool session (no incoming model)', () => { + assert.strictEqual(shouldRestorePerTypeModelOnSessionSwitch(true, true, false), true); // fresh untitled + assert.strictEqual(shouldRestorePerTypeModelOnSessionSwitch(true, true, true), false); // carries its own model (transfer/restored) + assert.strictEqual(shouldRestorePerTypeModelOnSessionSwitch(false, true, false), false); // non-empty + assert.strictEqual(shouldRestorePerTypeModelOnSessionSwitch(true, false, false), false); // general/local + }); + + test('persist a model selection only when requested AND not during a session switch', () => { + assert.strictEqual(shouldPersistModelSelection(true, false), true); // explicit user pick, no switch + assert.strictEqual(shouldPersistModelSelection(true, true), false); // during a switch → never persist + assert.strictEqual(shouldPersistModelSelection(false, false), false); // transient (storeSelection=false) + }); + + test('drop a cross-pool draft model in either direction; keep an in-pool one', () => { + assert.strictEqual(shouldDropAgnosticDraftModel(agnosticAuto, allMerged, sessionType), true); // general model → agent-host session + assert.strictEqual(shouldDropAgnosticDraftModel(agentHostOpus, allMerged, undefined), true); // agent-host model → general session (reverse leak) + assert.strictEqual(shouldDropAgnosticDraftModel(agentHostOpus, allMerged, sessionType), false); // in-pool + assert.strictEqual(shouldDropAgnosticDraftModel(undefined, allMerged, sessionType), false); // no draft model + }); + }); + + /** + * Faithful storage-write ledger: writes a key only when {@link shouldPersistModelSelection} + * allows, exactly like `chatInputPart.setCurrentLanguageModel`. Returns the in-memory model + * and the storage map so tests can assert both the picker value and the persisted key. + */ + function runSessionSwitchSequence(opts: { + isEmpty: boolean; + ownsPool: boolean; + hadIncomingModel: boolean; + key: string; + storedModel: ILanguageModelChatMetadataAndIdentifier | undefined; // persisted per-type value at bind time + draftModel: ILanguageModelChatMetadataAndIdentifier | undefined; // shared agnostic draft's model + pool: ILanguageModelChatMetadataAndIdentifier[]; // models available to this session at bind + poolDefault: ILanguageModelChatMetadataAndIdentifier; + startingInMemory: ILanguageModelChatMetadataAndIdentifier; // reused-widget stale model + /** When true, reproduce the v1 hazard: never suppress persistence during the bind. */ + persistDuringSessionSwitch?: boolean; + }): { storage: Map<string, string>; inMemory: ILanguageModelChatMetadataAndIdentifier; keyAfterSessionSwitch: string | undefined } { + const storage = new Map<string, string>(); + if (opts.storedModel) { + storage.set(opts.key, opts.storedModel.identifier); + } + let inMemory = opts.startingInMemory; + + const suppress = opts.persistDuringSessionSwitch ? false : shouldSuppressModelPersistenceOnSessionSwitch(opts.isEmpty, opts.ownsPool); + const restore = shouldRestorePerTypeModelOnSessionSwitch(opts.isEmpty, opts.ownsPool, opts.hadIncomingModel); + + const setModel = (model: ILanguageModelChatMetadataAndIdentifier, storeSelection: boolean) => { + inMemory = model; + if (shouldPersistModelSelection(storeSelection, suppress)) { + storage.set(opts.key, model.identifier); + } + }; + + // 1. Draft sync during the switch. A cross-pool draft model is stripped, so the switch + // falls back to the pool default; an in-pool draft model would apply as-is. + const draftModel = shouldDropAgnosticDraftModel(opts.draftModel, opts.pool, opts.ownsPool ? sessionType : undefined) + ? undefined + : opts.draftModel; + if (draftModel) { + setModel(draftModel, /*storeSelection*/ true); + } else { + // _syncFromModel/_setEmptyModelState land on the pool default during the switch. + setModel(opts.poolDefault, /*storeSelection*/ true); + } + + // Snapshot of the key at the end of the switch, BEFORE the authoritative restore. + // The invariant is that a correct (suppressed) switch leaves this equal to the stored + // value; the v1 hazard clobbers it here. + const keyAfterSessionSwitch = storage.get(opts.key); + + // 2. [CVVM].2 restore (view model now assigned; session type correct). + if (restore && opts.storedModel && opts.pool.some(m => m.identifier === opts.storedModel!.identifier)) { + setModel(opts.storedModel, /*storeSelection*/ true); + } + + return { storage, inMemory, keyAfterSessionSwitch }; + } + + test('Repro A (copilotcli-style): cross-pool `auto` draft, key=Opus → key stays Opus, picker=Opus', () => { + const { storage, inMemory, keyAfterSessionSwitch } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: true, hadIncomingModel: false, + key: perTypeKey, storedModel: agentHostOpus, draftModel: agnosticAuto, + pool: agentHostPool, poolDefault: agentHostHaiku, startingInMemory: agnosticAuto, + }); + assert.strictEqual(keyAfterSessionSwitch, agentHostOpus.identifier, 'no write during the switch may reach the per-type key'); + assert.strictEqual(storage.get(perTypeKey), agentHostOpus.identifier, 'per-type key must not be clobbered during the switch'); + assert.strictEqual(inMemory.identifier, agentHostOpus.identifier, 'picker must show the remembered Opus'); + }); + + test('Repro B (reused widget): stale in-memory Haiku, key=Opus → key stays Opus, picker=Opus', () => { + const { storage, inMemory } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: true, hadIncomingModel: false, + key: perTypeKey, storedModel: agentHostOpus, draftModel: undefined, + pool: agentHostPool, poolDefault: agentHostHaiku, startingInMemory: agentHostHaiku, + }); + assert.strictEqual(storage.get(perTypeKey), agentHostOpus.identifier); + assert.strictEqual(inMemory.identifier, agentHostOpus.identifier); + }); + + test('a session that carries its own model (transfer/restored) keeps it and does not clobber the key', () => { + const { storage, inMemory } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: true, hadIncomingModel: true, // has its own model + key: perTypeKey, storedModel: agentHostOpus, draftModel: agentHostHaiku, + pool: agentHostPool, poolDefault: agentHostHaiku, startingInMemory: agentHostHaiku, + }); + // Suppression still on (empty own-pool) → key not written during the switch; restore skipped → keeps its own Haiku. + assert.strictEqual(storage.get(perTypeKey), agentHostOpus.identifier, 'key preserved'); + assert.strictEqual(inMemory.identifier, agentHostHaiku.identifier, 'incoming model honored in-memory'); + }); + + test('cold pool on session switch: restore is deferred and the key is not clobbered', () => { + // Pool empty during the switch (targeted models not loaded). Restore can't resolve yet; + // the switch falls to (a suppressed) default and the key stays Opus for the late wait to restore. + const { storage } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: true, hadIncomingModel: false, + key: perTypeKey, storedModel: agentHostOpus, draftModel: agnosticAuto, + pool: [], poolDefault: agentHostHaiku, startingInMemory: agnosticAuto, + }); + assert.strictEqual(storage.get(perTypeKey), agentHostOpus.identifier); + }); + + test('reverse leak: an agent-host model in the general draft does not clobber the general key', () => { + const generalGpt = createDefaultModelForLocation('gpt', 'GPT', ChatAgentLocation.Chat); + const { storage } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: false, hadIncomingModel: false, // general/local session + key: generalKey, storedModel: generalGpt, draftModel: agentHostOpus, + pool: [generalGpt], poolDefault: generalGpt, startingInMemory: generalGpt, + }); + // A general session switch is NOT suppressed, but the cross-pool agent-host draft model + // is stripped, so the general default (== stored) applies and the general key is not corrupted. + assert.strictEqual(storage.get(generalKey), generalGpt.identifier); + }); + + test('NEGATIVE control: reproducing the v1 hazard (persist during the switch) DOES clobber the key', () => { + // Proves the ledger is a genuine guard: with persistence NOT suppressed during the switch + // (the v1 behavior), the cross-pool `auto` draft resolves to the pool default and + // overwrites the remembered Opus during the switch — exactly the reported corruption. + // (The later restore step is what v1's mistimed init defeated; the faithful signal is + // the key state at the end of the switch.) + const { keyAfterSessionSwitch } = runSessionSwitchSequence({ + isEmpty: true, ownsPool: true, hadIncomingModel: false, + key: perTypeKey, storedModel: agentHostOpus, draftModel: agnosticAuto, + pool: agentHostPool, poolDefault: agentHostHaiku, startingInMemory: agnosticAuto, + persistDuringSessionSwitch: true, + }); + assert.strictEqual(keyAfterSessionSwitch, agentHostHaiku.identifier, 'v1 clobbers the key to the pool default during the switch'); + assert.notStrictEqual(keyAfterSessionSwitch, agentHostOpus.identifier); + }); + + /** + * Cold-restore wait: a restored session persists its own model (e.g. Opus), but at restart + * the agent-host pool loads late. `shouldWaitForSessionModel` decides whether to WAIT for + * the session's own model to arrive instead of persisting a transient pool default (Haiku) + * over it — the second half of the "reverts to Haiku after restart" fix. + */ + suite('shouldWaitForSessionModel (cold-restore wait)', () => { + test('waits when the session model targets this pool but is not loaded yet', () => { + // Cold pool: nothing loaded. + assert.strictEqual(shouldWaitForSessionModel(agentHostOpus, sessionType, []), true); + // Partial pool: the pool default (Haiku) loaded but the remembered Opus has not. + assert.strictEqual(shouldWaitForSessionModel(agentHostOpus, sessionType, [agnosticAuto, agentHostHaiku]), true); + }); + + test('does NOT wait once the session model is available (normal apply path handles it)', () => { + assert.strictEqual(shouldWaitForSessionModel(agentHostOpus, sessionType, allMerged), false); + }); + + test('does NOT wait for a model that does not belong to this session pool (would wait forever)', () => { + // A general/cross-pool model in an agent-host session: not our pool → default instead. + assert.strictEqual(shouldWaitForSessionModel(agnosticAuto, sessionType, [agentHostHaiku]), false); + // A model targeting a DIFFERENT agent-host type. + const otherType = { ...agentHostOpus, metadata: { ...agentHostOpus.metadata, targetChatSessionType: 'agent-host-copilotcli' } }; + assert.strictEqual(shouldWaitForSessionModel(otherType, sessionType, []), false); + // No session type at all. + assert.strictEqual(shouldWaitForSessionModel(agentHostOpus, undefined, []), false); + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts index 77e2114512731c..2ebc5dd95d059d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatSelectedTools.test.ts @@ -181,4 +181,55 @@ suite('ChatSelectedTools', () => { assert.strictEqual(userSelectedTools[toolData3.id], true); }); }); + + test('Can disable a tool from a hidden tool set #324006', () => { + return runWithFakedTimers({}, async () => { + const toolData1: IToolData = { + id: 'testTool1', + modelDescription: 'Test Tool 1', + displayName: 'Test Tool 1', + canBeReferencedInPrompt: true, + toolReferenceName: 't1', + source: ToolDataSource.Internal, + }; + + const toolData2: IToolData = { + id: 'testTool2', + modelDescription: 'Test Tool 2', + displayName: 'Test Tool 2', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + toolReferenceName: 't2', + }; + + // A tool set that is hidden from the tools picker (e.g. a built-in client tool set). + // The user can not toggle it, so it always resolves to enabled and must not force its + // member tools back on when they are individually disabled. + const toolset = toolsService.createToolSet( + ToolDataSource.Internal, + 'hiddenToolSet', 'hiddenToolSet', + { hiddenInToolsPicker: true } + ); + + store.add(toolsService.registerToolData(toolData1)); + store.add(toolsService.registerToolData(toolData2)); + + store.add(toolset); + store.add(toolset.addTool(toolData1)); + store.add(toolset.addTool(toolData2)); + + await timeout(1000); // UGLY the tools service updates its state sync but emits the event async (750ms) delay. This affects the observable that depends on the event + + // Disable tool 2 individually. The hidden tool set has no stored state (the picker + // never surfaces it), so it defaults to enabled. + const toSet = ToolAndToolSetEnablementMap.fromEntries([[toolData1, true], [toolData2, false]]); + selectedTools.set(toSet, false); + + const userSelectedTools = selectedTools.userSelectedTools.get(); + + // The individually disabled tool stays disabled even though its owning tool set resolves to enabled. + assert.strictEqual(userSelectedTools[toolData1.id], true); + assert.strictEqual(userSelectedTools[toolData2.id], false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts new file mode 100644 index 00000000000000..e7f482b21f6f0a --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/editor/chatInputCommandArgumentHint.test.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../../base/test/common/utils.js'; +import { Range } from '../../../../../../../../editor/common/core/range.js'; +import { IDecorationOptions } from '../../../../../../../../editor/common/editorCommon.js'; +import { withTestCodeEditor } from '../../../../../../../../editor/test/browser/testCodeEditor.js'; +import { IDynamicVariable } from '../../../../../common/attachments/chatVariables.js'; +import { IChatWidget } from '../../../../../browser/chat.js'; +import { ChatWidget } from '../../../../../browser/widget/chatWidget.js'; +import { ChatDynamicVariableModel } from '../../../../../browser/attachments/chatDynamicVariables.js'; +import '../../../../../browser/widget/input/editor/chatInputCommandArgumentHint.js'; + +suite('InputEditorCommandArgumentHint', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function getCtor() { + const ctor = ChatWidget.CONTRIBS.find(contrib => contrib.name === 'InputEditorCommandArgumentHint'); + assert.ok(ctor, 'InputEditorCommandArgumentHint should be registered as a chat widget contribution'); + return ctor!; + } + + function commandReference(range: Range, argumentHint: string | undefined): IDynamicVariable { + return { + id: 'agent-host-command:plan', + range, + data: { $mid: 'agentHostCompletion', kind: 'command' }, + _meta: { command: 'plan', ...(argumentHint !== undefined ? { argumentHint } : {}) }, + }; + } + + function run(value: string, variables: IDynamicVariable[], trigger: 'parsedInput' | 'references' = 'parsedInput'): IDecorationOptions[] { + let captured: IDecorationOptions[] = []; + withTestCodeEditor(value, {}, (editor, _vm, instantiationService) => { + const store = new DisposableStore(); + try { + const realSet = editor.setDecorationsByType.bind(editor); + editor.setDecorationsByType = ((desc: string, key: string, opts: IDecorationOptions[]) => { + if (key === 'chat-command-argument-hint') { + captured = opts; + } + return realSet(desc, key, opts); + }) as typeof editor.setDecorationsByType; + + const parsedInputEmitter = store.add(new Emitter<void>()); + const referencesEmitter = store.add(new Emitter<void>()); + const dynamicVariableModel = { variables, onDidChangeReferences: referencesEmitter.event } as unknown as ChatDynamicVariableModel; + const widget = { + inputEditor: editor, + onDidChangeParsedInput: parsedInputEmitter.event, + getContrib: (id: string) => id === ChatDynamicVariableModel.ID ? dynamicVariableModel : undefined, + } as unknown as IChatWidget; + + store.add(instantiationService.createInstance(getCtor(), widget)); + (trigger === 'references' ? referencesEmitter : parsedInputEmitter).fire(); + } finally { + store.dispose(); + } + }); + return captured; + } + + test('renders ghost text after a command with a trailing space and an argument hint', () => { + const decorations = run('/plan ', [commandReference(new Range(1, 1, 1, 6), 'task')]); + assert.deepStrictEqual(decorations, [{ + range: { startLineNumber: 1, endLineNumber: 1, startColumn: 7, endColumn: 1000 }, + renderOptions: { after: { contentText: 'task', color: undefined } } + }]); + }); + + test('renders ghost text when only the references change (accepted completion, no parsed-input change)', () => { + const decorations = run('/plan ', [commandReference(new Range(1, 1, 1, 6), 'task')], 'references'); + assert.deepStrictEqual(decorations, [{ + range: { startLineNumber: 1, endLineNumber: 1, startColumn: 7, endColumn: 1000 }, + renderOptions: { after: { contentText: 'task', color: undefined } } + }]); + }); + + test('renders nothing without an argument hint, once an argument is typed, or with leading text', () => { + assert.deepStrictEqual(run('/plan ', [commandReference(new Range(1, 1, 1, 6), undefined)]), []); + assert.deepStrictEqual(run('/plan task', [commandReference(new Range(1, 1, 1, 6), 'task')]), []); + assert.deepStrictEqual(run('hi /plan ', [commandReference(new Range(1, 4, 1, 9), 'task')]), []); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.ts new file mode 100644 index 00000000000000..91b62338203588 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/editor/chatEditorInput.test.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 assert from 'assert'; +import { Event } from '../../../../../../../base/common/event.js'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { NullLogService } from '../../../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../../../platform/storage/common/storage.js'; +import { ChatEditorInput } from '../../../../browser/widgetHosts/editor/chatEditorInput.js'; +import { IChatService, IChatSessionStartOptions } from '../../../../common/chatService/chatService.js'; +import { IChatSessionsService, localChatSessionType } from '../../../../common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../../../common/constants.js'; +import { IChatModel } from '../../../../common/model/chatModel.js'; +import { LocalChatSessionUri } from '../../../../common/model/chatUri.js'; + +suite('ChatEditorInput', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('explicit local session type starts local session for generic editor URI', async () => { + const sessionResource = LocalChatSessionUri.forSession('explicit-local'); + const model = { + onDidDispose: Event.None, + onDidChange: Event.None, + sessionResource, + } as Partial<IChatModel> as IChatModel; + + let startCall: { location: ChatAgentLocation; options: IChatSessionStartOptions | undefined } | undefined; + let didTryDefaultLoad = false; + const chatService = { + startNewLocalSession(location: ChatAgentLocation, options?: IChatSessionStartOptions) { + startCall = { location, options }; + return { object: model, dispose: () => { } }; + }, + async acquireOrLoadSession() { + didTryDefaultLoad = true; + return undefined; + }, + } as Partial<IChatService> as IChatService; + + const input = new ChatEditorInput( + ChatEditorInput.getNewEditorUri(), + { explicitSessionType: localChatSessionType }, + chatService, + {} as IDialogService, + {} as IConfigurationService, + {} as IChatSessionsService, + {} as IInstantiationService, + {} as IStorageService, + new NullLogService(), + ); + + try { + const resolved = await input.resolve(); + + assert.deepStrictEqual({ + model: resolved?.model, + sessionResource: input.sessionResource, + startLocation: startCall?.location, + debugOwner: startCall?.options?.debugOwner, + didTryDefaultLoad, + }, { + model, + sessionResource, + startLocation: ChatAgentLocation.Chat, + debugOwner: 'ChatEditorInput#resolveExplicitLocal', + didTryDefaultLoad: false, + }); + } finally { + input.dispose(); + } + }); + + test('explicit local session type preserves empty local session resource', async () => { + const sessionResource = LocalChatSessionUri.forSession('explicit-empty-local'); + const model = { + hasRequests: false, + onDidDispose: Event.None, + onDidChange: Event.None, + sessionResource, + } as Partial<IChatModel> as IChatModel; + + const loadedResources: string[] = []; + const chatService = { + async acquireOrLoadSession(resource: URI) { + loadedResources.push(resource.toString()); + return { object: model, dispose: () => { } }; + }, + startNewLocalSession() { + throw new Error('Should not create a new local session when the local session resource resolves'); + }, + } as Partial<IChatService> as IChatService; + + const input = new ChatEditorInput( + sessionResource, + { explicitSessionType: localChatSessionType }, + chatService, + {} as IDialogService, + {} as IConfigurationService, + {} as IChatSessionsService, + {} as IInstantiationService, + {} as IStorageService, + new NullLogService(), + ); + + try { + const resolved = await input.resolve(); + + assert.deepStrictEqual({ + model: resolved?.model, + sessionResource: input.sessionResource, + loadedResources, + }, { + model, + sessionResource, + loadedResources: [sessionResource.toString()], + }); + } finally { + input.dispose(); + } + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageDetails.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageDetails.test.ts new file mode 100644 index 00000000000000..b6f38bd0e93920 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageDetails.test.ts @@ -0,0 +1,49 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { observableValue } from '../../../../../../../base/common/observable.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { ChatContextUsageDetails, IChatContextUsageData } from '../../../../browser/widgetHosts/viewPane/chatContextUsageDetails.js'; + +suite('ChatContextUsageDetails', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function data(overrides: Partial<IChatContextUsageData> = {}): IChatContextUsageData { + return { + usedTokens: 50_000, + completionTokens: 4_000, + totalContextWindow: 100_000, + percentage: 50, + ...overrides, + }; + } + + function tokenText(details: ChatContextUsageDetails): string | null | undefined { + return details.domNode.querySelector('.quota-value')?.textContent; + } + + function createDetails(initial: IChatContextUsageData | undefined) { + const instaService = workbenchInstantiationService(undefined, disposables); + const observable = observableValue<IChatContextUsageData | undefined>('test', initial); + const details = disposables.add(instaService.createInstance(ChatContextUsageDetails, undefined, observable)); + return { details, observable }; + } + + test('renders the initial data on construction', () => { + const { details } = createDetails(data({ percentage: 42 })); + assert.strictEqual(tokenText(details), '42%'); + }); + + test('re-renders in place when the observable changes (open-popover refresh)', () => { + const { details, observable } = createDetails(data({ percentage: 80 })); + assert.strictEqual(tokenText(details), '80%'); + + // Simulate a mid-session change (e.g. after /compact) while the popover instance stays alive. + observable.set(data({ percentage: 15 }), undefined); + assert.strictEqual(tokenText(details), '15%'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageWidget.test.ts index e28165a36eb4f3..3efde02fa05391 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widgetHosts/viewPane/chatContextUsageWidget.test.ts @@ -12,7 +12,8 @@ import { IHoverService } from '../../../../../../../platform/hover/browser/hover import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; import { MockContextKeyService } from '../../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { InMemoryStorageService } from '../../../../../../../platform/storage/common/storage.js'; -import { ChatContextUsageWidget, resolveContextWindowInputTokens } from '../../../../browser/widgetHosts/viewPane/chatContextUsageWidget.js'; +import { ChatContextUsageWidget, isSameContextUsageData, resolveContextWindowInputTokens } from '../../../../browser/widgetHosts/viewPane/chatContextUsageWidget.js'; +import { IChatContextUsageData } from '../../../../browser/widgetHosts/viewPane/chatContextUsageDetails.js'; import { IChatUsage } from '../../../../common/chatService/chatService.js'; import { ILanguageModelChatMetadata, ILanguageModelConfigurationSchema, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatRequestModel, IChatResponseModel } from '../../../../common/model/chatModel.js'; @@ -83,6 +84,36 @@ suite('resolveContextWindowInputTokens', () => { }); }); +suite('isSameContextUsageData', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const base: IChatContextUsageData = { + usedTokens: 50_000, + completionTokens: 4_000, + totalContextWindow: 100_000, + percentage: 50, + outputBufferPercentage: 8, + sessionCost: 2, + promptTokenDetails: [{ category: 'files', label: 'a', percentageOfPrompt: 40 }], + }; + + test('treats fresh objects with identical fields as equal (avoids redundant popover repaints)', () => { + assert.strictEqual(isSameContextUsageData(base, { ...base, promptTokenDetails: [{ ...base.promptTokenDetails![0] }] }), true); + }); + + test('detects changes in any displayed field, including the token-details breakdown', () => { + assert.strictEqual(isSameContextUsageData(base, { ...base, percentage: 15 }), false); + assert.strictEqual(isSameContextUsageData(base, { ...base, sessionCost: 3 }), false); + assert.strictEqual(isSameContextUsageData(base, { ...base, promptTokenDetails: [{ category: 'files', label: 'a', percentageOfPrompt: 41 }] }), false); + }); + + test('handles undefined on either side', () => { + assert.strictEqual(isSameContextUsageData(undefined, undefined), true); + assert.strictEqual(isSameContextUsageData(base, undefined), false); + assert.strictEqual(isSameContextUsageData(undefined, base), false); + }); +}); + suite('ChatContextUsageWidget', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/workbench/contrib/chat/test/common/automations/schedule.test.ts b/src/vs/workbench/contrib/chat/test/common/automations/schedule.test.ts new file mode 100644 index 00000000000000..da95539df4ed1f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/automations/schedule.test.ts @@ -0,0 +1,134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAutomationSchedule } from '../../../common/automations/automation.js'; +import { computeNextRunAt } from '../../../common/automations/schedule.js'; + +/** + * Builds a local-time `Date` from year/month/day/hour/minute. Used so test + * fixtures read as wall-clock instants regardless of the host timezone. + */ +function localDate(year: number, month: number, day: number, hour = 0, minute = 0): Date { + return new Date(year, month - 1, day, hour, minute, 0, 0); +} + +suite('Automations - computeNextRunAt', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('manual schedules never produce a next-run', () => { + const schedule: IAutomationSchedule = { interval: 'manual', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }; + assert.strictEqual(computeNextRunAt(schedule, localDate(2026, 6, 1, 12, 0)), undefined); + }); + + test('hourly schedules return now + 1h regardless of wall-clock fields', () => { + const schedule: IAutomationSchedule = { interval: 'hourly', scheduleHour: 0, scheduleMinute: 0, scheduleDay: 0 }; + const now = localDate(2026, 6, 1, 12, 30); + const next = computeNextRunAt(schedule, now); + assert.ok(next); + assert.strictEqual(next.getTime() - now.getTime(), 60 * 60 * 1000); + }); + + test('daily: when target time is later today, returns today at that time', () => { + const schedule: IAutomationSchedule = { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }; + const now = localDate(2026, 6, 1, 8, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 1, 9, 0)); + }); + + test('daily: when target time has passed, returns tomorrow at that time', () => { + const schedule: IAutomationSchedule = { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }; + const now = localDate(2026, 6, 1, 10, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 2, 9, 0)); + }); + + test('daily: when target equals now, returns tomorrow (strict >)', () => { + const schedule: IAutomationSchedule = { interval: 'daily', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 }; + const now = localDate(2026, 6, 1, 9, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 2, 9, 0)); + }); + + test('daily: rolls into next month at month boundary', () => { + const schedule: IAutomationSchedule = { interval: 'daily', scheduleHour: 9, scheduleMinute: 30, scheduleDay: 0 }; + const now = localDate(2026, 6, 30, 23, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 7, 1, 9, 30)); + }); + + test('weekly: same weekday and time in the future returns today', () => { + // 2026-06-01 is a Monday (getDay() === 1) + const schedule: IAutomationSchedule = { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 1 }; + const now = localDate(2026, 6, 1, 7, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 1, 9, 0)); + }); + + test('weekly: same weekday but time already passed returns next week', () => { + const schedule: IAutomationSchedule = { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 1 }; + const now = localDate(2026, 6, 1, 10, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 8, 9, 0)); + }); + + test('weekly: future weekday this week', () => { + // Now = Mon (1). Target = Fri (5). + const schedule: IAutomationSchedule = { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 5 }; + const now = localDate(2026, 6, 1, 7, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 5, 9, 0)); + }); + + test('weekly: past weekday this week wraps to next week', () => { + // Now = Thu (4). Target = Mon (1). + const schedule: IAutomationSchedule = { interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 1 }; + const now = localDate(2026, 6, 4, 7, 0); + assert.deepStrictEqual(computeNextRunAt(schedule, now), localDate(2026, 6, 8, 9, 0)); + }); + + test('rejects invalid hour, minute, or day for daily/weekly', () => { + const now = localDate(2026, 6, 1, 12, 0); + assert.strictEqual(computeNextRunAt({ interval: 'daily', scheduleHour: 24, scheduleMinute: 0, scheduleDay: 0 }, now), undefined); + assert.strictEqual(computeNextRunAt({ interval: 'daily', scheduleHour: -1, scheduleMinute: 0, scheduleDay: 0 }, now), undefined); + assert.strictEqual(computeNextRunAt({ interval: 'daily', scheduleHour: 9, scheduleMinute: 60, scheduleDay: 0 }, now), undefined); + assert.strictEqual(computeNextRunAt({ interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 7 }, now), undefined); + assert.strictEqual(computeNextRunAt({ interval: 'weekly', scheduleHour: 9, scheduleMinute: 0, scheduleDay: -1 }, now), undefined); + }); + + test('hourly ignores hour/minute fields (still produces a next-run with junk values)', () => { + const now = localDate(2026, 6, 1, 12, 0); + const schedule: IAutomationSchedule = { interval: 'hourly', scheduleHour: 99, scheduleMinute: 99, scheduleDay: 99 }; + const next = computeNextRunAt(schedule, now); + assert.ok(next); + assert.strictEqual(next.getTime() - now.getTime(), 60 * 60 * 1000); + }); + + test('daily: spring-forward day is not skipped', () => { + // 2026-03-08 02:30 local is in the spring-forward gap in US timezones + // (clock jumps 02:00 → 03:00). Even outside DST timezones the test + // asserts the calendar-date semantics: starting 23:30 the day before, + // `tomorrow` must be the *next calendar day*, not the day after that. + const schedule: IAutomationSchedule = { interval: 'daily', scheduleHour: 2, scheduleMinute: 30, scheduleDay: 0 }; + const now = localDate(2026, 3, 7, 23, 30); // Saturday March 7, 23:30 + const next = computeNextRunAt(schedule, now); + assert.ok(next); + // Next run must be Sunday March 8, not Monday March 9. `localDate` + // may shift 02:30 forward into the DST gap on actual DST hosts, but + // the *date* must land on the 8th, never the 9th. + assert.strictEqual(next.getDate(), 8, `expected next.getDate()===8, got ${next.toString()}`); + assert.strictEqual(next.getMonth(), 2); + }); + + test('weekly: spring-forward across the boundary lands on the correct calendar day', () => { + // Weekly on Sunday 02:30, now = Saturday March 7 23:30. + const schedule: IAutomationSchedule = { interval: 'weekly', scheduleHour: 2, scheduleMinute: 30, scheduleDay: 0 }; + const now = localDate(2026, 3, 7, 23, 30); + const next = computeNextRunAt(schedule, now); + assert.ok(next); + assert.strictEqual(next.getDate(), 8); + assert.strictEqual(next.getDay(), 0); // Sunday + }); + + test('rejects unknown intervals', () => { + const now = localDate(2026, 6, 1, 12, 0); + const schedule = { interval: 'bogus', scheduleHour: 9, scheduleMinute: 0, scheduleDay: 0 } as unknown as IAutomationSchedule; + assert.strictEqual(computeNextRunAt(schedule, now), undefined); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts index 68b1ea09359ba3..32f8ac69d34610 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatEntitlementService.test.ts @@ -143,6 +143,7 @@ suite('parseQuotas', () => { resetAt: undefined, entitlement: 0, quotaRemaining: undefined, + creditsUsed: undefined, }, completions: { percentRemaining: 100, @@ -152,6 +153,7 @@ suite('parseQuotas', () => { resetAt: undefined, entitlement: 0, quotaRemaining: undefined, + creditsUsed: undefined, }, premiumChat: { percentRemaining: 97.4, @@ -161,6 +163,7 @@ suite('parseQuotas', () => { resetAt: undefined, entitlement: 3900, quotaRemaining: undefined, + creditsUsed: undefined, }, additionalUsageEnabled: true, additionalUsageCount: 0, @@ -411,6 +414,7 @@ suite('parseQuotas', () => { overage_count: 0, overage_entitlement: 0, overage_permitted: false, + credits_used: 499, percent_remaining: 7.5, unlimited: false, entitlement: '20000', @@ -422,6 +426,7 @@ suite('parseQuotas', () => { const quotas = parseQuotas(data); assert.strictEqual(quotas.premiumChat?.quotaRemaining, 1501); assert.strictEqual(quotas.premiumChat?.entitlement, 20000); + assert.strictEqual(quotas.premiumChat?.creditsUsed, 499); }); test('quotaRemaining is undefined when not present in snapshot', () => { 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 1d6f4dc549e4d9..0b5eb6bdd9052d 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 @@ -46,11 +46,11 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { IChatDebugService } from '../../../common/chatDebugService.js'; import { ChatDebugServiceImpl } from '../../../common/chatDebugServiceImpl.js'; import { ChatRequestQueueKind, ChatSendResult, IChatFollowup, IChatModelReference, IChatProgress, IChatService, ResponseModelState } from '../../../common/chatService/chatService.js'; -import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; +import { backfillRestoredPickerState, ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ChatEditingSessionState, IChatEditingService, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../common/languageModels.js'; -import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { ChatModel, IChatModel, IChatRequestVariableData, ISerializableChatData, ISerializableChatModelInputState } from '../../../common/model/chatModel.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; @@ -2319,6 +2319,44 @@ suite('ChatService', () => { }); }); +suite('backfillRestoredPickerState', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const AGENT = 'agent'; + const model = (identifier: string): ISerializableChatModelInputState['selectedModel'] => ({ + identifier, + metadata: { + id: identifier, name: identifier, vendor: 'copilot', version: '1.0', family: 'test', + extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, + isDefaultForLocation: {} + } + }); + const state = (modeId: string, selectedModel: ISerializableChatModelInputState['selectedModel']): ISerializableChatModelInputState => ({ + attachments: [], mode: { id: modeId, kind: ChatModeKind.Agent }, selectedModel, inputText: '', selections: [], contrib: {} + }); + + test('backfills a model dropped at cold restore from the per-session stored selection', () => { + const result = backfillRestoredPickerState(state(AGENT, undefined), state(AGENT, model('agent-host-claude:opus')), AGENT); + assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-claude:opus'); + }); + + test('keeps the chosen model when present (never overrides it with the stored one)', () => { + const result = backfillRestoredPickerState(state(AGENT, model('agent-host-claude:opus')), state(AGENT, model('agent-host-claude:haiku')), AGENT); + assert.strictEqual(result?.selectedModel?.identifier, 'agent-host-claude:opus'); + }); + + test('promotes a stored custom agent over the default Agent only, never over an explicit mode', () => { + assert.strictEqual(backfillRestoredPickerState(state(AGENT, undefined), state('custom-uri', undefined), AGENT)?.mode.id, 'custom-uri', 'default Agent → stored custom agent'); + assert.strictEqual(backfillRestoredPickerState(state('other-uri', undefined), state('custom-uri', undefined), AGENT)?.mode.id, 'other-uri', 'explicit mode is not overridden'); + assert.strictEqual(backfillRestoredPickerState(state(AGENT, undefined), state(AGENT, undefined), AGENT)?.mode.id, AGENT, 'stored default Agent leaves chosen Agent'); + }); + + test('returns the chosen state unchanged when there is no stored state', () => { + const chosen = state(AGENT, undefined); + assert.strictEqual(backfillRestoredPickerState(chosen, undefined, AGENT), chosen); + }); +}); + function toSnapshotExportData(model: IChatModel) { const exp = model.toExport(); diff --git a/src/vs/workbench/contrib/chat/test/common/constants.test.ts b/src/vs/workbench/contrib/chat/test/common/constants.test.ts index d2f6a701f2c887..5984b2c014c056 100644 --- a/src/vs/workbench/contrib/chat/test/common/constants.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/constants.test.ts @@ -6,13 +6,15 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; -import { ChatConfiguration, getDefaultNewChatSessionType, getNewChatEditorSessionType, isVisibleEditorChatSessionType } from '../../common/constants.js'; +import { ChatConfiguration, getComputedDefaultSessionType, getDefaultNewChatSessionType, isRememberedSessionTypeUsable, isVisibleEditorChatSessionType, recordUserSelectedSessionType } from '../../common/constants.js'; import { localChatSessionType, SessionType, IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { MockChatSessionsService } from './mockChatSessionsService.js'; +import { TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { getRememberedSessionType } from '../../common/chatSessionTypePreference.js'; suite('ChatConfiguration defaults', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); function createChatSessionsService(...types: string[]): MockChatSessionsService { const service = new MockChatSessionsService(); @@ -28,9 +30,17 @@ suite('ChatConfiguration defaults', () => { test('editor default returns local when local is enabled', () => { const configurationService = new TestConfigurationService(); const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); - - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), localChatSessionType); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), true); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: localChatSessionType, + rememberedAware: localChatSessionType, + localVisible: true, + }); }); test('editor default returns agent host Copilot when local is disabled and copilotAh is configured', () => { @@ -39,9 +49,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', }); const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); - - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), false); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + localVisible: false, + }); }); test('editor default keeps configured agent host Copilot before contribution registers', () => { @@ -50,9 +68,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', }); const chatSessionsService = createChatSessionsService(SessionType.CopilotCLI); - - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), false); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + localVisible: false, + }); }); test('editor default skips hidden extension host Copilot CLI', () => { @@ -62,9 +88,17 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.CopilotCliHideExtensionHostEditor]: true, }); const chatSessionsService = createChatSessionsService(SessionType.CopilotCLI, SessionType.AgentHostCopilot); - - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), SessionType.AgentHostCopilot); - assert.strictEqual(isVisibleEditorChatSessionType(SessionType.CopilotCLI, configurationService, chatSessionsService), false); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + extensionHostVisible: isVisibleEditorChatSessionType(SessionType.CopilotCLI, configurationService, chatSessionsService), + }, { + computed: SessionType.AgentHostCopilot, + rememberedAware: SessionType.AgentHostCopilot, + extensionHostVisible: false, + }); }); test('editor default keeps local as last resort when local is disabled without configured provider', () => { @@ -72,61 +106,105 @@ suite('ChatConfiguration defaults', () => { [ChatConfiguration.EditorLocalAgentEnabled]: false, }); const chatSessionsService = createChatSessionsService(); + const storageService = disposables.add(new TestStorageService()); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + localVisible: isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), + }, { + computed: localChatSessionType, + rememberedAware: localChatSessionType, + localVisible: true, + }); + }); - assert.strictEqual(getDefaultNewChatSessionType(configurationService, chatSessionsService), localChatSessionType); - assert.strictEqual(isVisibleEditorChatSessionType(localChatSessionType, configurationService, chatSessionsService), true); + test('remembered explicit selection wins for new sessions', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + }, { + computed: localChatSessionType, + remembered: SessionType.AgentHostClaude, + rememberedAware: SessionType.AgentHostClaude, + }); }); -}); -suite('getNewChatEditorSessionType (last-used agent)', () => { + test('explicit override wins over remembered selection', () => { + const configurationService = new TestConfigurationService(); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); - ensureNoDisposablesAreLeakedInTestSuite(); + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); - function createChatSessionsService(...types: string[]): MockChatSessionsService { - const service = new MockChatSessionsService(); - service.setContributions(types.map(type => ({ - type, - name: type, - displayName: type, - description: type, - } satisfies IChatSessionsExtensionPoint))); - return service; - } + assert.deepStrictEqual({ + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { explicitOverride: SessionType.AgentHostCopilot }), + }, { + remembered: SessionType.AgentHostClaude, + rememberedAware: SessionType.AgentHostCopilot, + }); + }); - test('prefers a visible non-local last-used agent when the provider is not explicitly configured', () => { + test('current session type is fallback after remembered selection', () => { const configurationService = new TestConfigurationService(); - const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); - assert.strictEqual(getNewChatEditorSessionType(configurationService, chatSessionsService, SessionType.AgentHostCopilot), SessionType.AgentHostCopilot); - }); + assert.deepStrictEqual({ + withoutRemembered: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { currentSessionType: SessionType.AgentHostCopilot }), + }, { + withoutRemembered: SessionType.AgentHostCopilot, + }); - test('ignores a last-used agent whose provider is not visible/registered', () => { - const configurationService = new TestConfigurationService(); - const chatSessionsService = createChatSessionsService(); // agent host not registered + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); - assert.strictEqual(getNewChatEditorSessionType(configurationService, chatSessionsService, SessionType.AgentHostCopilot), localChatSessionType); + assert.deepStrictEqual({ + withRemembered: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService, { currentSessionType: SessionType.AgentHostCopilot }), + }, { + withRemembered: SessionType.AgentHostClaude, + }); }); - test('an explicitly configured local provider wins over a last-used agent', () => { + test('selecting computed default clears remembered selection', () => { const configurationService = new TestConfigurationService({ - [ChatConfiguration.EditorDefaultProvider]: 'local', + [ChatConfiguration.EditorLocalAgentEnabled]: false, + [ChatConfiguration.EditorDefaultProvider]: 'copilotAh', + }); + const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot, SessionType.AgentHostClaude); + const storageService = disposables.add(new TestStorageService()); + + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostClaude); + recordUserSelectedSessionType(storageService, configurationService, chatSessionsService, SessionType.AgentHostCopilot); + + assert.deepStrictEqual({ + computed: getComputedDefaultSessionType(configurationService, chatSessionsService), + remembered: getRememberedSessionType(storageService), + rememberedAware: getDefaultNewChatSessionType(configurationService, chatSessionsService, storageService), + }, { + computed: SessionType.AgentHostCopilot, + remembered: undefined, + rememberedAware: SessionType.AgentHostCopilot, }); - const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); - - assert.strictEqual(getNewChatEditorSessionType(configurationService, chatSessionsService, SessionType.AgentHostCopilot), localChatSessionType); - }); - - test('a last-used local agent does not override the default', () => { - const configurationService = new TestConfigurationService(); - const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); - - assert.strictEqual(getNewChatEditorSessionType(configurationService, chatSessionsService, localChatSessionType), localChatSessionType); }); - test('falls back to the default when there is no last-used agent', () => { + test('remembered agent host is usable before contribution registers', () => { const configurationService = new TestConfigurationService(); - const chatSessionsService = createChatSessionsService(SessionType.AgentHostCopilot); + const chatSessionsService = createChatSessionsService(); - assert.strictEqual(getNewChatEditorSessionType(configurationService, chatSessionsService, undefined), localChatSessionType); + assert.deepStrictEqual({ + agentHost: isRememberedSessionTypeUsable(SessionType.AgentHostClaude, configurationService, chatSessionsService), + extensionContributed: isRememberedSessionTypeUsable('my-extension-agent', configurationService, chatSessionsService), + }, { + agentHost: true, + extensionContributed: false, + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index 600215bcfc63cb..59b59d2928c008 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -14,7 +14,6 @@ import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/prom import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { SessionType } from '../../common/chatSessionsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; -import { AICustomizationSources } from '../../common/aiCustomizationWorkspaceService.js'; suite('CustomizationHarnessService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -47,7 +46,6 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -73,7 +71,6 @@ suite('CustomizationHarnessService', () => { id: harnessId, label: 'Test Harness', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -100,7 +97,6 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -122,7 +118,6 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -144,7 +139,6 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -168,7 +162,6 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -190,12 +183,10 @@ suite('CustomizationHarnessService', () => { const service = createService(); const emitter = new Emitter<void>(); store.add(emitter); - const customFilter = { sources: [PromptsStorage.local, PromptsStorage.user] }; const externalDescriptor: IHarnessDescriptor = { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => customFilter, itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -205,7 +196,6 @@ suite('CustomizationHarnessService', () => { store.add(service.registerExternalHarness(externalDescriptor)); service.setActiveSession(activeSessionResource); - assert.deepStrictEqual(service.getActiveDescriptor().getStorageSourceFilter(PromptsType.agent), customFilter); }); test('external harness item provider returns items', async () => { @@ -225,7 +215,6 @@ suite('CustomizationHarnessService', () => { id: 'test-ext', label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider, }; const activeSessionResource = URI.parse('test-ext://session'); @@ -246,7 +235,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -260,7 +248,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -281,7 +268,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -294,7 +280,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -315,7 +300,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (static)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), }; const service = createService( createVSCodeHarnessDescriptor(), @@ -328,7 +312,6 @@ suite('CustomizationHarnessService', () => { id: 'cli', label: 'Copilot CLI (from API)', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [], @@ -360,7 +343,6 @@ suite('CustomizationHarnessService', () => { id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ @@ -390,7 +372,6 @@ suite('CustomizationHarnessService', () => { id: testSessionType, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.plugin] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ @@ -481,7 +462,6 @@ suite('CustomizationHarnessService', () => { id: testSessionType1, label: 'Test Extension', icon: ThemeIcon.fromId('extensions'), - getStorageSourceFilter: () => ({ sources: [AICustomizationSources.local] }), itemProvider: { onDidChange: emitter.event, provideChatSessionCustomizations: async (_sessionResource: URI, _token: CancellationToken) => [ diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c344272965e3de..76e524a7214817 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -172,6 +172,11 @@ export class MockChatSessionsService implements IChatSessionsService { return provider.provideChatInputCompletions(sessionResource, params, token); } + resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string { + const sessionType = getChatSessionType(sessionResource); + return this.contentProviders.get(sessionType)?.resolveChatResponseUri?.(sessionResource, href, kind) ?? href; + } + async getChatInputCompletionTriggerCharacters(sessionType: string): Promise<readonly string[] | undefined> { const provider = this.contentProviders.get(sessionType); if (!provider) { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index dd7d97e8844426..10408fa892e233 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -407,6 +407,20 @@ suite('Response', () => { await assertSnapshot(response.value); }); + test('system notification remains distinct from later response content', () => { + const response = store.add(new Response([])); + response.updateContent({ kind: 'systemNotification', content: new MarkdownString('Background command completed') }); + response.updateContent({ kind: 'markdownContent', content: new MarkdownString('Finished processing output.') }); + + assert.deepStrictEqual({ + kinds: response.value.map(part => part.kind), + text: response.toString(), + }, { + kinds: ['systemNotification', 'markdownContent'], + text: 'Background command completed\n\nFinished processing output.', + }); + }); + test('inline reference', async () => { const response = store.add(new Response([])); response.updateContent({ content: new MarkdownString('text before '), kind: 'markdownContent' }); diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatViewModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatViewModel.test.ts new file mode 100644 index 00000000000000..8b77609e8d3435 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/model/chatViewModel.test.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { ChatRequestQueueKind } from '../../../common/chatService/chatService.js'; +import { getStickyScrollTargetItem } from '../../../common/model/chatViewModel.js'; + +interface ITestChatViewModelItem { + readonly id: string; + readonly kind?: string; + readonly pendingKind?: ChatRequestQueueKind; +} + +suite('ChatViewModel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('sticky scroll target ignores trailing pending items', () => { + const response: ITestChatViewModelItem = { id: 'response' }; + const pendingOnly: ITestChatViewModelItem = { id: 'pending-only', pendingKind: ChatRequestQueueKind.Queued }; + const emptyItems: ITestChatViewModelItem[] = []; + + assert.deepStrictEqual([ + getStickyScrollTargetItem([ + { id: 'request' }, + response, + { id: 'pending-divider-steering', kind: 'pendingDivider' }, + { id: 'pending-steering', pendingKind: ChatRequestQueueKind.Steering }, + { id: 'pending-divider-queued', kind: 'pendingDivider' }, + { id: 'pending-queued', pendingKind: ChatRequestQueueKind.Queued }, + ])?.id, + getStickyScrollTargetItem([pendingOnly])?.id, + getStickyScrollTargetItem(emptyItems)?.id, + ], [ + 'response', + 'pending-only', + undefined, + ]); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts index 08ff4d558433ed..1bfebf8e368adf 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginEnablement.test.ts @@ -8,7 +8,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { ContributionEnablementState, IEnablementModel, isContributionEnabled } from '../../../common/enablement.js'; -import { AgentPluginCollisionEnablementModel, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins } from '../../../common/plugins/agentPluginEnablement.js'; +import { AgentPluginCollisionEnablementModel, getCanonicalAgentPluginCollisionGroups, getSortedAgentPlugins, IDiscoveredAgentPlugins, isAgentPluginBlockedByPolicy } from '../../../common/plugins/agentPluginEnablement.js'; import { AgentPluginDiscoveryPriority, IAgentPlugin } from '../../../common/plugins/agentPluginService.js'; import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; @@ -139,4 +139,40 @@ suite('AgentPlugin enablement', () => { collisionGroupCount: 0, }); }); + + suite('isAgentPluginBlockedByPolicy', () => { + const policyId = 'model-council@microsoft/vscode-team-kit'; + + function makeMarketplacePluginForPolicy(): IAgentPlugin { + const uri = URI.file('/Users/test/.vscode-insiders/agent-plugins/github.com/microsoft/vscode-team-kit/model-council'); + return makePlugin(uri, 'model-council', makeMarketplacePlugin()); + } + + test('no policy set: nothing is blocked', () => { + const plugin = makeMarketplacePluginForPolicy(); + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, undefined), false); + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, {}), false); + }); + + test('additive: a plugin the policy never mentions is not blocked', () => { + const plugin = makeMarketplacePluginForPolicy(); + // Enterprise enables a different plugin; the user's own plugin must keep working. + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, { 'workiq@copilot-plugins': true }), false); + }); + + test('a plugin explicitly enabled by policy is not blocked', () => { + const plugin = makeMarketplacePluginForPolicy(); + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, { [policyId]: true }), false); + }); + + test('deny list: a plugin explicitly disabled by policy is blocked', () => { + const plugin = makeMarketplacePluginForPolicy(); + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, { [policyId]: false }), true); + }); + + test('a plugin without a policy identity is never blocked', () => { + const plugin = makePlugin(URI.file('/Users/test/local-plugins/my-plugin'), 'my-plugin'); + assert.strictEqual(isAgentPluginBlockedByPolicy(plugin, { [policyId]: false }), false); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts index fe1cd1b055f30d..d0059bcc1bd42d 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/agentPluginFormatDetection.test.ts @@ -16,6 +16,7 @@ import { InMemoryFileSystemProvider } from '../../../../../../platform/files/com import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { McpServerType } from '../../../../../../platform/mcp/common/mcpPlatformTypes.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { testWorkspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { TestContextService } from '../../../../../test/common/workbenchTestServices.js'; @@ -983,4 +984,69 @@ suite('AgentPlugin format detection', () => { assert.ok(!config.args[1].includes('${CLAUDE_PLUGIN_ROOT}'), `Expected CLAUDE_PLUGIN_ROOT to be expanded in args, got: ${config.args[1]}`); assert.strictEqual(config.env['CLAUDE_PLUGIN_ROOT'], uri.fsPath, 'Expected CLAUDE_PLUGIN_ROOT env var to be set'); })); + + test('Copilot Plugin MCP servers expand root aliases and default cwd to plugin root', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = pluginUri('/plugins/copilot-mcp-root'); + await writeFile('/plugins/copilot-mcp-root/plugin.json', JSON.stringify({ name: 'copilot-mcp-root' })); + await writeFile('/plugins/copilot-mcp-root/.mcp.json', JSON.stringify({ + mcpServers: { + 'copilot-server': { + command: '${PLUGIN_ROOT}/bin/server', + args: ['--data', '${CLAUDE_PLUGIN_ROOT}/data'], + env: { CONFIG_DIR: '${PLUGIN_ROOT}/etc' }, + }, + 'explicit-cwd-server': { + command: 'node', + cwd: '/custom/cwd', + }, + }, + })); + + const discovery = createDiscovery(); + discovery.start(mockEnablementModel); + await discovery.setSourcesAndRefresh([uri]); + + const plugins = getDiscoveredPlugins(discovery); + assert.strictEqual(plugins.length, 1); + + await waitForState(plugins[0].mcpServerDefinitions, d => d.length === 2); + const servers = new Map(plugins[0].mcpServerDefinitions.get().map(server => [server.name, server.configuration])); + const defaultCwdConfig = servers.get('copilot-server'); + assert.strictEqual(defaultCwdConfig?.type, McpServerType.LOCAL); + if (defaultCwdConfig?.type !== McpServerType.LOCAL) { + assert.fail('Expected a local MCP server configuration'); + } + const explicitCwdConfig = servers.get('explicit-cwd-server'); + assert.strictEqual(explicitCwdConfig?.type, McpServerType.LOCAL); + if (explicitCwdConfig?.type !== McpServerType.LOCAL) { + assert.fail('Expected a local MCP server configuration'); + } + assert.deepStrictEqual({ + defaultCwd: { + command: defaultCwdConfig.command, + args: defaultCwdConfig.args, + cwd: defaultCwdConfig.cwd, + env: defaultCwdConfig.env, + }, + explicitCwd: { + command: explicitCwdConfig.command, + cwd: explicitCwdConfig.cwd, + }, + }, { + defaultCwd: { + command: `${uri.fsPath}/bin/server`, + args: ['--data', `${uri.fsPath}/data`], + cwd: uri.fsPath, + env: { + CONFIG_DIR: `${uri.fsPath}/etc`, + PLUGIN_ROOT: uri.fsPath, + CLAUDE_PLUGIN_ROOT: uri.fsPath, + }, + }, + explicitCwd: { + command: 'node', + cwd: '/custom/cwd', + }, + }); + })); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 4fb3b952cf3cf4..047f096be5c1fc 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -69,6 +69,7 @@ export class MockPromptsService implements IPromptsService { getDiscoveryInfo(_type: PromptsType, _token: CancellationToken): Promise<IPromptDiscoveryInfo> { throw new Error('Method not implemented.'); } dispose(): void { } onDidChangeInstructions: Event<void> = Event.None; + onDidChangeAgentInstructions: Event<void> = Event.None; onDidChangePromptFiles: Event<void> = Event.None; onDidChangeSkills: Event<void> = Event.None; onDidChangeHooks: Event<void> = Event.None; 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 7d3b295c90b84b..bf0f4573145ca4 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 @@ -808,6 +808,12 @@ suite('PromptsService', () => { 'Must find correct instruction files.', ); }); + + test('exposes onDidChangeAgentInstructions', async () => { + const disposable = service.onDidChangeAgentInstructions(() => { }); + await new Promise(resolve => setTimeout(resolve, 0)); + disposable.dispose(); + }); }); suite('getCustomAgents', () => { diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts index 8d6f28e663503a..426f28545d977b 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts @@ -276,6 +276,63 @@ suite('FetchWebPageTool', () => { assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'File outside workspace should require post-confirmation'); }); + test('file URI that traverses out of the workspace requires confirmation', async () => { + // Regression: a `..` traversal that escapes the workspace must not be judged as inside it. + // The membership check and the read must agree on the canonical (normalized) path. + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + // The real target, after resolving `..`, lives outside the workspace. + const fileContentMap = new ResourceMap<string | VSBuffer>([ + [URI.file('/etc/secret.txt'), 'secret content'], + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap<string>()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + new MockChatService(), + workspaceContextService, + new MockAgentNetworkFilterService(), + ); + + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: ['file:///workspaceRoot/../../etc/secret.txt'] }, toolCallId: 'test-file-traversal', chatSessionResource: undefined }, + CancellationToken.None + ); + assert.ok(preparation, 'Should return prepared invocation'); + assert.ok(preparation.confirmationMessages?.title, 'Traversal escaping the workspace should show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'Traversal escaping the workspace should require post-confirmation'); + }); + + test('file URI with `..` that stays inside the workspace still skips confirmation', async () => { + // Normalization must not over-block: an in-workspace path that happens to contain `..` + // resolves back inside the workspace and should not prompt. + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const fileContentMap = new ResourceMap<string | VSBuffer>([ + [URI.file('/workspaceRoot/plan.md'), 'Plan content'], + ]); + + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap<string>()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + new MockChatService(), + workspaceContextService, + new MockAgentNetworkFilterService(), + ); + + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: ['file:///workspaceRoot/subdir/../plan.md'] }, toolCallId: 'test-file-inside-traversal', chatSessionResource: undefined }, + CancellationToken.None + ); + assert.ok(preparation, 'Should return prepared invocation'); + assert.strictEqual(preparation.confirmationMessages?.title, undefined, 'In-workspace file (after normalization) should not show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, false, 'In-workspace file should not require post-confirmation'); + }); + test('workspace file mixed with untrusted web URI: only web URI triggers confirmation', async () => { const workspaceRoot = URI.file('/workspaceRoot'); const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); @@ -361,6 +418,122 @@ suite('FetchWebPageTool', () => { assert.ok(preparation2.confirmationMessages?.title); }); + test('should require confirmation for a file URI embedded inside a pasted web URL', async () => { + const fileContentMap = new ResourceMap<string | VSBuffer>([ + [URI.parse('file:///home/victim/.ssh/id_rsa'), 'secret key'] + ]); + + // The user only ever pasted a web URL that happens to contain the file URI as a + // query-parameter value. It must NOT be treated as an explicit request for the file, + // so the confirmation dialog must still be shown. + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap<string>()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService(), + upcastDeepPartial<IChatService>({ + getSession: () => { + return { + getRequests: () => [{ + message: { + text: 'fetch https://attacker.example/p.html?u=file:///home/victim/.ssh/id_rsa' + } + }], + }; + }, + }), + new TestContextService(), + new MockAgentNetworkFilterService(), + ); + + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: ['file:///home/victim/.ssh/id_rsa'] }, toolCallId: 'test-call-injection', chatSessionResource: LocalChatSessionUri.forSession('a') }, + CancellationToken.None + ); + + assert.ok(preparation, 'Should return prepared invocation'); + assert.ok(preparation.confirmationMessages?.title, 'Embedded file URI should still show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'Embedded file URI should still require post-confirmation'); + }); + + test('should auto-approve a standalone out-of-workspace file URI the user pasted', async () => { + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const fileContentMap = new ResourceMap<string | VSBuffer>([ + [URI.file('/tmp/external-plan.md'), 'External plan content'] + ]); + + // The user explicitly referenced the file URI as its own token, so it should be + // treated as user-approved even though it lives outside the workspace. + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap<string>()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + upcastDeepPartial<IChatService>({ + getSession: () => { + return { + getRequests: () => [{ + message: { + text: 'please fetch (file:///tmp/external-plan.md) for me' + } + }], + }; + }, + }), + workspaceContextService, + new MockAgentNetworkFilterService(), + ); + + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: [URI.file('/tmp/external-plan.md').toString()] }, toolCallId: 'test-call-standalone-file', chatSessionResource: LocalChatSessionUri.forSession('a') }, + CancellationToken.None + ); + + assert.ok(preparation, 'Should return prepared invocation'); + assert.strictEqual(preparation.confirmationMessages?.title, undefined, 'Explicitly referenced file URI should not show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, false, 'Explicitly referenced file URI should not require post-confirmation'); + }); + + test('should require confirmation when a prior message only mentions a bare (scheme-less) path', async () => { + const workspaceRoot = URI.file('/workspaceRoot'); + const workspaceContextService = new TestContextService(testWorkspace(workspaceRoot)); + + const fileContentMap = new ResourceMap<string | VSBuffer>([ + [URI.file('/etc/secret.txt'), 'secret content'] + ]); + + // The user only ever typed a bare filesystem path (no `file://` scheme). It must not be + // treated as a referenced resource — a scheme-less token must not default to a file URI + // and auto-approve a matching read. + const tool = new FetchWebPageTool( + new TestWebContentExtractorService(new ResourceMap<string>()), + new ExtendedTestFileService(fileContentMap), + new MockTrustedDomainService([]), + upcastDeepPartial<IChatService>({ + getSession: () => { + return { + getRequests: () => [{ + message: { + text: 'the config lives at /etc/secret.txt on the box' + } + }], + }; + }, + }), + workspaceContextService, + new MockAgentNetworkFilterService(), + ); + + const preparation = await tool.prepareToolInvocation( + { parameters: { urls: ['file:///etc/secret.txt'] }, toolCallId: 'test-call-bare-path', chatSessionResource: LocalChatSessionUri.forSession('a') }, + CancellationToken.None + ); + + assert.ok(preparation, 'Should return prepared invocation'); + assert.ok(preparation.confirmationMessages?.title, 'Bare path mention should still show confirmation dialog'); + assert.strictEqual(preparation.confirmationMessages?.confirmResults, true, 'Bare path mention should still require post-confirmation'); + }); + test('should return message for binary files indicating they are not supported', async () => { // Create binary content (a simple PNG-like header with null bytes) const binaryContent = new Uint8Array([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D]); diff --git a/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts b/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts index 4d49c3a3ba5e74..7a8458964de619 100644 --- a/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts +++ b/src/vs/workbench/contrib/customEditor/common/contributedCustomEditors.ts @@ -108,12 +108,12 @@ function normalizeStoredCustomEditorDescriptor(descriptor: StoredCustomEditorDes selector: descriptor.selector, priority: typeof descriptor.priority === 'string' ? { editor: descriptor.priority, - diff: descriptor.priority, - merge: descriptor.priority, + diff: RegisteredEditorPriority.never, + merge: RegisteredEditorPriority.never, } : { editor: descriptor.priority.editor, - diff: descriptor.priority.diff ?? descriptor.priority.editor, - merge: descriptor.priority.merge ?? descriptor.priority.editor, + diff: descriptor.priority.diff ?? RegisteredEditorPriority.never, + merge: descriptor.priority.merge ?? RegisteredEditorPriority.never, }, }; } @@ -123,11 +123,16 @@ function getPriorityFromContribution( extension: IExtensionDescription, includeDiffAndMergePriority: boolean, ): CustomEditorDescriptor['priority'] { + // The `textEditor` value drives the normal editor and keeps its historical `default` fallback when + // omitted. `diffEditor` and `mergeEditor` do not inherit from `textEditor`: they default to + // `never`, so a custom editor is not used for diffs or merges unless it explicitly opts in (which + // requires the `customEditorPriority` proposal). const editorPriority = getSinglePriorityFromContribution(typeof contribution === 'string' ? contribution : contribution?.textEditor, extension) ?? RegisteredEditorPriority.default; + const readObjectField = includeDiffAndMergePriority && typeof contribution !== 'string'; return { editor: editorPriority, - diff: includeDiffAndMergePriority && typeof contribution !== 'string' ? getSinglePriorityFromContribution(contribution?.diffEditor, extension) ?? editorPriority : editorPriority, - merge: includeDiffAndMergePriority && typeof contribution !== 'string' ? getSinglePriorityFromContribution(contribution?.mergeEditor, extension) ?? editorPriority : editorPriority, + diff: (readObjectField ? getSinglePriorityFromContribution(contribution?.diffEditor, extension) : undefined) ?? RegisteredEditorPriority.never, + merge: (readObjectField ? getSinglePriorityFromContribution(contribution?.mergeEditor, extension) : undefined) ?? RegisteredEditorPriority.never, }; } @@ -139,6 +144,9 @@ function getSinglePriorityFromContribution(value: CustomEditorPriority | undefin case CustomEditorPriority.option: return RegisteredEditorPriority.option; + case CustomEditorPriority.never: + return RegisteredEditorPriority.never; + case CustomEditorPriority.builtin: // Builtin is only valid for builtin extensions return extension.isBuiltin ? RegisteredEditorPriority.builtin : RegisteredEditorPriority.default; diff --git a/src/vs/workbench/contrib/customEditor/common/customEditor.ts b/src/vs/workbench/contrib/customEditor/common/customEditor.ts index bc39cb2309fcf1..0f5eacdd03903a 100644 --- a/src/vs/workbench/contrib/customEditor/common/customEditor.ts +++ b/src/vs/workbench/contrib/customEditor/common/customEditor.ts @@ -81,6 +81,7 @@ export const enum CustomEditorPriority { default = 'default', builtin = 'builtin', option = 'option', + never = 'never', } export const enum CustomEditorDiffEditorLayout { diff --git a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts index 47bf896dceb9f6..1f6e0cf391a4e6 100644 --- a/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts +++ b/src/vs/workbench/contrib/customEditor/common/extensionPoint.ts @@ -33,10 +33,12 @@ const customEditorPrioritySchema = { enum: [ CustomEditorPriority.default, CustomEditorPriority.option, + CustomEditorPriority.never, ], markdownEnumDescriptions: [ nls.localize('contributes.priority.default', 'The editor is automatically used when the user opens a resource, provided that no other default custom editors are registered for that resource.'), nls.localize('contributes.priority.option', 'The editor is not automatically used when the user opens a resource, but a user can switch to the editor using the `Reopen With` command.'), + nls.localize('contributes.priority.never', 'The editor is never automatically used, and it is also skipped when the user points a `workbench.editorAssociations` entry at it. It can still be opened via the `Reopen With` command or the specialized `workbench.diffEditorAssociations` setting.'), ], } as const satisfies IJSONSchema; @@ -77,7 +79,7 @@ const customEditorsContributionSchema = { } }, [Fields.priority]: { - markdownDescription: nls.localize('contributes.priority', 'Controls if the custom editor is enabled automatically when the user opens a file, diff, or merge editor. This may be overridden by users using the `workbench.editorAssociations` or `workbench.diffEditorAssociations` setting.'), + markdownDescription: nls.localize('contributes.priority', 'Controls if the custom editor is enabled automatically when the user opens a file, diff, or merge editor. This may be overridden by users using the `workbench.editorAssociations` or `workbench.diffEditorAssociations` setting. When omitted, the custom editor defaults to `default` for the normal editor and `never` for diff and merge editors, so it is not used for diffs or merges unless it opts in.'), anyOf: [ customEditorPrioritySchema, { @@ -87,15 +89,15 @@ const customEditorsContributionSchema = { properties: { [PriorityFields.textEditor]: { ...customEditorPrioritySchema, - markdownDescription: nls.localize('contributes.priority.textEditor', 'Controls if the custom editor is enabled automatically when the user opens a file. This is the base value: when `diffEditor` or `mergeEditor` are not specified, they fall back to this value.'), + markdownDescription: nls.localize('contributes.priority.textEditor', 'Controls if the custom editor is enabled automatically when the user opens a file. `diffEditor` and `mergeEditor` do not inherit this value; when they are not specified they default to `never`.'), }, [PriorityFields.diffEditor]: { ...customEditorPrioritySchema, - markdownDescription: nls.localize('contributes.priority.diffEditor', 'Controls if the custom editor is enabled automatically when the user opens a diff. When not specified, the value of `textEditor` is used.'), + markdownDescription: nls.localize('contributes.priority.diffEditor', 'Controls if the custom editor is enabled automatically when the user opens a diff. When not specified this defaults to `never`, so the custom editor is not used for diffs unless it opts in.'), }, [PriorityFields.mergeEditor]: { ...customEditorPrioritySchema, - markdownDescription: nls.localize('contributes.priority.mergeEditor', 'Controls if the custom editor is enabled automatically when the user opens a merge editor. When not specified, the value of `textEditor` is used.'), + markdownDescription: nls.localize('contributes.priority.mergeEditor', 'Controls if the custom editor is enabled automatically when the user opens a merge editor. When not specified this defaults to `never`, so the custom editor is not used for merges unless it opts in.'), }, } } diff --git a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts index 25aa89b6c6faa6..0c0d408619575b 100644 --- a/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts +++ b/src/vs/workbench/contrib/debug/browser/exceptionWidget.ts @@ -21,6 +21,8 @@ import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { Action } from '../../../../base/common/actions.js'; import { widgetClose } from '../../../../platform/theme/common/iconRegistry.js'; import { Range } from '../../../../editor/common/core/range.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; const $ = dom.$; @@ -39,7 +41,8 @@ export class ExceptionWidget extends ZoneWidget { private debugSession: IDebugSession | undefined, private readonly shouldScroll: () => boolean, @IThemeService themeService: IThemeService, - @IInstantiationService private readonly instantiationService: IInstantiationService + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IClipboardService private readonly clipboardService: IClipboardService ) { super(editor, { showFrame: true, showArrow: true, isAccessible: true, frameWidth: 1, className: 'exception-widget-container' }); @@ -84,6 +87,10 @@ export class ExceptionWidget extends ZoneWidget { let ariaLabel = label.textContent; const actionBar = this._disposables.add(new ActionBar(actions)); + actionBar.push(new Action('editor.copyExceptionInfo', nls.localize('copy', "Copy"), ThemeIcon.asClassName(Codicon.copy), true, async () => { + const clipboardText = this.buildExceptionText(); + await this.clipboardService.writeText(clipboardText); + }), { label: false, icon: true }); actionBar.push(new Action('editor.closeExceptionWidget', nls.localize('close', "Close"), ThemeIcon.asClassName(widgetClose), true, async () => { const contribution = this.editor.getContribution<IDebugEditorContribution>(EDITOR_CONTRIBUTION_ID); contribution?.closeExceptionWidget(); @@ -132,6 +139,22 @@ export class ExceptionWidget extends ZoneWidget { } } + private buildExceptionText(): string { + const parts: string[] = []; + if (this.exceptionInfo.id) { + parts.push(nls.localize('exceptionThrownWithId', 'Exception has occurred: {0}', this.exceptionInfo.id)); + } else { + parts.push(nls.localize('exceptionThrown', 'Exception has occurred.')); + } + if (this.exceptionInfo.description) { + parts.push(this.exceptionInfo.description); + } + if (this.exceptionInfo.details?.stackTrace) { + parts.push(this.exceptionInfo.details.stackTrace); + } + return parts.join('\n'); + } + focus(): void { // Focus into the container for accessibility purposes so the exception and stack trace gets read this.container?.focus(); diff --git a/src/vs/workbench/contrib/extensions/browser/media/extension.css b/src/vs/workbench/contrib/extensions/browser/media/extension.css index 8454447913129d..3993f3b68b9749 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extension.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extension.css @@ -15,7 +15,7 @@ box-sizing: border-box; width: 100%; height: 100%; - padding: 0 0 0 16px; + padding: 0 0 0 20px; overflow: hidden; display: flex; } @@ -56,10 +56,10 @@ } .extension-list-item > .details > .header-container { - height: 19px; + height: 20px; display: flex; overflow: hidden; - padding-right: 11px; + padding-right: 10px; } .extension-list-item > .details > .header-container > .header { @@ -72,7 +72,7 @@ } .extension-list-item > .details > .header-container > .header > .name { - font-weight: bold; + font-weight: var(--vscode-agents-fontWeight-semiBold); white-space: nowrap; text-overflow: ellipsis; overflow: hidden; @@ -127,11 +127,6 @@ margin-left: 2px; } -.extension-list-item > .details > .header-container > .header > .activation-status .activationTime, -.extension-list-item > .details > .header-container > .header > .activation-status:not(:empty) .codicon { - margin-right: 2px; -} - .extension-list-item > .details > .header-container > .header .codicon { margin-right: 2px; -webkit-mask: inherit; @@ -170,7 +165,7 @@ } .extension-list-item > .details > .description { - padding-right: 11px; + padding-right: 8px; line-height: normal; color: var(--vscode-descriptionForeground); } @@ -187,7 +182,8 @@ .extension-list-item > .details > .footer { display: flex; justify-content: flex-end; - padding-right: 7px; + padding-right: 2px; + padding-top: 2px; height: 24px; overflow: hidden; align-items: center; @@ -204,9 +200,9 @@ } .extension-list-item > .details > .footer .publisher > .publisher-name { - font-size: 90%; + font-size: 11px; color: var(--vscode-descriptionForeground); - font-weight: 600; + font-weight: var(--vscode-agents-fontWeight-semiBold); } .monaco-list-row.selected .extension-list-item > .details > .footer .publisher > .publisher-name{ @@ -257,10 +253,6 @@ color: unset; } -.extension-list-item .monaco-action-bar .action-label.icon { - padding: 1px 2px; -} - .hc-black .extension-list-item .monaco-action-bar .action-label.icon, .hc-light .extension-list-item .monaco-action-bar .action-label.icon { padding: 0px 2px; diff --git a/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css b/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css index 0f66f5fcc4df13..a8b7fb5012d991 100644 --- a/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css +++ b/src/vs/workbench/contrib/extensions/browser/media/extensionsWidgets.css @@ -4,16 +4,17 @@ *--------------------------------------------------------------------------------------------*/ .extension-icon .icon { - width: 42px; - height: 42px; - padding-right: 14px; + width: 36px; + height: 36px; + margin-right: 16px; + border-radius: var(--vscode-cornerRadius-xSmall, 2px); flex-shrink: 0; object-fit: contain; } .extension-icon .codicon { - padding-right: 14px; - font-size: 42px !important; + padding-right: 16px; + font-size: 36px !important; } .extension-sync-ignored.hide { @@ -29,7 +30,7 @@ } .extension-ratings > .codicon[class*='codicon-extensions-rating']:not(:first-child) { - margin-left: 3px; + margin-left: 4px; } .extension-ratings > .count { @@ -56,49 +57,43 @@ } .extension-bookmark { - display: inline-block; - height: 20px; - width: 20px; + display: flex; } .extension-bookmark > .recommendation, .extension-bookmark > .pre-release { - border-right: 20px solid transparent; - border-top: 20px solid; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-top-left-radius: var(--vscode-cornerRadius-small, 4px); + border-bottom-right-radius: var(--vscode-cornerRadius-small, 4px); box-sizing: border-box; - position: relative; } .extension-bookmark > .pre-release { - border-top-color: var(--vscode-extensionIcon-preReleaseForeground); + background-color: var(--vscode-extensionIcon-preReleaseForeground); color: #ffffff; } -.extension-bookmark > .recommendation > .codicon, -.extension-bookmark > .pre-release > .codicon { - position: absolute; - bottom: 9px; - left: 1px; - color: inherit; - font-size: 80% !important; -} - -.extension-bookmark .recommendation { - border-top-color: var(--vscode-extensionButton-prominentBackground); +.extension-bookmark > .recommendation { + background-color: var(--vscode-extensionButton-prominentBackground); color: var(--vscode-extensionButton-prominentForeground); } -.hc-black .extension-bookmark .recommendation, -.hc-light .extension-bookmark .recommendation, -.hc-black .extension-bookmark .pre-release, -.hc-light .extension-bookmark .pre-release { - border-top-color: var(--vscode-contrastBorder); - color: var(--vscode-editor-background); +.extension-bookmark > .recommendation > .codicon, +.extension-bookmark > .pre-release > .codicon { + color: inherit; + font-size: 12px !important; + line-height: 12px; } -.hc-black .extension-bookmark .recommendation .codicon, -.hc-light .extension-bookmark .recommendation .codicon, -.hc-black .extension-bookmark .pre-release .codicon, -.hc-light .extension-bookmark .pre-release .codicon { - color: var(--vscode-editor-background); +.hc-black .extension-bookmark > .recommendation, +.hc-light .extension-bookmark > .recommendation, +.hc-black .extension-bookmark > .pre-release, +.hc-light .extension-bookmark > .pre-release { + background-color: var(--vscode-editor-background); + color: var(--vscode-contrastBorder); + border: var(--vscode-strokeThickness) solid var(--vscode-contrastBorder); } diff --git a/src/vs/workbench/contrib/files/browser/explorerService.ts b/src/vs/workbench/contrib/files/browser/explorerService.ts index d1a69738fc1564..90e83d0d1ca412 100644 --- a/src/vs/workbench/contrib/files/browser/explorerService.ts +++ b/src/vs/workbench/contrib/files/browser/explorerService.ts @@ -10,7 +10,7 @@ import { IFilesConfiguration, ISortOrderConfiguration, SortOrder, LexicographicO import { ExplorerItem, ExplorerModel } from '../common/explorerModel.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationEvent, FileOperation, IFileService, FileChangesEvent, FileChangeType, IResolveFileOptions } from '../../../../platform/files/common/files.js'; -import { dirname, basename, joinPath } from '../../../../base/common/resources.js'; +import { dirname, basename } from '../../../../base/common/resources.js'; import { IConfigurationService, IConfigurationChangeEvent } from '../../../../platform/configuration/common/configuration.js'; import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; @@ -26,10 +26,6 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { IExpression } from '../../../../base/common/glob.js'; import { ResourceGlobMatcher } from '../../../common/resources.js'; import { IFilesConfigurationService } from '../../../services/filesConfiguration/common/filesConfigurationService.js'; -import { Schemas } from '../../../../base/common/network.js'; -import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; -import { generateUuid } from '../../../../base/common/uuid.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; export const UNDO_REDO_SOURCE = new UndoRedoSource(); @@ -47,7 +43,6 @@ export class ExplorerService implements IExplorerService { private onFileChangesScheduler: RunOnceScheduler; private fileChangeEvents: FileChangesEvent[] = []; private revealExcludeMatcher: ResourceGlobMatcher; - private remoteClipboardTempDir: URI | undefined; constructor( @IFileService private fileService: IFileService, @@ -59,9 +54,7 @@ export class ExplorerService implements IExplorerService { @IBulkEditService private readonly bulkEditService: IBulkEditService, @IProgressService private readonly progressService: IProgressService, @IHostService hostService: IHostService, - @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService, - @IEnvironmentService private readonly environmentService: IEnvironmentService, - @ILogService private readonly logService: ILogService, + @IFilesConfigurationService private readonly filesConfigurationService: IFilesConfigurationService ) { this.config = this.configurationService.getValue('explorer'); @@ -266,66 +259,11 @@ export class ExplorerService implements IExplorerService { async setToCopy(items: ExplorerItem[], cut: boolean): Promise<void> { const previouslyCutItems = this.cutItems; this.cutItems = cut ? items : undefined; - - const resources = items.map(s => s.resource); - - // For remote resources, download to a temp location so they can be - // pasted both in other VS Code windows and into native file managers. - // We replace remote URIs with local file:// URIs pointing to temp copies. - const clipboardResources = await this.resolveClipboardResources(resources); - - await this.clipboardService.writeResources(clipboardResources); + await this.clipboardService.writeResources(items.map(s => s.resource)); this.view?.itemsCopied(items, cut, previouslyCutItems); } - /** - * Returns `file://` URIs for all resources. Local files pass through - * unchanged. Remote files are downloaded to a temp directory and their - * temp `file://` URIs are returned instead. - */ - private async resolveClipboardResources(resources: URI[]): Promise<URI[]> { - const result: URI[] = []; - const remoteResources = resources.filter(r => r.scheme !== Schemas.file); - - // Local files: use as-is - for (const resource of resources) { - if (resource.scheme === Schemas.file) { - result.push(resource); - } - } - - // Remote files: download to temp - if (remoteResources.length > 0) { - try { - // Clean up previous temp dir before creating a new one - await this.cleanupRemoteClipboardTempDir(); - - const tempDir = joinPath(this.environmentService.cacheHome, 'remote-clipboard', generateUuid()); - await this.fileService.createFolder(tempDir); - this.remoteClipboardTempDir = tempDir; - - for (const resource of remoteResources) { - // Place each file in its own unique subfolder to avoid - // name collisions (e.g. two different folders both have index.ts) - const uniqueDir = joinPath(tempDir, generateUuid()); - await this.fileService.createFolder(uniqueDir); - const target = joinPath(uniqueDir, basename(resource)); - await this.fileService.copy(resource, target, true); - result.push(target); - } - } catch (error) { - // If download fails, fall back to the original remote URIs. - // VS Code cross-window paste will still work via the proxy - // provider, but native paste will not. - this.logService.warn('Failed to download remote files for clipboard', error); - result.push(...remoteResources); - } - } - - return result; - } - isCut(item: ExplorerItem): boolean { return !!this.cutItems && this.cutItems.some(i => this.uriIdentityService.extUri.isEqual(i.resource, item.resource)); } @@ -565,20 +503,8 @@ export class ExplorerService implements IExplorerService { } dispose(): void { - this.cleanupRemoteClipboardTempDir(); this.disposables.dispose(); } - - private async cleanupRemoteClipboardTempDir(): Promise<void> { - if (this.remoteClipboardTempDir) { - try { - await this.fileService.del(this.remoteClipboardTempDir, { recursive: true }); - } catch { - // Best-effort cleanup - } - this.remoteClipboardTempDir = undefined; - } - } } function doesFileEventAffect(item: ExplorerItem, view: IExplorerView, events: FileChangesEvent[], types: FileChangeType[]): boolean { diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index cf0d606f73f61d..2b13d92751e48d 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -1132,7 +1132,7 @@ export const pasteFileHandler = async (accessor: ServicesAccessor, fileList?: Fi const hostService = accessor.get(IHostService); const context = explorerService.getContext(false); - const hasNativeFilesToPaste = fileList !== undefined && fileList.length > 0; + const hasNativeFilesToPaste = fileList && fileList.length > 0; const confirmPasteNative = hasNativeFilesToPaste && configurationService.getValue<boolean>('explorer.confirmPasteNative'); const toPaste = await getFilesToPaste(fileList, clipboardService, hostService); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index 2ae470eb398e35..0e488c01f49b3f 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -333,15 +333,12 @@ export class ExplorerView extends ViewPane implements IExplorerView { } })); - // Native file paste: only handle when clipboardService has no resources (avoids double-paste from keybinding path) + // Support for paste of files into explorer this._register(DOM.addDisposableListener(DOM.getWindow(this.container), DOM.EventType.PASTE, async event => { if (!this.hasFocus() || this.readonlyContext.get()) { return; } if (event.clipboardData?.files?.length) { - if (await this.clipboardService.hasResources()) { - return; - } await this.commandService.executeCommand('filesExplorer.paste', event.clipboardData?.files); } })); diff --git a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts index 2d3e94bd8e6121..e860b8cdf5adb6 100644 --- a/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts +++ b/src/vs/workbench/contrib/imageCarousel/browser/imageCarousel.contribution.ts @@ -47,7 +47,6 @@ Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration).regis type: 'boolean', default: true, description: localize('imageCarousel.chat.enabled', "Controls whether clicking an image attachment in chat opens the Images Preview viewer."), - tags: ['experimental'], }, } }); diff --git a/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts b/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts index 08ba8050337db0..cfea1a4f8d1723 100644 --- a/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts +++ b/src/vs/workbench/contrib/issue/electron-browser/nativeIssueFormService.ts @@ -75,11 +75,11 @@ export class NativeIssueFormService extends IssueFormService implements IIssueFo // the editor pane, so it does not depend on arch/release/type here. const input = this.instantiationService.createInstance(IssueReporterEditorInput, data); - // In the Agents window, editors open in a floating modal editor part by - // default (`workbench.editor.useModal: 'all'`). The issue reporter needs to - // sit alongside the rest of the app so the user can capture screenshots and - // recordings, so target the main editor part's active group explicitly to - // open it docked in the editor area instead of as a modal overlay. + // When editors are forced modal (`workbench.editor.useModal: 'all'`), the + // issue reporter still needs to sit alongside the rest of the app so the + // user can capture screenshots and recordings. In the Agents window, target + // the main editor part's active group explicitly to open it docked in the + // editor area instead of as a modal overlay. const preferredGroup = data.isSessionsWindow ? this.editorGroupService.mainPart.activeGroup : undefined; await this.editorService.openEditor(input, { pinned: true }, preferredGroup); } diff --git a/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts new file mode 100644 index 00000000000000..76f65c731a7c00 --- /dev/null +++ b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts @@ -0,0 +1,205 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as nls from '../../../../nls.js'; +import { RunOnceScheduler } from '../../../../base/common/async.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { equals } from '../../../../base/common/arrays.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ResolvedKeybindingItem } from '../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INativeHostService, INativeSystemWideKeybinding } from '../../../../platform/native/common/native.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; + +export interface ISystemWideKeybindingCandidate { + readonly accelerator: string; + readonly commandId: string; + readonly args: unknown; + readonly userSettingsLabel: string; + readonly hasWhen: boolean; +} + +export interface ISystemWideKeybindingSelection { + readonly candidates: ISystemWideKeybindingCandidate[]; + /** User settings labels (or command ids) that cannot be expressed as an Electron accelerator (chords, single modifiers). */ + readonly unsupported: string[]; + /** Accelerators dropped because an earlier binding already claimed them. */ + readonly duplicates: string[]; +} + +/** + * Pure selection of the system-wide keybindings from the full set of resolved keybindings. Only + * user keybindings opted into `systemWide` with a single key combination (expressible as an + * Electron accelerator) are eligible; the first binding wins on accelerator conflicts. + */ +export function selectSystemWideKeybindings(items: readonly ResolvedKeybindingItem[]): ISystemWideKeybindingSelection { + const seen = new Set<string>(); + const candidates: ISystemWideKeybindingCandidate[] = []; + const unsupported: string[] = []; + const duplicates: string[] = []; + + for (const item of items) { + // Only user keybindings can ever be system-wide; skip defaults/extension bindings and removals. + if (!item.systemWide || item.isDefault || !item.command) { + continue; + } + + const resolved = item.resolvedKeybinding; + if (!resolved) { + continue; // unbound entry (e.g. a `-` removal) + } + + const accelerator = resolved.getElectronAccelerator(); + if (!accelerator) { + // Chords and single-modifier bindings cannot be expressed as an Electron accelerator. + unsupported.push(resolved.getUserSettingsLabel() ?? item.command); + continue; + } + + if (seen.has(accelerator)) { + duplicates.push(resolved.getUserSettingsLabel() ?? accelerator); + continue; + } + seen.add(accelerator); + + candidates.push({ + accelerator, + commandId: item.command, + args: item.commandArgs ?? undefined, + userSettingsLabel: resolved.getUserSettingsLabel() ?? accelerator, + hasWhen: !!item.when, + }); + } + + return { candidates, unsupported, duplicates }; +} + +/** + * Watches the resolved keybindings for entries opted into `systemWide` and mirrors them to the + * main process (which owns Electron's `globalShortcut`). The mechanism is always active for any + * user keybinding marked `systemWide`. + */ +export class SystemWideKeybindingsContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.systemWideKeybindings'; + + private readonly syncScheduler: RunOnceScheduler; + + /** Accelerators that failed to register on the last sync, to avoid re-notifying unchanged failures. */ + private lastReportedFailures: string[] = []; + + /** User settings labels whose ignored `when` clause we already warned about. */ + private readonly warnedWhenLabels = new Set<string>(); + + constructor( + @IKeybindingService private readonly keybindingService: IKeybindingService, + @INativeHostService private readonly nativeHostService: INativeHostService, + @INotificationService private readonly notificationService: INotificationService, + @IProductService private readonly productService: IProductService, + @ILogService private readonly logService: ILogService, + ) { + super(); + + this.syncScheduler = this._register(new RunOnceScheduler(() => this.sync(), 200)); + + // Re-sync when keybindings change (also fires on keyboard-layout changes, which affect + // the accelerator strings). + this._register(this.keybindingService.onDidUpdateKeybindings(() => this.scheduleSync())); + + this.scheduleSync(); + } + + private scheduleSync(): void { + this.syncScheduler.schedule(); + } + + private async sync(): Promise<void> { + const candidates = this.collectCandidates(); + + // Nothing to register (no valid system-wide bindings): clear any previous registrations. + if (candidates.length === 0) { + await this.pushToMainProcess([]); + return; + } + + this.warnAboutIgnoredWhenClauses(candidates); + await this.pushToMainProcess(candidates); + } + + private collectCandidates(): ISystemWideKeybindingCandidate[] { + const { candidates, unsupported, duplicates } = selectSystemWideKeybindings(this.keybindingService.getKeybindings()); + + for (const label of unsupported) { + this.logService.warn(`[SystemWideKeybindings] '${label}' cannot be registered as a system-wide shortcut (only single key combinations are supported).`); + } + for (const label of duplicates) { + this.logService.warn(`[SystemWideKeybindings] duplicate system-wide accelerator for '${label}', keeping the first binding.`); + } + + return candidates; + } + + private warnAboutIgnoredWhenClauses(candidates: readonly ISystemWideKeybindingCandidate[]): void { + const newlyWarned: string[] = []; + for (const candidate of candidates) { + if (candidate.hasWhen && !this.warnedWhenLabels.has(candidate.userSettingsLabel)) { + this.warnedWhenLabels.add(candidate.userSettingsLabel); + newlyWarned.push(candidate.userSettingsLabel); + } + } + + if (newlyWarned.length > 0) { + this.notificationService.notify({ + severity: Severity.Warning, + message: nls.localize('systemWideKeybindings.whenIgnored', "The \"when\" clause is ignored for system-wide keybindings ({0}); they are always active while {1} is running.", newlyWarned.join(', '), this.productName()), + }); + } + } + + private async pushToMainProcess(candidates: readonly ISystemWideKeybindingCandidate[]): Promise<void> { + const payload: INativeSystemWideKeybinding[] = candidates.map(candidate => ({ + accelerator: candidate.accelerator, + commandId: candidate.commandId, + args: candidate.args, + userSettingsLabel: candidate.userSettingsLabel, + })); + + try { + const result = await this.nativeHostService.syncSystemWideKeybindings(payload); + this.reportFailures(result.failed); + } catch (error) { + this.logService.error('[SystemWideKeybindings] failed to sync system-wide keybindings with the main process', error); + } + } + + private reportFailures(failed: string[]): void { + const sorted = [...failed].sort(); + if (equals(sorted, this.lastReportedFailures)) { + return; // same failures as last time; don't re-notify + } + this.lastReportedFailures = sorted; + + if (sorted.length === 0) { + return; + } + + this.notificationService.notify({ + severity: Severity.Warning, + message: nls.localize('systemWideKeybindings.registrationFailed', "Some system-wide keybindings could not be registered ({0}); the key combination may already be taken by the operating system or another application.", sorted.join(', ')), + }); + } + + private productName(): string { + return this.productService.nameLong; + } +} + +registerWorkbenchContribution2( + SystemWideKeybindingsContribution.ID, + SystemWideKeybindingsContribution, + WorkbenchPhase.AfterRestored, +); diff --git a/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md new file mode 100644 index 00000000000000..83031d252cf5c0 --- /dev/null +++ b/src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.specification.md @@ -0,0 +1,194 @@ +# System-wide (OS global) keybindings + +This document is the canonical spec for the **system-wide keybindings** feature: user +`keybindings.json` entries that fire even when VS Code is not the focused application, backed by +Electron's [`globalShortcut`](https://www.electronjs.org/docs/latest/api/accelerator) module. + +History: introduced in PR microsoft/vscode#323871; the first-run notice dialog was later removed in +PR microsoft/vscode#324045 (it could appear in multiple windows at once, and the single-window +election meant to prevent that was racy). The feature is **desktop only** and **always on**. + +## User-facing contract + +A user adds `"systemWide": true` to a keybinding entry in **`keybindings.json`**: + +```jsonc +{ + "key": "ctrl+cmd+a", + "command": "workbench.action.openAgentsWindow", + "systemWide": true +} +``` + +While VS Code is running (even unfocused), pressing that combination runs the command. Rules: + +- **User keybindings only.** Default and extension-contributed keybindings can never be + system-wide — this prevents extensions from silently grabbing OS-global shortcuts. +- **Single key combinations only.** Chords (`ctrl+k ctrl+c`) and single-modifier bindings cannot be + expressed as an Electron accelerator; they are skipped with a `warn` log. +- **`when` clauses are ignored** for the global trigger — an OS-global shortcut has no editor/UI + context, so it is always active while VS Code runs. The user is warned once per binding label. +- **First binding wins** on accelerator conflicts (deduped both in the renderer selection and in the + main-process per-window payload). +- **Desktop only.** The flag is inert on web/server (there is no `globalShortcut` there, and the + contribution is `electron-browser`). +- **Always on.** There is no setting to enable/disable it and no first-run dialog. + +The `systemWide` boolean is declared in the `keybindings.json` JSON schema in +`src/vs/workbench/services/keybinding/browser/keybindingService.ts`. + +## Architecture at a glance + +The feature spans the renderer (electron-browser) and the Electron main process, connected by the +existing native-host IPC channel. + +```mermaid +sequenceDiagram + participant KB as IKeybindingService (renderer) + participant C as SystemWideKeybindingsContribution (renderer) + participant NH as NativeHostMainService (main) + participant G as GlobalKeybindingsMainService (main) + participant OS as Electron globalShortcut / OS + participant W as window.ts vscode:runAction (renderer) + + KB->>C: onDidUpdateKeybindings (also fires on layout change) + C->>C: selectSystemWideKeybindings() (debounced 200ms) + C->>NH: syncSystemWideKeybindings(payload) [per window] + NH->>G: updateKeybindings(windowId, payload) + G->>OS: register / unregister accelerators (union across windows) + G-->>C: { failed: string[] } (surfaced as a warning notification) + OS->>G: accelerator pressed -> onTrigger(accelerator) + G->>W: sendWhenReady('vscode:runAction', { id, from:'systemWideKeybinding', args }) + W->>W: commandService.executeCommand(id, ...args) +``` + +### Renderer + +**`src/vs/workbench/contrib/keybindings/electron-browser/systemWideKeybindings.contribution.ts`** — +the heart of the renderer side. Registered as a workbench contribution at +`WorkbenchPhase.AfterRestored` (never blocks startup). + +- `selectSystemWideKeybindings(items)` — a **pure** function that filters the full set of resolved + keybindings down to eligible candidates. Eligibility: `item.systemWide && !item.isDefault && + item.command && item.resolvedKeybinding && getElectronAccelerator() !== null` and the accelerator + has not already been claimed. Returns `{ candidates, unsupported, duplicates }` so the caller can + log why entries were dropped. Kept pure and separately unit-tested. +- `SystemWideKeybindingsContribution` — subscribes to `IKeybindingService.onDidUpdateKeybindings` + (which also fires on keyboard-layout changes, since accelerator strings depend on layout), + debounces via a 200ms `RunOnceScheduler`, and on each `sync()`: + 1. collects candidates (logging unsupported/duplicate entries), + 2. warns once per label about ignored `when` clauses (`warnedWhenLabels` guard), + 3. pushes the payload to the main process via `INativeHostService.syncSystemWideKeybindings`, + 4. reports registration failures via `INotificationService`, deduped against the last reported set + (`lastReportedFailures`) so unchanged failures are not re-notified. + +**`src/vs/workbench/electron-browser/window.ts`** — handles the `vscode:runAction` IPC in the +renderer. For `request.from === 'systemWideKeybinding'` it runs the command with **exactly** the +args configured in `keybindings.json` — unlike the `menu`/`mouse` senders it does **not** append a +`{ from }` sentinel, so a command taking positional args receives the same payload it would from a +normal in-window keybinding. Emits the standard `workbenchActionExecuted` telemetry. + +### IPC contract + +**`src/vs/platform/native/common/native.ts`** + +- `INativeSystemWideKeybinding` — `{ accelerator, commandId, args?, userSettingsLabel? }`. The + `accelerator` is in Electron accelerator format (e.g. `Control+Cmd+A`). +- `INativeSystemWideKeybindingResult` — `{ failed: string[] }`. `failed` lists user-settings labels + (or accelerators) that could not be registered. +- `ICommonNativeHostService.syncSystemWideKeybindings(keybindings)` — the method exposed to the + renderer. + +**`src/vs/platform/window/common/window.ts`** — `INativeRunActionInWindowRequest.from` includes +`'systemWideKeybinding'` in its union. + +### Main process + +**`src/vs/platform/native/electron-main/nativeHostMainService.ts`** — `syncSystemWideKeybindings` +delegates to `IGlobalKeybindingsMainService.updateKeybindings(windowId, ...)`. If there is no +`windowId` it returns `{ failed: [] }`. + +**`src/vs/platform/globalKeybindings/electron-main/globalKeybindingsMainService.ts`** — owns the OS +registrations. Wired up in `src/vs/code/electron-main/app.ts` with the real Electron global: +`services.set(IGlobalKeybindingsMainService, new SyncDescriptor(GlobalKeybindingsMainService, [globalShortcut]))`. + +- `IGlobalShortcutRegistry` — the tiny `register/unregister/isRegistered` subset of Electron's + `globalShortcut`, injected as a constructor arg (not imported directly) so tests can supply a fake. +- State: + - `registry: Map<windowId, Map<accelerator, binding>>` — each window's desired bindings. + - `registeredAccelerators: Set<string>` — accelerators this service currently owns an OS + registration for. + - `failedAccelerators: Set<string>` — desired accelerators that failed to register (e.g. already + taken); retried on the next reconcile. +- `updateKeybindings(windowId, keybindings)` — validates + dedups the payload for that window, + replaces (or clears) the window's entry, calls `reconcile()`, and returns the `failed` labels for + that window. +- `reconcile()` — computes the union of desired accelerators across **all** windows. Unregisters + only accelerators this service owns that are no longer desired (never touches shortcuts owned + elsewhere), then registers newly desired ones (retrying previously failed). Each accelerator is + registered **once** with a stable callback `() => this.onTrigger(accelerator)` that reads the + **current** registry at fire time, so command/args are never captured from a stale snapshot. +- `onTrigger(accelerator)` — resolves the target window: the focused window if it owns the + accelerator, otherwise the deterministic winner (lowest window id) among alive owners. Sends + `vscode:runAction` via `target.sendWhenReady(...)`. It deliberately does **not** force-focus the + routing window — a system-wide keybinding fires while VS Code is typically unfocused, and pulling + the routing window forward would flicker when the command opens/reveals a *different* window + (e.g. `workbench.action.openAgentsWindow`). This matches every other `vscode:runAction` sender. +- Lifecycle: on `IWindowsMainService.onDidDestroyWindow` it drops the window's entry and reconciles; + on `ILifecycleMainService.onWillShutdown` and on dispose it unregisters everything. + +### How the `systemWide` flag is plumbed + +The boolean travels from `keybindings.json` to `ResolvedKeybindingItem`: + +- `src/vs/workbench/services/keybinding/common/keybindingIO.ts` — parses `systemWide` from each + entry (`IUserKeybindingItem`) and writes it back out on serialization. +- `src/vs/platform/keybinding/common/keybinding.ts` — `IUserKeybindingItem.systemWide?: boolean`. +- `src/vs/platform/keybinding/common/resolvedKeybindingItem.ts` — `ResolvedKeybindingItem.systemWide` + (defaults `false`; only ever `true` for user keybindings). +- `src/vs/workbench/services/keybinding/browser/keybindingService.ts` — threads `item.systemWide` + into every `ResolvedKeybindingItem` it builds, and declares the schema property. + +## Design decisions and rationale + +1. **User-only, single-combo, `when`-ignored** — hard constraints of OS-global shortcuts; each is + enforced and surfaced (log for unsupported/duplicate, notification for ignored `when`). +2. **Per-window registry + union registration** — multiple windows may each request the same or + different system-wide bindings. The main service registers the union and routes a press to a + single window (focused owner, else deterministic lowest id). It only ever unregisters + accelerators it owns, so it never stomps global shortcuts registered by the OS or other apps. +3. **No force-focus on trigger** — avoids visible flicker and lets the invoked command decide what + to surface/focus. +4. **Always-on, no dialog** — the feature is unconditionally active; the earlier one-time notice was + removed because it leaked into multiple windows and its single-window election was racy. Users who + opt a binding into `systemWide` are assumed to understand the implication. +5. **Deduped failure notifications** — `reportFailures` only notifies when the failed set changes, so + a persistent conflict is reported once, not on every re-sync. +6. **Stable trigger callback reading live state** — registering once per accelerator (rather than + re-registering on every payload change) avoids races and stale command/args capture. + +## Testing + +- `src/vs/platform/globalKeybindings/test/electron-main/globalKeybindingsMainService.test.ts` — the + main service against a fake `IGlobalShortcutRegistry` and fake windows service: register/reconcile, + dedup within a window, failed-registration reporting + retry, trigger routing (focused owner, + deterministic lowest-id, no force-focus, undefined args), cross-window conflict resolution, window + destroy unregistration, and shutdown unregister-all. Pure electron-main (node-safe, no DOM/CSS). +- `src/vs/workbench/contrib/keybindings/test/electron-browser/systemWideKeybindings.test.ts` — the + pure `selectSystemWideKeybindings`: eligibility filtering, unsupported (chords/modifiers), + duplicates. +- `keybindingIO.test.ts` / `keybindingEditing.test.ts` — round-trip of the `systemWide` flag through + parse/serialize. + +## Gotchas for future work + +- Adding another `vscode:runAction` sender kind requires extending the `from` union in + `INativeRunActionInWindowRequest` (`src/vs/platform/window/common/window.ts`) and handling it in + `window.ts`. +- Accelerator strings are keyboard-layout dependent; the contribution re-syncs on + `onDidUpdateKeybindings`, which also fires on layout changes. +- `globalShortcut.register` returns `false` (or throws) when an accelerator is already taken by the + OS or another application; such accelerators land in `failedAccelerators`, are retried on the next + reconcile, and are surfaced to the user as a warning. +- Keep `selectSystemWideKeybindings` pure — it is the primary unit-tested seam for renderer + eligibility logic. diff --git a/src/vs/workbench/contrib/keybindings/test/electron-browser/systemWideKeybindings.test.ts b/src/vs/workbench/contrib/keybindings/test/electron-browser/systemWideKeybindings.test.ts new file mode 100644 index 00000000000000..9a34c1750b18f3 --- /dev/null +++ b/src/vs/workbench/contrib/keybindings/test/electron-browser/systemWideKeybindings.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { KeyChord, KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { ResolvedKeybinding } from '../../../../../base/common/keybindings.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ResolvedKeybindingItem } from '../../../../../platform/keybinding/common/resolvedKeybindingItem.js'; +import { createUSLayoutResolvedKeybinding } from '../../../../../platform/keybinding/test/common/keybindingsTestUtils.js'; +import { selectSystemWideKeybindings } from '../../electron-browser/systemWideKeybindings.contribution.js'; + +suite('SystemWideKeybindings selection', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function resolve(encoded: number): ResolvedKeybinding { + const resolved = createUSLayoutResolvedKeybinding(encoded, OperatingSystem.Macintosh); + assert.ok(resolved, 'expected a resolvable keybinding'); + return resolved; + } + + function item(resolvedKeybinding: ResolvedKeybinding | undefined, command: string | null, options?: { commandArgs?: unknown; when?: string; isDefault?: boolean; systemWide?: boolean }): ResolvedKeybindingItem { + return new ResolvedKeybindingItem( + resolvedKeybinding, + command, + options?.commandArgs, + options?.when ? ContextKeyExpr.deserialize(options.when) : undefined, + options?.isDefault ?? false, + null, + false, + options?.systemWide ?? false, + ); + } + + test('selects only user system-wide single-combo bindings and preserves args/when', () => { + const acceleratorBinding = resolve(KeyMod.WinCtrl | KeyMod.CtrlCmd | KeyCode.KeyA); + + const selection = selectSystemWideKeybindings([ + // eligible: user, system-wide, single combo, with args + when + item(acceleratorBinding, 'workbench.action.openAgentsWindow', { commandArgs: { foo: 1 }, when: 'editorFocus', systemWide: true }), + // ignored: not system-wide + item(resolve(KeyMod.CtrlCmd | KeyCode.KeyB), 'noop.notSystemWide'), + // ignored: default keybinding even if flagged + item(resolve(KeyMod.CtrlCmd | KeyCode.KeyC), 'noop.default', { isDefault: true, systemWide: true }), + // ignored: removal / no command + item(resolve(KeyMod.CtrlCmd | KeyCode.KeyD), null, { systemWide: true }), + ]); + + assert.deepStrictEqual(selection, { + candidates: [{ + accelerator: 'Ctrl+Cmd+A', + commandId: 'workbench.action.openAgentsWindow', + args: { foo: 1 }, + userSettingsLabel: 'ctrl+cmd+a', + hasWhen: true, + }], + unsupported: [], + duplicates: [], + }); + }); + + test('reports chords / single-modifier bindings as unsupported', () => { + const chord = resolve(KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyMod.CtrlCmd | KeyCode.KeyC)); + + const selection = selectSystemWideKeybindings([ + item(chord, 'noop.chord', { systemWide: true }), + ]); + + assert.deepStrictEqual(selection, { + candidates: [], + unsupported: ['cmd+k cmd+c'], + duplicates: [], + }); + }); + + test('keeps the first binding on accelerator conflicts', () => { + const selection = selectSystemWideKeybindings([ + item(resolve(KeyMod.CtrlCmd | KeyCode.KeyA), 'first.wins', { systemWide: true }), + item(resolve(KeyMod.CtrlCmd | KeyCode.KeyA), 'second.loses', { systemWide: true }), + ]); + + assert.deepStrictEqual(selection, { + candidates: [{ + accelerator: 'Cmd+A', + commandId: 'first.wins', + args: undefined, + userSettingsLabel: 'cmd+a', + hasWhen: false, + }], + unsupported: [], + duplicates: ['cmd+a'], + }); + }); +}); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts index 42605282d94975..f86e30a98dde2d 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpCommands.ts @@ -26,14 +26,17 @@ import { ILocalizedString, localize, localize2 } from '../../../../nls.js'; import { IActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; import { MenuEntryActionViewItem } from '../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { Action2, MenuId, MenuItemAction, MenuRegistry } from '../../../../platform/actions/common/actions.js'; +import { McpServerStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; import { IFileService } from '../../../../platform/files/common/files.js'; import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; import { mcpAutoStartConfig, McpAutoStartValue } from '../../../../platform/mcp/common/mcpManagement.js'; +import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputButton, IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../platform/quickinput/common/quickInput.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; @@ -50,7 +53,6 @@ import { IOutputService } from '../../../services/output/common/output.js'; import { IRemoteUserDataProfilesService } from '../../../services/userDataProfile/common/remoteUserDataProfiles.js'; import { IUserDataProfileService } from '../../../services/userDataProfile/common/userDataProfile.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; -import { McpServerStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; import { CHAT_CONFIG_MENU_ID } from '../../chat/browser/actions/chatActions.js'; import { ChatViewId, IChatWidgetService } from '../../chat/browser/chat.js'; import { IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; @@ -111,6 +113,8 @@ export class ListMcpServerCommand extends Action2 { mcpService: accessor.get(IMcpService), commandService: accessor.get(ICommandService), quickInput: accessor.get(IQuickInputService), + notificationService: accessor.get(INotificationService), + logService: accessor.get(ILogService), }; return this._runWithMode(services, undefined); } @@ -198,7 +202,7 @@ export class ListMcpServerCommand extends Action2 { const { agentHostCustomizations, commandService, quickInput } = services; const BACK_ID = '$back'; - type ItemType = { id: string } & IQuickPickItem; + type ItemType = { id: string; server?: IAgentHostMcpServer } & IQuickPickItem; const store = new DisposableStore(); const pick = quickInput.createQuickPick<ItemType>({ useSeparators: true }); @@ -218,10 +222,12 @@ export class ListMcpServerCommand extends Action2 { alwaysShow: true, } satisfies ItemType] : servers.map((server): ItemType => ({ id: server.id, + server, label: server.name, description: server.enabled ? mcpServerStatusToLabel(server.status) : localize('mcp.disabled', 'Disabled'), + buttons: getAgentHostMcpServerButtons(server), }))), { type: 'separator' } satisfies IQuickPickSeparator, { @@ -239,6 +245,19 @@ export class ListMcpServerCommand extends Action2 { refresh(); store.add(agentHostCustomizations.onDidChangeCustomizations(() => refresh())); + store.add(pick.onDidTriggerItemButton(async event => { + if (!isAgentHostMcpServerButton(event.button) || !event.item.server) { + return; + } + + pick.busy = true; + try { + await runAgentHostMcpServerLifecycleAction(event.item.server, event.button.action, services); + refresh(); + } finally { + pick.busy = false; + } + })); const picked = await new Promise<ItemType | undefined>(resolve => { store.add(pick.onDidAccept(() => { @@ -271,6 +290,69 @@ interface IListMcpServerServices { readonly mcpService: IMcpService; readonly commandService: ICommandService; readonly quickInput: IQuickInputService; + readonly notificationService: INotificationService; + readonly logService: ILogService; +} + +type AgentHostMcpServerLifecycleAction = 'start' | 'stop'; +type IAgentHostMcpServer = ReturnType<IAgentHostCustomizationService['getMcpServers']>[number]; + +interface IAgentHostMcpServerButton extends IQuickInputButton { + readonly action: AgentHostMcpServerLifecycleAction; +} + +function isAgentHostMcpServerButton(button: IQuickInputButton): button is IAgentHostMcpServerButton { + return 'action' in button && (button.action === 'start' || button.action === 'stop'); +} + +const startAgentHostMcpServerButton: IAgentHostMcpServerButton = { + iconClass: ThemeIcon.asClassName(Codicon.play), + tooltip: localize('mcp.start', 'Start Server'), + action: 'start', +}; + +const stopAgentHostMcpServerButton: IAgentHostMcpServerButton = { + iconClass: ThemeIcon.asClassName(Codicon.debugStop), + tooltip: localize('mcp.stop', 'Stop Server'), + action: 'stop', +}; + +function getAgentHostMcpServerButtons(server: IAgentHostMcpServer): IAgentHostMcpServerButton[] { + if (canStartAgentHostMcpServer(server)) { + return [startAgentHostMcpServerButton]; + } + if (canStopAgentHostMcpServer(server)) { + return [stopAgentHostMcpServerButton]; + } + return []; +} + +function canStartAgentHostMcpServer(server: IAgentHostMcpServer): boolean { + return server.enabled && (server.status === McpServerStatus.Stopped || server.status === McpServerStatus.Error); +} + +function canStopAgentHostMcpServer(server: IAgentHostMcpServer): boolean { + return server.enabled && ( + server.status === McpServerStatus.Starting + || server.status === McpServerStatus.Ready + || server.status === McpServerStatus.AuthRequired + ); +} + +async function runAgentHostMcpServerLifecycleAction(server: IAgentHostMcpServer, action: AgentHostMcpServerLifecycleAction, services: Pick<IListMcpServerServices, 'notificationService' | 'logService'>): Promise<void> { + try { + if (action === 'start' && canStartAgentHostMcpServer(server)) { + await server.start(); + } else if (action === 'stop' && canStopAgentHostMcpServer(server)) { + await server.stop(); + } + } catch (error) { + services.logService.error(`Failed to ${action} MCP server '${server.name}'`, error); + const message = error instanceof Error ? error.message : String(error); + services.notificationService.error(action === 'start' + ? localize('mcp.agentHost.startError', "Failed to start MCP server '{0}': {1}", server.name, message) + : localize('mcp.agentHost.stopError', "Failed to stop MCP server '{0}': {1}", server.name, message)); + } } function mcpServerStatusToLabel(status: McpServerStatus): string { @@ -304,6 +386,8 @@ export class McpAgentHostServerOptionsCommand extends Action2 { const agentHostCustomizations = accessor.get(IAgentHostCustomizationService); const quickInputService = accessor.get(IQuickInputService); const outputService = accessor.get(IOutputService); + const notificationService = accessor.get(INotificationService); + const logService = accessor.get(ILogService); const server = agentHostCustomizations.getMcpServers(agentHostSession).find(s => s.id === customizationId); if (!server) { @@ -312,20 +396,41 @@ export class McpAgentHostServerOptionsCommand extends Action2 { const logOutputChannelId = server.logOutputChannelId; - type ItemType = { action: 'toggle' | 'showOutput' } & IQuickPickItem; + type ItemType = { action: 'toggle' | 'showOutput' | 'authenticate' | AgentHostMcpServerLifecycleAction } & IQuickPickItem; const items: (ItemType | IQuickPickSeparator)[] = [ { type: 'separator', label: localize('mcp.actions.status', 'Status') }, - { - label: server.enabled - ? localize('mcp.agentHost.disable', 'Disable Server') - : localize('mcp.agentHost.enable', 'Enable Server'), - description: server.enabled - ? mcpServerStatusToLabel(server.status) - : localize('mcp.disabled', 'Disabled'), - action: 'toggle', - }, ]; + if (canStartAgentHostMcpServer(server)) { + items.push({ + label: localize('mcp.start', 'Start Server'), + description: mcpServerStatusToLabel(server.status), + action: 'start', + }); + } else if (canStopAgentHostMcpServer(server)) { + items.push({ + label: localize('mcp.stop', 'Stop Server'), + description: mcpServerStatusToLabel(server.status), + action: 'stop', + }); + } + + items.push({ + label: server.enabled + ? localize('mcp.agentHost.disable', 'Disable Server') + : localize('mcp.agentHost.enable', 'Enable Server'), + description: server.enabled + ? mcpServerStatusToLabel(server.status) + : localize('mcp.disabled', 'Disabled'), + action: 'toggle', + }); + if (server.state.kind === McpServerStatus.AuthRequired) { + items.push({ + label: localize('mcp.agentHost.authenticate', 'Authenticate'), + description: server.state.resource.resource, + action: 'authenticate', + }); + } if (logOutputChannelId) { items.push({ @@ -349,6 +454,16 @@ export class McpAgentHostServerOptionsCommand extends Action2 { return; } + if (picked.action === 'authenticate') { + await agentHostCustomizations.authenticateMcpServer(agentHostSession, server.id); + return; + } + + if (picked.action === 'start' || picked.action === 'stop') { + await runAgentHostMcpServerLifecycleAction(server, picked.action, { notificationService, logService }); + return; + } + if (picked.action === 'toggle') { server.setEnabled(!server.enabled); } @@ -1112,6 +1227,19 @@ export class ShowOutput extends Action2 { } } +interface IAgentHostMcpServerCommandArg { + readonly agentHostSession: URI; + readonly serverId: string; +} + +function isAgentHostMcpServerCommandArg(arg: string | IAgentHostMcpServerCommandArg): arg is IAgentHostMcpServerCommandArg { + return typeof arg !== 'string' && URI.isUri(arg.agentHostSession) && typeof arg.serverId === 'string'; +} + +function getAgentHostMcpServer(accessor: ServicesAccessor, arg: IAgentHostMcpServerCommandArg): IAgentHostMcpServer | undefined { + return accessor.get(IAgentHostCustomizationService).getMcpServers(arg.agentHostSession).find(server => server.id === arg.serverId); +} + export class RestartServer extends Action2 { constructor() { super({ @@ -1122,7 +1250,14 @@ export class RestartServer extends Action2 { }); } - async run(accessor: ServicesAccessor, serverId: string, opts?: IMcpServerStartOpts) { + async run(accessor: ServicesAccessor, serverId: string | IAgentHostMcpServerCommandArg, opts?: IMcpServerStartOpts) { + if (isAgentHostMcpServerCommandArg(serverId)) { + const server = getAgentHostMcpServer(accessor, serverId); + accessor.get(ILogService).warn(`Restarting MCP server '${server?.name ?? serverId.serverId}' is not supported for agent-host servers`); + accessor.get(INotificationService).warn(localize('mcp.agentHost.restartUnsupported', "Restarting MCP server '{0}' is not supported for agent-host servers. Stop and start the server instead.", server?.name ?? serverId.serverId)); + return; + } + const s = accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId); s?.showOutput(); await s?.stop(); @@ -1140,7 +1275,12 @@ export class StartServer extends Action2 { }); } - async run(accessor: ServicesAccessor, serverId: string, opts?: IMcpServerStartOpts & { waitForLiveTools?: boolean }) { + async run(accessor: ServicesAccessor, serverId: string | IAgentHostMcpServerCommandArg, opts?: IMcpServerStartOpts & { waitForLiveTools?: boolean }) { + if (isAgentHostMcpServerCommandArg(serverId)) { + await getAgentHostMcpServer(accessor, serverId)?.start(); + return; + } + let servers = accessor.get(IMcpService).servers.get(); if (serverId !== '*') { servers = servers.filter(s => s.definition.id === serverId); @@ -1165,7 +1305,12 @@ export class StopServer extends Action2 { }); } - async run(accessor: ServicesAccessor, serverId: string) { + async run(accessor: ServicesAccessor, serverId: string | IAgentHostMcpServerCommandArg) { + if (isAgentHostMcpServerCommandArg(serverId)) { + await getAgentHostMcpServer(accessor, serverId)?.stop(); + return; + } + const s = accessor.get(IMcpService).servers.get().find(s => s.definition.id === serverId); await s?.stop(); } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts index 86c939d322d24a..7418294b51f249 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpLanguageFeatures.ts @@ -16,6 +16,12 @@ import { CodeLens, CodeLensList, CodeLensProvider, InlayHint, InlayHintList } fr import { ITextModel } from '../../../../editor/common/model.js'; import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js'; import { localize } from '../../../../nls.js'; +import { IAgentHostConnectionInfo, IAgentHostConnectionsService, LOCAL_AGENT_HOST_SCHEME_PREFIX } from '../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { remoteAgentHostSessionTypeId } from '../../../../platform/agentHost/common/agentHostSessionType.js'; +import { AgentSession } from '../../../../platform/agentHost/common/agentService.js'; +import { ActionType } from '../../../../platform/agentHost/common/state/protocol/actions.js'; +import { CustomizationType, McpServerStatus, type ChildCustomization, type Customization, type SessionState } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { StateComponents } from '../../../../platform/agentHost/common/state/sessionState.js'; import { ConfigurationTarget } from '../../../../platform/configuration/common/configuration.js'; import { IMarkerData, IMarkerService, MarkerSeverity } from '../../../../platform/markers/common/markers.js'; import { ISecretStorageService } from '../../../../platform/secrets/common/secrets.js'; @@ -23,10 +29,13 @@ import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IConfigurationResolverService } from '../../../services/configurationResolver/common/configurationResolver.js'; import { ConfigurationResolverExpression, IResolvedValue } from '../../../services/configurationResolver/common/configurationResolverExpression.js'; +import { IAgentHostCustomizationService } from '../../chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; +import { IChatWidgetService } from '../../chat/browser/chat.js'; +import { isContributionDisabled } from '../../chat/common/enablement.js'; import { McpCommandIds } from '../common/mcpCommandIds.js'; import { mcpConfigurationSection } from '../common/mcpConfiguration.js'; +import { countRunningMcpServersInOtherSessions, getActiveAgentHostMcpSessionResource, IMcpEditorAgentHostServer, type IMcpEditorAgentHostSessionServers } from '../common/mcpEditorAffordanceState.js'; import { IMcpRegistry } from '../common/mcpRegistryTypes.js'; -import { isContributionDisabled } from '../../chat/common/enablement.js'; import { IMcpConfigPath, IMcpServerStartOpts, IMcpService, IMcpWorkbenchService, McpConnectionState, mcpOAuthClientSecretStorageKey } from '../common/mcpTypes.js'; const diagnosticOwner = 'vscode.mcp'; @@ -35,6 +44,8 @@ type ConfigDescriptor = Pick<IMcpConfigPath, 'section' | 'scope' | 'target'> & { serversKey?: string; }; +type AgentHostMcpServer = ReturnType<IAgentHostCustomizationService['getMcpServers']>[number]; + export class McpLanguageFeatures extends Disposable implements IWorkbenchContribution { private readonly _cachedMcpSection = this._register(new MutableDisposable<{ model: ITextModel; inConfig: ConfigDescriptor; tree: Node } & IDisposable>()); @@ -43,6 +54,9 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib @IMcpRegistry private readonly _mcpRegistry: IMcpRegistry, @IMcpWorkbenchService private readonly _mcpWorkbenchService: IMcpWorkbenchService, @IMcpService private readonly _mcpService: IMcpService, + @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, + @IAgentHostCustomizationService private readonly _agentHostCustomizationService: IAgentHostCustomizationService, + @IAgentHostConnectionsService private readonly _agentHostConnectionsService: IAgentHostConnectionsService, @IMarkerService private readonly _markerService: IMarkerService, @IConfigurationResolverService private readonly _configurationResolverService: IConfigurationResolverService, @ISecretStorageService private readonly _secretStorageService: ISecretStorageService, @@ -60,12 +74,45 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib onDidChange: onDidChangeCodeLens.event, provideCodeLenses: (model, range) => this._provideCodeLenses(model, () => onDidChangeCodeLens.fire(codeLensProvider)), }; + const refreshCodeLens = () => onDidChangeCodeLens.fire(codeLensProvider); this._register(languageFeaturesService.codeLensProvider.register(patterns, codeLensProvider)); this._register(this._secretStorageService.onDidChangeSecret(key => { if (key.startsWith('mcp.oauth.clientSecret:')) { - onDidChangeCodeLens.fire(codeLensProvider); + refreshCodeLens(); } })); + const focusedWidgetViewModelListener = this._register(new MutableDisposable()); + const updateFocusedWidgetViewModelListener = () => { + focusedWidgetViewModelListener.value = this._chatWidgetService.lastFocusedWidget?.onDidChangeViewModel(refreshCodeLens); + refreshCodeLens(); + }; + const connectionStateListeners = this._register(new MutableDisposable<DisposableStore>()); + const updateConnectionStateListeners = () => { + const store = new DisposableStore(); + for (const connectionInfo of this._agentHostConnectionsService.connections) { + const connection = connectionInfo.connection; + if (connection) { + store.add(connection.onDidAction(({ action }) => { + switch (action.type) { + case ActionType.SessionCustomizationsChanged: + case ActionType.SessionCustomizationUpdated: + case ActionType.SessionCustomizationRemoved: + case ActionType.SessionMcpServerStateChanged: + refreshCodeLens(); + break; + } + })); + } + } + connectionStateListeners.value = store; + refreshCodeLens(); + }; + updateFocusedWidgetViewModelListener(); + updateConnectionStateListeners(); + this._register(this._chatWidgetService.onDidChangeFocusedWidget(updateFocusedWidgetViewModelListener)); + this._register(this._chatWidgetService.onDidChangeFocusedSession(refreshCodeLens)); + this._register(this._agentHostConnectionsService.onDidChangeConnections(updateConnectionStateListeners)); + this._register(this._agentHostCustomizationService.onDidChangeCustomizations(refreshCodeLens)); this._register(languageFeaturesService.inlayHintsProvider.register(patterns, { onDidChangeInlayHints: _mcpRegistry.onDidChangeInputs, @@ -183,169 +230,186 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib return lensList; } - const mcpServers = read(this._mcpService.servers).filter(s => s.collection.id === collection.id); - for (const node of serversNode.children || []) { - if (node.type !== 'property' || node.children?.[0]?.type !== 'string') { - continue; - } + const agentHostSession = getActiveAgentHostMcpSessionResource(this._chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource); + if (agentHostSession) { + const mcpServers = this._agentHostCustomizationService.getMcpServers(agentHostSession); + const otherRunningCounts = this._getOtherRunningAgentHostMcpServerCounts(agentHostSession); + for (const node of serversNode.children || []) { + if (node.type !== 'property' || node.children?.[0]?.type !== 'string') { + continue; + } - const name = node.children[0].value as string; + const name = node.children[0].value as string; + const server = mcpServers.find(s => s.name === name); + if (!server) { + continue; + } - const server = mcpServers.find(s => s.definition.label === name); - if (!server) { - continue; + this._addAgentHostServerCodeLenses(lenses, Range.fromPositions(model.getPositionAt(node.children[0].offset)), agentHostSession, server, otherRunningCounts.get(name) ?? 0); } + } else { + const mcpServers = read(this._mcpService.servers).filter(s => s.collection.id === collection.id); + for (const node of serversNode.children || []) { + if (node.type !== 'property' || node.children?.[0]?.type !== 'string') { + continue; + } - const range = Range.fromPositions(model.getPositionAt(node.children[0].offset)); + const name = node.children[0].value as string; - if (isContributionDisabled(read(server.enablement))) { - lenses.push({ - range, - command: { - id: McpCommandIds.ServerOptions, - title: '$(circle-slash) ' + localize('server.disabled', 'Disabled'), - arguments: [server.definition.id], - }, - }); - continue; - } + const server = mcpServers.find(s => s.definition.label === name); + if (!server) { + continue; + } - const canDebug = !!server.readDefinitions().get().server?.devMode?.debug; - const state = read(server.connectionState).state; - switch (state) { - case McpConnectionState.Kind.Error: + const range = Range.fromPositions(model.getPositionAt(node.children[0].offset)); + + if (isContributionDisabled(read(server.enablement))) { lenses.push({ range, command: { - id: McpCommandIds.ShowOutput, - title: '$(error) ' + localize('server.error', 'Error'), + id: McpCommandIds.ServerOptions, + title: '$(circle-slash) ' + localize('server.disabled', 'Disabled'), arguments: [server.definition.id], }, - }, { - range, - command: { - id: McpCommandIds.RestartServer, - title: localize('mcp.restart', "Restart"), - arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], - }, }); - if (canDebug) { + continue; + } + + const canDebug = !!server.readDefinitions().get().server?.devMode?.debug; + const state = read(server.connectionState).state; + switch (state) { + case McpConnectionState.Kind.Error: lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(error) ' + localize('server.error', 'Error'), + arguments: [server.definition.id], + }, + }, { range, command: { id: McpCommandIds.RestartServer, - title: localize('mcp.debug', "Debug"), - arguments: [server.definition.id, { debug: true, autoTrustChanges: true } satisfies IMcpServerStartOpts], + title: localize('mcp.restart', "Restart"), + arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], }, }); - } - break; - case McpConnectionState.Kind.Starting: - lenses.push({ - range, - command: { - id: McpCommandIds.ShowOutput, - title: '$(loading~spin) ' + localize('server.starting', 'Starting'), - arguments: [server.definition.id], - }, - }, { - range, - command: { - id: McpCommandIds.StopServer, - title: localize('cancel', "Cancel"), - arguments: [server.definition.id], - }, - }); - break; - case McpConnectionState.Kind.Running: - lenses.push({ - range, - command: { - id: McpCommandIds.ShowOutput, - title: '$(check) ' + localize('server.running', 'Running'), - arguments: [server.definition.id], - }, - }, { - range, - command: { - id: McpCommandIds.StopServer, - title: localize('mcp.stop', "Stop"), - arguments: [server.definition.id], - }, - }, { - range, - command: { - id: McpCommandIds.RestartServer, - title: localize('mcp.restart', "Restart"), - arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], - }, - }); - if (canDebug) { + if (canDebug) { + lenses.push({ + range, + command: { + id: McpCommandIds.RestartServer, + title: localize('mcp.debug', "Debug"), + arguments: [server.definition.id, { debug: true, autoTrustChanges: true } satisfies IMcpServerStartOpts], + }, + }); + } + break; + case McpConnectionState.Kind.Starting: lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(loading~spin) ' + localize('server.starting', 'Starting'), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.StopServer, + title: localize('cancel', "Cancel"), + arguments: [server.definition.id], + }, + }); + break; + case McpConnectionState.Kind.Running: + lenses.push({ + range, + command: { + id: McpCommandIds.ShowOutput, + title: '$(check) ' + localize('server.running', 'Running'), + arguments: [server.definition.id], + }, + }, { + range, + command: { + id: McpCommandIds.StopServer, + title: localize('mcp.stop', "Stop"), + arguments: [server.definition.id], + }, + }, { range, command: { id: McpCommandIds.RestartServer, - title: localize('mcp.debug', "Debug"), - arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts], + title: localize('mcp.restart', "Restart"), + arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], }, }); - } - break; - case McpConnectionState.Kind.Stopped: - lenses.push({ - range, - command: { - id: McpCommandIds.StartServer, - title: '$(debug-start) ' + localize('mcp.start', "Start"), - arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], - }, - }); - if (canDebug) { + if (canDebug) { + lenses.push({ + range, + command: { + id: McpCommandIds.RestartServer, + title: localize('mcp.debug', "Debug"), + arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts], + }, + }); + } + break; + case McpConnectionState.Kind.Stopped: lenses.push({ range, command: { id: McpCommandIds.StartServer, - title: localize('mcp.debug', "Debug"), - arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts], + title: '$(debug-start) ' + localize('mcp.start', "Start"), + arguments: [server.definition.id, { autoTrustChanges: true } satisfies IMcpServerStartOpts], }, }); - } - } - - - if (state !== McpConnectionState.Kind.Error) { - const toolCount = read(server.tools).length; - if (toolCount) { - lenses.push({ - range, - command: { - id: '', - title: localize('server.toolCount', '{0} tools', toolCount), + if (canDebug) { + lenses.push({ + range, + command: { + id: McpCommandIds.StartServer, + title: localize('mcp.debug', "Debug"), + arguments: [server.definition.id, { autoTrustChanges: true, debug: true } satisfies IMcpServerStartOpts], + }, + }); } - }); } + if (state !== McpConnectionState.Kind.Error) { + const toolCount = read(server.tools).length; + if (toolCount) { + lenses.push({ + range, + command: { + id: '', + title: localize('server.toolCount', '{0} tools', toolCount), + } + }); + } + + const promptCount = read(server.prompts).length; + if (promptCount) { + lenses.push({ + range, + command: { + id: McpCommandIds.StartPromptForServer, + title: localize('server.promptcount', '{0} prompts', promptCount), + arguments: [server], + } + }); + } - const promptCount = read(server.prompts).length; - if (promptCount) { lenses.push({ range, command: { - id: McpCommandIds.StartPromptForServer, - title: localize('server.promptcount', '{0} prompts', promptCount), - arguments: [server], + id: McpCommandIds.ServerOptions, + title: localize('mcp.server.more', 'More...'), + arguments: [server.definition.id], } }); } - - lenses.push({ - range, - command: { - id: McpCommandIds.ServerOptions, - title: localize('mcp.server.more', 'More...'), - arguments: [server.definition.id], - } - }); } } @@ -402,6 +466,184 @@ export class McpLanguageFeatures extends Disposable implements IWorkbenchContrib return lensList; } + private _addAgentHostServerCodeLenses(lenses: CodeLens[], range: Range, agentHostSession: URI, server: AgentHostMcpServer, otherRunningSessionCount: number): void { + const commandArg = { agentHostSession, serverId: server.id }; + if (!server.enabled) { + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: '$(circle-slash) ' + localize('server.disabled', 'Disabled'), + arguments: [agentHostSession, server.id], + }, + }); + return; + } + + switch (server.status) { + case McpServerStatus.Error: + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: '$(error) ' + localize('server.error', 'Error'), + arguments: [agentHostSession, server.id], + }, + }); + lenses.push({ + range, + command: { + id: McpCommandIds.StartServer, + title: localize('mcp.start', "Start"), + arguments: [commandArg], + }, + }); + break; + case McpServerStatus.Starting: + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: '$(loading~spin) ' + localize('server.starting', 'Starting'), + arguments: [agentHostSession, server.id], + }, + }); + lenses.push({ + range, + command: { + id: McpCommandIds.StopServer, + title: localize('cancel', "Cancel"), + arguments: [commandArg], + }, + }); + break; + case McpServerStatus.Ready: + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: '$(check) ' + localize('server.running', 'Running'), + arguments: [agentHostSession, server.id], + }, + }); + lenses.push({ + range, + command: { + id: McpCommandIds.StopServer, + title: localize('mcp.stop', "Stop"), + arguments: [commandArg], + }, + }); + break; + case McpServerStatus.AuthRequired: + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: '$(account) ' + localize('server.authRequired', 'Authentication Required'), + arguments: [agentHostSession, server.id], + }, + }); + lenses.push({ + range, + command: { + id: McpCommandIds.StopServer, + title: localize('mcp.stop', "Stop"), + arguments: [commandArg], + }, + }); + break; + case McpServerStatus.Stopped: + lenses.push({ + range, + command: { + id: McpCommandIds.StartServer, + title: '$(debug-start) ' + localize('mcp.start', "Start"), + arguments: [commandArg], + }, + }); + break; + } + + if (otherRunningSessionCount > 0) { + lenses.push({ + range, + command: { + id: '', + title: otherRunningSessionCount === 1 + ? localize('server.runningInOneOtherSession', '(Running in 1 session)') + : localize('server.runningInOtherSessions', '(Running in {0} sessions)', otherRunningSessionCount), + } + }); + } + + if (server.status !== McpServerStatus.Error) { + lenses.push({ + range, + command: { + id: McpCommandIds.AgentHostServerOptions, + title: localize('mcp.server.more', 'More...'), + arguments: [agentHostSession, server.id], + } + }); + } + } + + private _getOtherRunningAgentHostMcpServerCounts(agentHostSession: URI): Map<string, number> { + const sessionServers: IMcpEditorAgentHostSessionServers[] = []; + for (const connectionInfo of this._agentHostConnectionsService.connections) { + const connection = connectionInfo.connection; + if (!connection) { + continue; + } + + for (const subscription of connection.getActiveSubscriptions()) { + if (subscription.kind !== StateComponents.Session) { + continue; + } + + const state = connection.getSubscriptionUnmanaged(StateComponents.Session, subscription.resource)?.value; + const resource = this._toAgentHostSessionResource(connectionInfo, subscription.resource); + if (!resource || !state || state instanceof Error) { + continue; + } + + sessionServers.push({ resource, servers: this._getMcpServersFromSessionState(state) }); + } + } + return countRunningMcpServersInOtherSessions(agentHostSession, sessionServers); + } + + private _toAgentHostSessionResource(connectionInfo: IAgentHostConnectionInfo, backendSession: URI): URI | undefined { + const provider = AgentSession.provider(backendSession); + if (!provider) { + return undefined; + } + const scheme = connectionInfo.isAmbient + ? `${LOCAL_AGENT_HOST_SCHEME_PREFIX}${provider}` + : remoteAgentHostSessionTypeId(connectionInfo.authority, provider); + return URI.from({ scheme, path: backendSession.path }); + } + + private _getMcpServersFromSessionState(state: SessionState): IMcpEditorAgentHostServer[] { + const servers: IMcpEditorAgentHostServer[] = []; + const collect = (customizations: readonly (Customization | ChildCustomization)[] | undefined) => { + for (const customization of customizations ?? []) { + if (customization.type === CustomizationType.McpServer) { + servers.push({ + name: customization.name, + enabled: customization.enabled, + status: customization.state.kind, + }); + } else if (customization.type === CustomizationType.Directory || customization.type === CustomizationType.Plugin) { + collect(customization.children); + } + } + }; + collect(state.customizations); + return servers; + } + private async _provideInlayHints(model: ITextModel, range: Range): Promise<InlayHintList | undefined> { const parsed = await this._parseModel(model); if (!parsed) { @@ -502,5 +744,3 @@ function forEachPropertyWithReplacement(node: Node, callback: (node: Node) => vo node.children?.forEach(n => forEachPropertyWithReplacement(n, callback)); } } - - diff --git a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts index 5731e1e3b0ec30..0b0ed1c1180be8 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/installedMcpServersDiscovery.ts @@ -18,7 +18,7 @@ import { IWorkbenchLocalMcpServer } from '../../../../services/mcp/common/mcpWor import { getMcpServerMapping } from '../mcpConfigFileUtils.js'; import { mcpConfigurationSection } from '../mcpConfiguration.js'; import { IMcpRegistry } from '../mcpRegistryTypes.js'; -import { IMcpConfigPath, IMcpWorkbenchService, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; +import { IMcpConfigPath, IMcpWorkbenchService, MCP_CONFIGURATION_COLLECTION_ID_PREFIX, McpCollectionDefinition, McpCollectionSortOrder, McpServerDefinition, McpServerLaunch, McpServerTransportType, McpServerTrust } from '../mcpTypes.js'; import { IMcpDiscovery } from './mcpDiscovery.js'; interface CollectionState extends IDisposable { @@ -77,7 +77,7 @@ export class InstalledMcpServersDiscovery extends Disposable implements IMcpDisc const config = server.config; const mcpConfigPath = await mcpConfigPathPromise; - const collectionId = `mcp.config.${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; + const collectionId = `${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${mcpConfigPath ? mcpConfigPath.id : 'unknown'}`; let definitions = collections.get(collectionId); if (!definitions) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpEditorAffordanceState.ts b/src/vs/workbench/contrib/mcp/common/mcpEditorAffordanceState.ts new file mode 100644 index 00000000000000..489758e08ae252 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/common/mcpEditorAffordanceState.ts @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { isEqual } from '../../../../base/common/resources.js'; +import { URI } from '../../../../base/common/uri.js'; +import { McpServerStatus } from '../../../../platform/agentHost/common/state/protocol/state.js'; +import { isAgentHostTarget } from '../../chat/common/chatSessionsService.js'; +import { getChatSessionType } from '../../chat/common/model/chatUri.js'; + +export interface IMcpEditorAgentHostServer { + readonly name: string; + readonly enabled: boolean; + readonly status: McpServerStatus; +} + +export interface IMcpEditorAgentHostSessionServers { + readonly resource: URI; + readonly servers: readonly IMcpEditorAgentHostServer[]; +} + +export function getActiveAgentHostMcpSessionResource(sessionResource: URI | undefined): URI | undefined { + return sessionResource && isAgentHostTarget(getChatSessionType(sessionResource)) + ? sessionResource + : undefined; +} + +export function countRunningMcpServersInOtherSessions(currentSession: URI, sessions: readonly IMcpEditorAgentHostSessionServers[]): Map<string, number> { + const counts = new Map<string, number>(); + for (const session of sessions) { + if (isEqual(session.resource, currentSession)) { + continue; + } + const running = new Set<string>(); + for (const server of session.servers) { + if (server.enabled && server.status === McpServerStatus.Ready) { + running.add(server.name); + } + } + for (const name of running) { + counts.set(name, (counts.get(name) ?? 0) + 1); + } + } + return counts; +} diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 6ca7dc0d6a7459..f538b6530557d0 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -27,7 +27,7 @@ import { IGalleryMcpServer, IGalleryMcpServerConfiguration, IInstallableMcpServe import { IMcpDevModeConfig, IMcpSandboxConfiguration, IMcpServerConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js'; import { StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceFolder, IWorkspaceFolderData } from '../../../../platform/workspace/common/workspace.js'; -import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; +import { IWorkbenchLocalMcpServer, IWorkbencMcpServerInstallOptions, WORKSPACE_FOLDER_CONFIG_ID_PREFIX } from '../../../services/mcp/common/mcpWorkbenchManagementService.js'; import { ContributionEnablementState, IEnablementModel } from '../../chat/common/enablement.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { IMcpServerSamplingConfiguration } from './mcpConfiguration.js'; @@ -37,6 +37,14 @@ import { UriTemplate } from '../../../../base/common/uriTemplate.js'; export const extensionMcpCollectionPrefix = 'ext.'; +/** + * Prefix of the collection id used for MCP servers configured via the various + * `mcp.json`-style config files (user, remote user, workspace, and + * `.vscode/mcp.json` workspace-folder configs). The suffix is the + * {@link IMcpConfigPath.id} of the originating config path. + */ +export const MCP_CONFIGURATION_COLLECTION_ID_PREFIX = 'mcp.config.'; + export function extensionPrefixedIdentifier(identifier: ExtensionIdentifier, id: string): string { return ExtensionIdentifier.toKey(identifier) + '/' + id; } @@ -119,6 +127,27 @@ export namespace McpCollectionDefinition { && a.trustBehavior === b.trustBehavior && objectsEqual(a.sandbox, b.sandbox); } + + /** + * Returns `true` when the collection was discovered from the workspace (its + * config target is the workspace or a workspace folder). This is + * intentionally based on the config target and not the storage scope: + * extension-contributed collections use a workspace storage scope but are + * configured at the user level, so they are not workspace-discovered. + */ + export function isWorkspaceDiscovered(collection: McpCollectionDefinition): boolean { + return collection.configTarget === ConfigurationTarget.WORKSPACE + || collection.configTarget === ConfigurationTarget.WORKSPACE_FOLDER; + } + + /** + * Returns `true` when the collection originates from a `.vscode/mcp.json` + * workspace-folder config, identified by its collection id prefix (the + * shared `mcp.config.` prefix plus the workspace-folder config id). + */ + export function isVscodeMcpJson(collection: McpCollectionDefinition): boolean { + return collection.id.startsWith(`${MCP_CONFIGURATION_COLLECTION_ID_PREFIX}${WORKSPACE_FOLDER_CONFIG_ID_PREFIX}`); + } } export interface McpServerDefinition { diff --git a/src/vs/workbench/contrib/mcp/test/common/mcpEditorAffordanceState.test.ts b/src/vs/workbench/contrib/mcp/test/common/mcpEditorAffordanceState.test.ts new file mode 100644 index 00000000000000..56611755dac8e1 --- /dev/null +++ b/src/vs/workbench/contrib/mcp/test/common/mcpEditorAffordanceState.test.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. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { countRunningMcpServersInOtherSessions, getActiveAgentHostMcpSessionResource, type IMcpEditorAgentHostSessionServers } from '../../common/mcpEditorAffordanceState.js'; + +suite('MCP Editor Affordance State', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('tracks session switching across agent host sessions', () => { + const first = URI.parse('agent-host-copilotcli:/session-1'); + const second = URI.parse('agent-host-claude:/session-2'); + + assert.deepStrictEqual([ + getActiveAgentHostMcpSessionResource(first)?.toString(), + getActiveAgentHostMcpSessionResource(second)?.toString(), + ], [ + first.toString(), + second.toString(), + ]); + }); + + test('treats provisional agent host sessions as active MCP sessions', () => { + const provisional = URI.parse('agent-host-copilotcli:/untitled-123'); + + assert.strictEqual(getActiveAgentHostMcpSessionResource(provisional)?.toString(), provisional.toString()); + }); + + test('falls back to local state for non-agent-host sessions', () => { + assert.deepStrictEqual([ + getActiveAgentHostMcpSessionResource(URI.parse('vscode-local-chat://local/session')), + getActiveAgentHostMcpSessionResource(URI.parse('file:///workspace/mcp.json')), + getActiveAgentHostMcpSessionResource(undefined), + ], [ + undefined, + undefined, + undefined, + ]); + }); + + test('counts running servers in other sessions', () => { + const current = URI.parse('agent-host-copilotcli:/current'); + const sessions: IMcpEditorAgentHostSessionServers[] = [ + { + resource: current, + servers: [ + { name: 'db', enabled: true, status: McpServerStatus.Ready }, + { name: 'search', enabled: true, status: McpServerStatus.Ready }, + ], + }, + { + resource: URI.parse('agent-host-copilotcli:/other-1'), + servers: [ + { name: 'db', enabled: true, status: McpServerStatus.Ready }, + { name: 'db', enabled: true, status: McpServerStatus.Ready }, + { name: 'search', enabled: false, status: McpServerStatus.Ready }, + ], + }, + { + resource: URI.parse('agent-host-claude:/other-2'), + servers: [ + { name: 'db', enabled: true, status: McpServerStatus.Ready }, + { name: 'search', enabled: true, status: McpServerStatus.Stopped }, + ], + }, + ]; + + assert.deepStrictEqual([...countRunningMcpServersInOtherSessions(current, sessions)], [['db', 2]]); + }); +}); diff --git a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts index aee39b19d5959e..91bb9698c13a15 100644 --- a/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts +++ b/src/vs/workbench/contrib/notebook/browser/view/cellParts/cellToolbarStickyScroll.ts @@ -12,6 +12,13 @@ export function registerCellToolbarStickyScroll(notebookEditor: INotebookEditor, const min = opts?.min ?? 0; const updateForScroll = () => { + // Re-resolve the captured cell against the editor's current view model. The + // scroll listener can outlive the cell's membership (e.g. the cell was removed + // or the pooled editor widget was reattached to a different notebook), in which + // case `getAbsoluteTopOfElement` below would throw an "Invalid index -1" ListError. + if (notebookEditor.getCellByHandle(cell.handle) !== cell) { + return; + } if (cell.isInputCollapsed) { element.style.top = ''; } else { diff --git a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css index cf385c11e43f3d..b706b7f3414da1 100644 --- a/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css +++ b/src/vs/workbench/contrib/onboarding/browser/media/spotlight.css @@ -130,9 +130,15 @@ .spotlight-callout-actions { display: flex; + flex-wrap: wrap; gap: var(--vscode-spacing-size80); } +.spotlight-callout-actions .monaco-button { + width: fit-content; + white-space: nowrap; +} + /* Respect reduced-motion: drop the animated hole transition. */ @media (prefers-reduced-motion: reduce) { .spotlight-hole { diff --git a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts index 8567e8ecd9e9a0..55827d1b6a1a6e 100644 --- a/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts +++ b/src/vs/workbench/contrib/onboarding/browser/onboarding.contribution.ts @@ -46,7 +46,7 @@ function buildDeveloperModeConfigurationNode(): IConfigurationNode { properties, additionalProperties: { type: 'boolean' }, tags: ['experimental'], - description: localize('onboarding.developerMode', "Map of onboarding scenario/tour id to whether developer mode is enabled for it. When enabled for a scenario, that onboarding tour ignores usage-based eligibility checks (such as how many sessions you have started) and previously persisted shown state. The tour is still shown at most once per window session, so reload the window to show it again.") + description: localize('onboarding.developerMode', "Map of onboarding scenario/tour id to whether developer mode is enabled for it. When enabled for a scenario, that onboarding tour ignores usage-based eligibility checks (such as how many sessions you have started), previously persisted shown state, and any linked experiment (so it is shown even if the experiment is not running or you are in the control group). It does not override the {0} setting. The tour is still shown at most once per window session, so reload the window to show it again.", `\`#${ONBOARDING_ENABLED_CONFIG}#\``) } } }; @@ -59,7 +59,7 @@ configurationRegistry.registerConfiguration({ properties: { [ONBOARDING_ENABLED_CONFIG]: { type: 'boolean', - default: false, + default: true, description: localize('onboarding.enabled', "When enabled, onboarding tours and hints may appear automatically to highlight features. Disabling this does not affect tours you start manually.") } } diff --git a/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts index e57d033fe407e1..0d8f905961884c 100644 --- a/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts +++ b/src/vs/workbench/contrib/onboarding/browser/onboardingService.ts @@ -104,6 +104,7 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding // flags resolve. The filter blocks any id with the reserved onboarding prefix unless its // gate has already been opened (this session or persisted from a previous one). this.assignmentService.addTelemetryAssignmentFilter({ + id: 'onboarding', exclude: assignment => assignment.startsWith(ONBOARDING_ASSIGNMENT_CONTEXT_PREFIX) && !this._openedAssignmentContextIds.has(assignment), onDidChange: this._onDidChangeOpenedIds.event }); @@ -181,6 +182,13 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding //#region Eligibility & scheduling + /** + * The master switch for *automatic* onboarding. When `onboarding.enabled` is + * explicitly `false`, no scenario ever runs automatically (developer mode does + * NOT override this — see {@link _evaluate}). Any other value (including unset) + * is treated as enabled. On-demand {@link runScenario} is intentionally exempt + * from this switch. + */ private get _enabled(): boolean { return this.configurationService.getValue<boolean>(ONBOARDING_ENABLED_CONFIG) !== false; } @@ -193,10 +201,25 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding * Re-evaluate every scenario and enqueue any that are eligible to run * automatically. Idempotent: already shown / queued scenarios are skipped. * + * The automatic eligibility rules are: + * 1. If `onboarding.enabled` is `false`, nothing runs automatically — this + * method returns immediately, and developer mode does NOT override it. + * 2. If a scenario declares an `experiment`, it only runs when the experiment + * is active AND the user is in the treatment arm (see below) — OR when + * developer mode is enabled for that scenario, which bypasses the experiment + * gate so the tour can be previewed locally. + * 3. If a scenario has no `experiment`, it runs for every user that meets its + * `when`/trigger criteria (the typical state once an experiment has graduated + * and the tour is rolled out to everyone). + * * For an experiment-active scenario, reaching eligibility *is* the "would-show" * moment: the telemetry gate is opened for the experiment's assignment-context id * (in both arms), and then only the treatment arm is enqueued to actually show the * tour. Control opens the gate but renders nothing and is not marked as shown. + * + * Developer mode is the exception: it shows the tour unconditionally and never + * opens the telemetry gate, so a local preview can never affect the experiment + * scorecard regardless of which arm the developer happens to be assigned to. */ private _evaluate(): void { if (!this._enabled || this._stopped) { @@ -229,8 +252,11 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding } const experiment = scenario.experiment ? this._experimentStates.get(scenario.id) : undefined; - if (experiment?.active) { + if (experiment?.active && !this._isDeveloperMode(scenario.id)) { // Would-show reached: start emitting the assignment-context id from now on. + // Skipped entirely in developer mode so a local preview never opens the + // telemetry gate and never affects the experiment scorecard (the tour is + // shown unconditionally below instead). this._openGate(experiment.assignmentContextId); if (!experiment.behavior) { // Control arm: the identifier now flows, but no tour is shown and the @@ -267,7 +293,12 @@ export class OnboardingScenarioService extends Disposable implements IOnboarding // Experiment-driven scenarios only run once the experiment is active (both treatment // flags resolved). The behavior flag does NOT gate eligibility — control still reaches // the would-show moment so the gate opens for it too. - if (scenario.experiment && this._experimentStates.get(scenario.id)?.active !== true) { + // + // Developer mode for this scenario bypasses the experiment gate entirely so the tour + // can be tested locally without the experiment running (or being assigned to the + // user). A developer-mode preview never opens the assignment-context gate (see + // `_evaluate`), so it never pollutes the scorecard. + if (scenario.experiment && this._experimentStates.get(scenario.id)?.active !== true && !this._isDeveloperMode(scenario.id)) { return false; } diff --git a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts index 6ced326c81a701..06012632e191fa 100644 --- a/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts +++ b/src/vs/workbench/contrib/onboarding/browser/spotlight/spotlightOverlay.ts @@ -137,7 +137,8 @@ export class SpotlightOverlay extends Disposable { const actions = append(footer, $('.spotlight-callout-actions')); this._skipButton = this._register(new Button(actions, { ...defaultButtonStyles, secondary: true })); - this._skipButton.label = localize('spotlight.skip', "Skip"); + this._skipButton.label = localize('spotlight.endTour', "End Tour"); + this._skipButton.setTitle(localize('spotlight.endTour.tooltip', "End Tour (Esc)")); this._register(this._skipButton.onDidClick(() => this._onDidSkip.fire(OnboardingDismissReason.SkipButton))); this._backButton = this._register(new Button(actions, { ...defaultButtonStyles, secondary: true })); @@ -148,7 +149,12 @@ export class SpotlightOverlay extends Disposable { this._nextButton.label = localize('spotlight.next', "Next"); this._register(this._nextButton.onDidClick(() => this._onDidClickNext.fire('button'))); - // Keyboard handling on the callout: Esc skips, focus is trapped within. + // Buttons swallow Escape internally, so route their escape events to end the tour too. + for (const button of [this._skipButton, this._backButton, this._nextButton]) { + this._register(button.onDidEscape(() => this._onDidSkip.fire(OnboardingDismissReason.EscapeKey))); + } + + // Keyboard handling on the callout: Esc ends the tour, focus is trapped within. this._register(addDisposableListener(this._callout, EventType.KEY_DOWN, e => this._onKeyDown(e))); this._register({ dispose: () => this._restoreFocus() }); diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts index 03413088e175d4..953c73a253a872 100644 --- a/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenario.ts @@ -66,7 +66,9 @@ export const ONBOARDING_ASSIGNMENT_CONTEXT_PREFIX = 'onb-'; * with {@link ONBOARDING_ASSIGNMENT_CONTEXT_PREFIX}. If either flag is missing or the id lacks * the required prefix, the experiment is **inactive** and the scenario does not run * automatically (it can still be started explicitly via a command/`runScenario`, which - * bypasses experiment gating and does not open the telemetry gate). + * bypasses experiment gating and does not open the telemetry gate; or by enabling + * `onboarding.developerMode` for the scenario, which previews the tour without opening the + * telemetry gate). */ export interface IOnboardingExperiment { /** diff --git a/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts index 63d29aa8395c14..7f2f0c2f74842c 100644 --- a/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts +++ b/src/vs/workbench/contrib/onboarding/common/onboardingScenarioService.ts @@ -14,12 +14,24 @@ export const ONBOARDING_ENABLED_CONFIG = 'onboarding.enabled'; /** * Developer/advanced setting. A map of scenario/tour id to a boolean: when a - * scenario's flag is `true`, that scenario bypasses usage-based eligibility - * gating (e.g. the new-session tour's "first few sessions" check) so it can be - * triggered on demand for testing. The scenario is still shown at most once per - * window session (tracked in memory, not persisted), so reload the window to - * replay it — the "Reset Onboarding Shown State" command only clears the - * persisted state and does not affect developer-mode replays. + * scenario's flag is `true`, that scenario bypasses the gating that normally + * keeps it from showing, so it can be triggered on demand for testing. Concretely, + * developer mode for a scenario: + * - bypasses usage-based eligibility gating (e.g. the new-session tour's "first + * few sessions" check), + * - bypasses the once-per-user persisted "shown" state, and + * - bypasses the experiment gate: a tour with a linked experiment will show even + * if the experiment is not running, or the user is in the control arm. A + * developer-mode preview never opens the assignment-context telemetry gate (in + * any arm), so it can never affect an experiment scorecard. + * + * Developer mode does NOT override the global `onboarding.enabled` switch: when + * onboarding is disabled, nothing shows automatically regardless of this setting. + * + * The scenario is still shown at most once per window session (tracked in memory, + * not persisted), so reload the window to replay it — the "Reset Onboarding Shown + * State" command only clears the persisted state and does not affect developer-mode + * replays. * * The default value lists every registered scenario id set to `false`, so the * available ids are discoverable (and toggleable) in the settings editor. diff --git a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts index 314cb622105058..cc5208cc33a700 100644 --- a/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts +++ b/src/vs/workbench/contrib/onboarding/test/browser/onboardingService.test.ts @@ -436,6 +436,57 @@ suite('OnboardingScenarioService', () => { ); }); + test('developer mode shows an experiment scenario whose experiment is not active', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + // Neither treatment flag resolves: the experiment is inactive, so it would not run + // automatically — but developer mode bypasses the experiment gate. + const assignment = new FakeAssignmentService({}); + registerScenario({ + id: 'exp-dev-inactive', + experiment: { behaviorFlag: 'exp.show', assignmentContextIdFlag: 'exp.id' }, + trigger: { kind: 'auto' }, + presentation: { kind: presentation.kind, payload: undefined } + }); + + const { service } = createService({ [ONBOARDING_DEVELOPER_MODE_CONFIG]: { 'exp-dev-inactive': true } }, assignment); + service.start(); + await timeout(0); + await timeout(0); + + // The tour is shown, but since the experiment is not active no telemetry gate is + // opened (there is no assignment-context id to flow). + assert.deepStrictEqual( + { runs: presentation.runs, excluded: assignment.isExcluded('onb-tour-q3') }, + { runs: ['exp-dev-inactive'], excluded: true } + ); + }); + + test('developer mode shows the tour even when the user is in the control arm', async () => { + const presentation = new RecordingPresentation(uniqueKind()); + registerPresentation(presentation); + const assignment = new FakeAssignmentService({ 'exp.show': false, 'exp.id': 'onb-tour-q3' }); + registerScenario({ + id: 'exp-dev-control', + experiment: { behaviorFlag: 'exp.show', assignmentContextIdFlag: 'exp.id' }, + trigger: { kind: 'auto' }, + presentation: { kind: presentation.kind, payload: undefined } + }); + + const { service } = createService({ [ONBOARDING_DEVELOPER_MODE_CONFIG]: { 'exp-dev-control': true } }, assignment); + service.start(); + await timeout(0); + await timeout(0); + + // Developer mode shows the tour unconditionally and never opens the telemetry + // gate, so the assignment-context id stays excluded even though the user is in + // the (active) control arm — a local preview can't affect the scorecard. + assert.deepStrictEqual( + { runs: presentation.runs, excluded: assignment.isExcluded('onb-tour-q3') }, + { runs: ['exp-dev-control'], excluded: true } + ); + }); + test('an opened gate persists so the id keeps flowing after a reload', async () => { const presentation = new RecordingPresentation(uniqueKind()); registerPresentation(presentation); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 758c8353e4938d..2f6f2a0612ce84 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -265,7 +265,6 @@ export const tocData: ITOCEntry<string> = { 'chat.useHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', - 'chat.sendElementsToChat.*', 'chat.useClaudeMdFile' ] }, diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index 7a2a233c8de48a..572e5ab77b99b6 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -24,7 +24,6 @@ import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js' import { IUserDataSyncWorkbenchService } from '../../../services/userDataSync/common/userDataSync.js'; interface IConfiguration extends IWindowsConfiguration { - update?: { mode?: string }; debug?: { console?: { wordWrap?: boolean } }; editor?: { accessibilitySupport?: 'on' | 'off' | 'auto' }; security?: { workspace?: { trust?: { enabled?: boolean } }; restrictUNCAccess?: boolean }; @@ -37,6 +36,7 @@ interface IConfiguration extends IWindowsConfiguration { enabled?: boolean; claudeAgent?: { enabled?: boolean }; codexAgent?: { enabled?: boolean }; + byokModels?: { enabled?: boolean }; otel?: { enabled?: boolean; exporterType?: string; @@ -62,7 +62,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo 'window.nativeFullScreen', 'window.clickThroughInactive', 'window.controlsStyle', - 'update.mode', 'editor.accessibilitySupport', 'security.workspace.trust.enabled', 'workbench.enableExperiments', @@ -74,6 +73,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo 'chat.agentHost.enabled', 'chat.agentHost.claudeAgent.enabled', 'chat.agentHost.codexAgent.enabled', + 'chat.agentHost.byokModels.enabled', 'chat.agents.claude.preferAgentHost', 'chat.editor.claude.preferAgentHost', 'chat.agentHost.otel.enabled', @@ -90,7 +90,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly nativeFullScreen = new ChangeObserver('boolean'); private readonly clickThroughInactive = new ChangeObserver('boolean'); private readonly controlsStyle = new ChangeObserver('string'); - private readonly updateMode = new ChangeObserver('string'); private accessibilitySupport: 'on' | 'off' | 'auto' | undefined; private readonly workspaceTrustEnabled = new ChangeObserver('boolean'); private readonly experimentsEnabled = new ChangeObserver('boolean'); @@ -102,6 +101,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private readonly agentHostEnabled = new ChangeObserver('boolean'); private readonly agentHostClaudeAgentEnabled = new ChangeObserver('boolean'); private readonly agentHostCodexAgentEnabled = new ChangeObserver('boolean'); + private readonly agentHostByokModelsEnabled = new ChangeObserver('boolean'); private readonly agentsClaudePreferAgentHost = new ChangeObserver('boolean'); private readonly editorClaudePreferAgentHost = new ChangeObserver('boolean'); private readonly agentHostOTelEnabled = new ChangeObserver('boolean'); @@ -172,9 +172,6 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Windows/Linux: Window controls style processChanged(!isMacintosh && this.controlsStyle.handleChange(config.window?.controlsStyle)); - // Update mode - processChanged(this.updateMode.handleChange(config.update?.mode)); - // On linux turning on accessibility support will also pass this flag to the chrome renderer, thus a restart is required if (isLinux && typeof config.editor?.accessibilitySupport === 'string' && config.editor.accessibilitySupport !== this.accessibilitySupport) { this.accessibilitySupport = config.editor.accessibilitySupport; @@ -207,6 +204,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo // Agent Host processChanged(this.agentHostEnabled.handleChange(config.chat?.agentHost?.enabled)); + processChanged(this.agentHostByokModelsEnabled.handleChange(config.chat?.agentHost?.byokModels?.enabled)); // Claude and Codex provider registration in the agent host is read at spawn // time, and the two `preferAgentHost` gates pick which Claude implementation diff --git a/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts b/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts index daf0ed234bc69a..52926459424709 100644 --- a/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts +++ b/src/vs/workbench/contrib/relauncher/test/browser/relauncher.test.ts @@ -92,6 +92,17 @@ suite('SettingsChangeRelauncher', () => { assert.strictEqual(restartCount, 1, 'should restart when confirmed'); }); + test('prompts to restart when chat.agentHost.byokModels.enabled changes', async () => { + confirmResult = true; + await changeSetting( + 'chat.agentHost.byokModels.enabled', + () => ({ chat: { agentHost: { byokModels: { enabled: true } } } }), + c => c.chat.agentHost.byokModels.enabled = false); + + assert.strictEqual(confirmCount, 1, 'should prompt to restart'); + assert.strictEqual(restartCount, 1, 'should restart when confirmed'); + }); + test('prompts to restart when chat.agents.claude.preferAgentHost changes', async () => { confirmResult = true; await changeSetting( diff --git a/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts b/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts index 7d239d13157838..7d9546fd66642b 100644 --- a/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts +++ b/src/vs/workbench/contrib/scm/browser/quickDiffModel.ts @@ -95,7 +95,7 @@ export class QuickDiffModelService implements IQuickDiffModelService { export class QuickDiffModel extends Disposable { - private readonly _model: ITextFileEditorModel; + private readonly _model: IResolvedTextFileEditorModel; private readonly _originalEditorModels = new ResourceMap<IResolvedTextEditorModel>(); private readonly _originalEditorModelsDisposables = this._register(new DisposableStore()); get originalTextModels(): Iterable<ITextModel> { @@ -116,6 +116,13 @@ export class QuickDiffModel extends Disposable { private _changes: QuickDiffChange[] = []; get changes(): QuickDiffChange[] { return this._changes; } + private _changesVersionId: number = 0; + /** + * The version id of the modified text model that {@link changes} were + * computed against. Matches {@link ITextModel.getVersionId}. + */ + get changesVersionId(): number { return this._changesVersionId; } + /** * Map of quick diff name to the index of the change in `this.changes` */ @@ -138,6 +145,7 @@ export class QuickDiffModel extends Disposable { ) { super(); this._model = textFileModel; + this._changesVersionId = textFileModel.textEditorModel.getVersionId(); this._register(textFileModel.textEditorModel.onDidChangeContent(() => this.triggerDiff())); this._register( @@ -155,7 +163,7 @@ export class QuickDiffModel extends Disposable { this._quickDiffs = []; this._originalEditorModels.clear(); this._quickDiffsPromise = undefined; - this.setChanges([], [], new Map()); + this.setChanges([], [], new Map(), this._model.textEditorModel.getVersionId()); this.triggerDiff(); })); @@ -199,7 +207,7 @@ export class QuickDiffModel extends Disposable { const editorModel = this._originalEditorModels.get(originalUri); return editorModel ? { - modified: this._model.textEditorModel!, + modified: this._model.textEditorModel, original: editorModel.textEditorModel } : undefined; } @@ -224,40 +232,42 @@ export class QuickDiffModel extends Disposable { this._diffDelayer .trigger(async () => { - const result: { allChanges: QuickDiffChange[]; changes: QuickDiffChange[]; mapChanges: Map<string, number[]> } | null = await this.diff(); + const result: { allChanges: QuickDiffChange[]; changes: QuickDiffChange[]; mapChanges: Map<string, number[]>; versionId: number } | null = await this.diff(); const editorModels = Array.from(this._originalEditorModels.values()); if (!result || this._disposed || this._model.isDisposed() || editorModels.some(editorModel => editorModel.isDisposed())) { return; // disposed } - this.setChanges(result.allChanges, result.changes, result.mapChanges); + this.setChanges(result.allChanges, result.changes, result.mapChanges, result.versionId); }) .catch(err => onUnexpectedError(err)); } - private setChanges(allChanges: QuickDiffChange[], changes: QuickDiffChange[], mapChanges: Map<string, number[]>): void { + private setChanges(allChanges: QuickDiffChange[], changes: QuickDiffChange[], mapChanges: Map<string, number[]>, versionId: number): void { const diff = sortedDiff(this.changes, changes, (a, b) => compareChanges(a.change, b.change)); this._allChanges = allChanges; this._changes = changes; this._quickDiffChanges = mapChanges; + this._changesVersionId = versionId; this._onDidChange.fire({ changes, diff }); } - private diff(): Promise<{ allChanges: QuickDiffChange[]; changes: QuickDiffChange[]; mapChanges: Map<string, number[]> } | null> { + private diff(): Promise<{ allChanges: QuickDiffChange[]; changes: QuickDiffChange[]; mapChanges: Map<string, number[]>; versionId: number } | null> { const location = this.environmentService.isSessionsWindow ? ProgressLocation.Window : ProgressLocation.Scm; return this.progressService.withProgress({ location, delay: 250 }, async () => { + const versionId = this._model.textEditorModel.getVersionId(); const originalURIs = await this.getQuickDiffsPromise(); if (this._disposed || this._model.isDisposed() || (originalURIs.length === 0)) { // Disposed - return Promise.resolve({ allChanges: [], changes: [], mapChanges: new Map() }); + return Promise.resolve({ allChanges: [], changes: [], mapChanges: new Map(), versionId }); } const quickDiffs = originalURIs .filter(quickDiff => this.editorWorkerService.canComputeDirtyDiff(quickDiff.originalResource, this._model.resource)); if (quickDiffs.length === 0) { // All files are too large - return Promise.resolve({ allChanges: [], changes: [], mapChanges: new Map() }); + return Promise.resolve({ allChanges: [], changes: [], mapChanges: new Map(), versionId }); } const quickDiffPrimary = quickDiffs.find(quickDiff => quickDiff.kind === 'primary'); @@ -331,7 +341,7 @@ export class QuickDiffModel extends Disposable { map.get(providerId)!.push(i); } - return { allChanges: allDiffsSorted, changes: diffsSorted, mapChanges: map }; + return { allChanges: allDiffsSorted, changes: diffsSorted, mapChanges: map, versionId }; }); } diff --git a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts index 72514432fcf910..eea4b5e31c1309 100644 --- a/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts +++ b/src/vs/workbench/contrib/search/browser/anythingQuickAccess.ts @@ -30,7 +30,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IWorkbenchEditorConfiguration, EditorResourceAccessor, isEditorInput } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorService, SIDE_GROUP, ACTIVE_GROUP } from '../../../services/editor/common/editorService.js'; -import { Range, IRange } from '../../../../editor/common/core/range.js'; +import { IRange } from '../../../../editor/common/core/range.js'; import { ThrottledDelayer } from '../../../../base/common/async.js'; import { top } from '../../../../base/common/arrays.js'; import { FileQueryCacheState } from '../common/cacheState.js'; @@ -1109,7 +1109,7 @@ export class AnythingQuickAccessProvider extends PickerQuickAccessProvider<IAnyt const editorOptions: ITextEditorOptions = { preserveFocus: options.preserveFocus, pinned: options.keyMods?.ctrlCmd || options.forcePinned || this.configuration.openEditorPinned, - selection: options.range ? Range.collapseToStart(options.range) : undefined + selection: options.range }; const targetGroup = options.keyMods?.alt || (this.configuration.openEditorPinned && options.keyMods?.ctrlCmd) || options.forceOpenSideBySide ? SIDE_GROUP : ACTIVE_GROUP; diff --git a/src/vs/workbench/contrib/search/common/search.ts b/src/vs/workbench/contrib/search/common/search.ts index 6764f0ca1fa4e8..967b83bec232e9 100644 --- a/src/vs/workbench/contrib/search/common/search.ts +++ b/src/vs/workbench/contrib/search/common/search.ts @@ -150,8 +150,8 @@ export function getOutOfWorkspaceEditorResources(accessor: ServicesAccessor): UR return resources as URI[]; } -// Supports patterns of <path><#|:|(><line><#|:|,><col?><:?> -const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?\)?:?\s*$/; +// Supports patterns of <path><#|:|(><line><#|:|,><col?>> optionally followed by a range suffix <-<endLine><#|:|,><endCol?>> +const LINE_COLON_PATTERN = /\s?[#:\(](?:line )?(\d*)(?:[#:,](\d*))?(?:-(\d*)(?:[#:,](\d*))?)?\)?:?\s*$/; export interface IFilterAndRange { filter: string; @@ -194,6 +194,20 @@ export function extractRangeFromFilter(filter: string, unless?: string[]): IFilt endColumn: startColumn }; } + + // End Line Number (range selection, e.g. "20-40") + const endLineNumber = parseInt(patternMatch[3] ?? '', 10); + if (isNumber(endLineNumber)) { + + // End Column Number (e.g. "20:3-40:5"), defaults to the start of the end line + const endColumn = parseInt(patternMatch[4] ?? '', 10); + range = { + startLineNumber: range.startLineNumber, + startColumn: range.startColumn, + endLineNumber: endLineNumber, + endColumn: isNumber(endColumn) ? endColumn : 1 + }; + } } // User has typed "something:" or "something#" without a line number, in this case treat as start of file diff --git a/src/vs/workbench/contrib/search/test/common/extractRange.test.ts b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts index 9072b36cdaccc5..44f212bdaad102 100644 --- a/src/vs/workbench/contrib/search/test/common/extractRange.test.ts +++ b/src/vs/workbench/contrib/search/test/common/extractRange.test.ts @@ -46,6 +46,34 @@ suite('extractRangeFromFilter', () => { assert.strictEqual(res?.range.startColumn, 20); }); + suite('ranges', function () { + const base = '/some/path/file.txt'; + const testSpecs = [ + // line range: "20-40" + { filter: `${base}:20-40`, range: { startLineNumber: 20, startColumn: 1, endLineNumber: 40, endColumn: 1 } }, + // line and column range: "20:3-40:5" + { filter: `${base}:20:3-40:5`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // end column defaults to start of the end line: "20:3-40" + { filter: `${base}:20:3-40`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 1 } }, + // mixed separators: "20#3-40,5" + { filter: `${base}#20#3-40,5`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // paren style: "(20,3-40,5)" + { filter: `${base}(20,3-40,5)`, range: { startLineNumber: 20, startColumn: 3, endLineNumber: 40, endColumn: 5 } }, + // dangling separator falls back to single line: "20-" + { filter: `${base}:20-`, range: { startLineNumber: 20, startColumn: 1, endLineNumber: 20, endColumn: 1 } }, + ]; + for (const { filter, range } of testSpecs) { + test(filter, () => { + assert.deepStrictEqual(extractRangeFromFilter(filter), { filter: base, range }); + }); + } + + test('hyphen in path is not treated as a range', () => { + assert.ok(!extractRangeFromFilter('/some/path/my-file.txt')); + assert.ok(!extractRangeFromFilter('/some/path/file-2.txt')); + }); + }); + suite('unless', function () { const testSpecs = [ // alpha-only symbol after unless diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css index 1d5152b4469a48..595e5658a2cf6c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/activityBar.css @@ -17,20 +17,26 @@ * Turn the active item indicator into an inset, rounded background box. It is * pushed behind the icon (which lives on the action-label at z-index 1) so the * icon stays legible on top of the fill. The size is derived from the activity - * bar's own CSS variables so it works for both the normal (48px) and compact - * (36px wide / 32px tall) layouts. The square side tracks the action height - * (the limiting dimension), and `left` keeps it horizontally centered. + * bar's own CSS variables so it works for both the normal (44px) and compact + * (32px wide / 32px tall) layouts. Bar width equals action height in both sizes, + * so the box is a centered square and `left` keeps it horizontally centered. */ .style-override .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { z-index: 0; top: 0; - left: calc((var(--activity-bar-width, 48px) - var(--activity-bar-action-height, 48px) + 8px) / 2); - width: calc(var(--activity-bar-action-height, 48px) - 8px); - height: calc(var(--activity-bar-action-height, 48px) - 8px); + left: calc((var(--activity-bar-width, 44px) - var(--activity-bar-action-height, 44px) + 4px) / 2); + width: calc(var(--activity-bar-action-height, 44px) - 4px); + height: calc(var(--activity-bar-action-height, 44px) - 4px); border-radius: var(--vscode-cornerRadius-medium); background-color: var(--vscode-activityBar-activeBackground, var(--vscode-list-inactiveSelectionBackground)); } +/* Compact: minimal horizontal inset — 28×28px box centered in the 32px item. */ +.style-override .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .action-item.checked .active-item-indicator { + width: calc(var(--activity-bar-action-height, 32px) - 4px); + height: calc(var(--activity-bar-action-height, 32px) - 4px); +} + /* * Some color themes pick the activity bar foreground to contrast a distinctly * colored activity bar background. When that background is neutralized to match @@ -79,13 +85,20 @@ top: 0; bottom: 0; margin: auto; - left: calc((var(--activity-bar-width, 48px) - var(--activity-bar-action-height, 48px) + 8px) / 2); - width: calc(var(--activity-bar-action-height, 48px) - 8px); - height: calc(var(--activity-bar-action-height, 48px) - 8px); + left: calc((var(--activity-bar-width, 44px) - var(--activity-bar-action-height, 44px) + 4px) / 2); + width: calc(var(--activity-bar-action-height, 44px) - 4px); + height: calc(var(--activity-bar-action-height, 44px) - 4px); border-radius: var(--vscode-cornerRadius-medium); background-color: var(--vscode-list-hoverBackground); } +/* Compact: minimal horizontal inset — 28×28px box centered in the 32px item. */ +.style-override .activitybar.compact > .content:not(.dragged-over):not(.dragged-over-head):not(.dragged-over-tail) :not(.monaco-menu) > .monaco-action-bar .action-item:not(.checked):hover::before { + left: 2px; + width: calc(var(--activity-bar-action-height, 32px) - 4px); + height: calc(var(--activity-bar-action-height, 32px) - 4px); +} + /* * Horizontal Activity Bar — top / bottom position on the primary sidebar, panel * (bottom) and auxiliary bar. When the activity bar is moved to the top or @@ -234,6 +247,7 @@ */ .style-override.monaco-workbench .pane-composite-part:not(.empty) > .header { border-bottom: none; + overflow: visible; } .style-override.monaco-workbench .pane-composite-part:not(.empty) > .footer { @@ -270,8 +284,9 @@ top: 0px; } -.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .codicon { - padding: 4px 0; +.style-override.monaco-workbench .part.auxiliarybar > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before, +.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item .action-label::before { + position: relative; } /* @@ -291,6 +306,7 @@ display: none; } +.style-override.monaco-workbench .part.auxiliarybar > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .codicon, .style-override.monaco-workbench .pane-composite-part.basepanel > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .codicon { font-weight: var(--vscode-agents-fontWeight-regular); } @@ -301,3 +317,17 @@ gap: 4px; } +.style-override.monaco-workbench .pane-composite-part > .title > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact, +.style-override.monaco-workbench .pane-composite-part > .header-or-footer > .composite-bar-container > .composite-bar > .monaco-action-bar .action-item.icon .badge.compact { + overflow: visible; +} + +.style-override.monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { + top: 26px; + right: 2px; +} + +.style-override.monaco-workbench .activitybar.compact > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { + top: 17px; + right: 2px; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css index 80e8227c4d393d..3836e8843e8262 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/editorBorder.css @@ -25,7 +25,7 @@ .style-override .monaco-grid-view .part.editor:not(.modal-editor-part) { border: var(--vscode-strokeThickness) solid var(--vscode-editorGroup-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)); - border-radius: var(--vscode-cornerRadius-medium, 6px); + border-radius: var(--vscode-cornerRadius-large, 8px); box-sizing: border-box; overflow: hidden; } @@ -38,3 +38,32 @@ .style-override .part.editor:not(.modal-editor-part)::after { box-shadow: none; } + +/* + * Unify the surrounding chrome with the editor card: the floating side bar, + * auxiliary bar and bottom panel cards (see `browser/media/floatingPanels.css`) + * default to the `agentsPanel` border token. Re-point them at the same + * `editorGroup-border` token the editor card uses above so every top-level card + * shares one border color. Only the color is overridden; the width and radius + * from floatingPanels.css are kept. The extra `.monaco-workbench` class (both + * `.style-override` and `.floating-panels` sit on the workbench element) raises + * specificity above the equally-`!important` rule in floatingPanels.css. + */ +.monaco-workbench.style-override.floating-panels .part.sidebar, +.monaco-workbench.style-override.floating-panels .part.auxiliarybar, +.monaco-workbench.style-override.floating-panels .part.panel, +.monaco-workbench.style-override.floating-panels .part.editor { + border-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; +} + +.monaco-workbench.style-override .chat-view-position-left > .voice-agent-controls-wrapper { + .agent-sessions-container { + border-right-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; + } +} + +.monaco-workbench.style-override .chat-view-position-right > .voice-agent-controls-wrapper { + .agent-sessions-container { + border-left-color: var(--vscode-agentsPanel-border, color-mix(in srgb, var(--vscode-foreground) 12%, transparent)) !important; + } +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css index 8ecf29a8cf182a..c73f9d1f309bcf 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/fontRamp.css @@ -137,13 +137,14 @@ } /* - * File Explorer root header — respect the real casing of the workspace / folder - * name (user input). `text-transform: none` overrides both the base - * `uppercase` rule and the `capitalize` override above (the `:has(...)` - * argument raises specificity). System labels such as "Untitled (Workspace)" - * are already title-cased in source, so they still render correctly; only - * user-provided folder names (e.g. "vscode-dev") are left untouched. + * File Explorer titles — respect the real casing of the workspace / folder name + * (user input). `text-transform: none` overrides both the base `uppercase` rule + * and the `capitalize` override above (the `:has(...)` argument raises + * specificity). System labels such as "Untitled (Workspace)" are already + * title-cased in source, so they still render correctly; only user-provided + * folder names (e.g. "vscode-dev") are left untouched. */ +.style-override.monaco-workbench .part:not(.editor):has(> .content > .composite.explorer-viewlet) > .title > .title-label h2, .style-override .monaco-pane-view .pane > .pane-header:has(> .icon.codicon-explorer-view-icon) > .title { text-transform: none; } @@ -175,3 +176,7 @@ border-top-color: transparent !important; border-bottom-color: transparent !important; } + +.style-override.monaco-workbench .monaco-count-badge { + font-weight: var(--vscode-fontWeight-semiBold); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css new file mode 100644 index 00000000000000..ed7f1a53104847 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/notificationsDialogs.css @@ -0,0 +1,63 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Notifications and Dialogs". + * + */ + +.style-override.monaco-workbench .notifications-list-container .notification-list-item { + padding: 6px 2px; +} + +.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-icon { + font-size: var(--vscode-codiconFontSize); + margin: 0 8px 0 6px; +} + +.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, +.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { + margin: 0 4px 0 0; +} + +.style-override.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-source { + color: var(--vscode-descriptionForeground); + margin-left: 24px; +} + +.style-override .monaco-dialog-box { + padding: 4px; + min-width: 440px; +} + +.style-override .monaco-dialog-box:not(.align-vertical) .dialog-message-row .dialog-message-container { + padding-left: 8px; + padding-right: 20px; +} + +.style-override .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message { + margin: 2px 0 12px 0; + font-size: var(--vscode-fontSize-heading3); + font-weight: var(--vscode-agents-fontWeight-semiBold); +} + +.style-override .monaco-dialog-box .dialog-toolbar-row { + position: absolute; + top: 8px; + right: 8px; +} + +.style-override .monaco-dialog-box .dialog-footer-row { + padding: 0 8px; +} + +.style-override .monaco-dialog-box .dialog-message-row { + padding: 16px 8px 0px; +} + +.style-override .monaco-dialog-box > .dialog-buttons-row { + padding: 16px 0px 0px; +} + diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css index d68435903fd850..3364b14304c33e 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/padding.css @@ -33,30 +33,70 @@ /* Header: inset the section title / actions to line up with the rows below. */ .style-override .monaco-pane-view .pane:not(:has(> .pane-body.chat-viewpane)) > .pane-header { - padding-left: var(--vscode-spacing-size80, 8px); - padding-right: var(--vscode-spacing-size40, 4px); + padding-left: var(--vscode-spacing-size40, 4px); + padding-right: 0; + margin: 0 var(--vscode-spacing-size40, 4px); } /* Body: inset each row box, leaving the scrollbar pinned to the pane edge. */ .style-override .monaco-pane-view .pane-body:not(.chat-viewpane) .monaco-list-row { - left: var(--vscode-spacing-size80, 8px); - right: var(--vscode-spacing-size80, 8px); + left: var(--vscode-spacing-size40, 4px); + right: var(--vscode-spacing-size40, 4px); width: auto; } -/* Tighten the part title bar gutters: flush-left, small right inset. */ +/* Tighten the part title bar gutters: flush-left, small right inset. Reduce height from 35px to 32px. + * KEEP IN SYNC WITH: part.ts PartLayout.TITLE_HEIGHT_STYLE_OVERRIDE */ .style-override.monaco-workbench .part > .title { + height: 32px; padding-left: var(--vscode-spacing-size40, 4px); padding-right: var(--vscode-spacing-size40, 4px); } +.style-override.monaco-workbench .part > .title > .title-label { + line-height: 32px; +} + +.style-override.monaco-workbench .part > .title > .title-actions { + height: 32px; + padding-left: 4px; +} + +/* + * In the Run and Debug view give the header actions (play button + launch + * configuration dropdown) the extra room by letting the actions grow while the + * title label stays non-greedy. + */ +.style-override.monaco-workbench .part > .title:has(.start-debug-action-item) > .title-actions { + flex: 2; +} + .style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions { padding: 0 4px; } +.style-override.monaco-workbench .part.editor > .content .editor-group-container > .title .editor-actions .actions-container > li:last-child, +.style-override.monaco-workbench .part > .title .title-actions .actions-container > li:last-child { + margin-right: 0; +} + /* Inset the part title label (e.g. "Explorer") to align with the content below. */ .style-override.monaco-workbench .part > .title > .title-label { padding-left: var(--vscode-spacing-size80, 8px); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + min-width: 0; + flex: 1; +} + +/* + * Let the title label grow to fill the bar so its actions sit to the right, + * except in the Run and Debug view where the header actions (play button + + * launch configuration dropdown) need the horizontal space instead. + */ +.style-override.monaco-workbench .part > .title:not(:has(.start-debug-action-item)) > .title-label { + flex: 2; } .style-override.monaco-workbench .pane-body .agent-sessions-viewer .monaco-scrollable-element { @@ -67,8 +107,90 @@ .agent-session-item .agent-session-title-toolbar .actions-container { gap: 4px; } + &.chat-view-location-auxiliarybar { + .chat-view-title-inner { + padding: var(--vscode-spacing-size40, 4px); + } + } + &.chat-view-location-sidebar, &.chat-view-location-panel { + .chat-view-title-inner { + padding: 0 var(--vscode-spacing-size40, 4px) 0 var(--vscode-spacing-size80, 8px); + } + } } .style-override.monaco-workbench .monaco-pane-view .pane > .pane-header > .actions { margin-right: 0px; } + +.style-override.monaco-workbench .chat-viewpane.has-sessions-control .agent-sessions-container { + .agent-sessions-title-container { + padding-left: 12px; + padding-right: 0; + } +} + +/* Give the settings / profile (global) actions at the bottom of the default + * vertical activity bar a 4px bottom margin so they sit consistently above the + * window edge, matching the pane header margins. */ +.style-override.monaco-workbench .activitybar:not(.top):not(.bottom) > .content > :not(.composite-bar):last-child { + margin-bottom: calc(var(--vscode-spacing-size20, 2px) + var(--vscode-strokeThickness, 1px)); +} + +.style-override.monaco-workbench .part.basepanel.bottom .composite.title, +.style-override.monaco-workbench .part.basepanel.top .composite.title, +.style-override.monaco-workbench .part.basepanel.left .composite.title, +.style-override.monaco-workbench .part.basepanel.right .composite.title { + padding-right: 4px; + overflow: visible; +} + +.style-override.monaco-workbench .quick-input-titlebar { + padding: 2px 0 0; +} + +.style-override.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar { + margin-right: 0; +} + +.style-override.monaco-workbench .quick-input-list .quick-input-list-entry { + padding: 0 4px; +} + +.style-override.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-entry-keybinding { + margin-right: 0; +} + +.style-override.monaco-workbench .quick-input-list .quick-input-list-entry-action-bar .action-label:last-child { + margin-right: 0; + margin-left: 4px; +} + +.style-override.monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { + margin-right: 0; + margin-left: 4px; +} +.style-override.monaco-workbench .scm-view .monaco-tl-contents > div { + padding-right: 4px; +} + +.style-override.monaco-workbench .search-view .search-widgets-container { + margin: 0px 8px 0 2px; +} + +.style-override.monaco-workbench .extensions-viewlet > .header { + padding: 4px 8px; +} + +.style-override.monaco-workbench .extensions-viewlet > .extensions .extension-view-header .count-badge-wrapper { + margin-right: 6px; +} + +.style-override.monaco-workbench .monaco-count-badge { + padding: 4px 6px; + min-width: 19px; + min-height: 19px; +.style-override.monaco-workbench .interactive-session .interactive-input-part { + padding-bottom: 0; +} + diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css index e9acb2e41c96ac..ca4258ad7897ae 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/paneHeaders.css @@ -40,8 +40,8 @@ content: ""; position: absolute; top: 0; - left: 8px; - right: 8px; + left: 4px; + right: 4px; height: 1px; pointer-events: none; background-color: var(--vscode-sideBarSectionHeader-border, var(--vscode-panelSectionHeader-border, var(--vscode-panel-border))); @@ -63,7 +63,7 @@ * inline header background from the PaneView is overridden here; the hover tint * below still wins via its higher specificity. */ .style-override .monaco-pane-view .pane > .pane-header { - border-radius: var(--vscode-cornerRadius-medium); + border-radius: var(--vscode-cornerRadius-small); background-color: var(--vscode-sideBar-background, var(--vscode-panel-background)) !important; } @@ -101,3 +101,7 @@ .style-override .monaco-pane-view .pane > .pane-header:focus { outline-offset: -2px; } + +.style-override.monaco-workbench .part > .title > .title-actions .start-debug-action-item { + min-width: 36px; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css index 03f9dfc1518ec8..4d044ec96df916 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/roundedCorners.css @@ -59,8 +59,8 @@ } /* List / tree rows (including the notification center list rows) */ -.style-override .monaco-list .monaco-list-row, -.style-override .notifications-list-container .monaco-list-row { +.style-override .monaco-list .monaco-list-row:not(.separator), +.style-override .notifications-list-container .monaco-list-row:not(.separator) { border-radius: var(--vscode-cornerRadius-small) !important; } @@ -70,6 +70,28 @@ border-radius: var(--vscode-cornerRadius-small) !important; } +/* + * Split buttons (dropdown action items — e.g. the extension list/editor + * "Install"/"Disable" buttons). These connect a primary action label to a + * trailing dropdown chevron with a 1px separator between them. Rounding each + * label on all corners (via the rule above) puts radii on the inner seam where + * the two segments meet. Round only the outer edges of the pair — left corners + * on the primary label, right corners on the chevron — so the seam stays flat. + */ +.style-override .monaco-action-bar .action-item.action-dropdown-item > .action-label { + border-radius: var(--vscode-cornerRadius-small) 0 0 var(--vscode-cornerRadius-small) !important; +} + +.style-override .monaco-action-bar .action-item.action-dropdown-item > .monaco-dropdown .action-label { + border-radius: 0 var(--vscode-cornerRadius-small) var(--vscode-cornerRadius-small) 0 !important; +} + +/* Update indicator (title-bar "Update" button — base CSS uses medium, but as a + * control it belongs to the controls tier). */ +.style-override .monaco-action-bar .update-indicator { + border-radius: var(--vscode-cornerRadius-small) !important; +} + /* * Agent status pill (experimental title-bar widget). A connected pill/badge * group whose segments hardcode 5-6px per-corner radii, with a few intentional @@ -106,17 +128,17 @@ border-radius: 0 !important; } -/* Scrollbar sliders */ +/* Scrollbar sliders (panels, editor, lists — all consistent) */ .style-override .monaco-scrollable-element > .scrollbar > .slider { border-radius: var(--vscode-cornerRadius-small) !important; } -/* - * The editor's own scrollbars sit flush against the viewport edge (next to the - * minimap); rounding their sliders looks odd, so keep those square. - */ -.style-override .monaco-editor .monaco-scrollable-element > .scrollbar > .slider { - border-radius: 0 !important; +/* Terminal uses xterm.js' own scrollbar (.xterm-scrollable-element) */ +.style-override .xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider { + border-radius: var(--vscode-cornerRadius-small) !important; + /* Inset the slider so both left and right rounded corners are visible */ + left: 1px !important; + width: calc(100% - 1px) !important; } /* ============================================================================= @@ -138,7 +160,6 @@ .style-override .monaco-grid-view .part.sidebar, .style-override .monaco-grid-view .part.panel, .style-override .monaco-grid-view .part.auxiliarybar { - border-radius: var(--vscode-cornerRadius-medium) !important; overflow: hidden; } @@ -232,3 +253,14 @@ border-radius: var(--vscode-sash-size); } +.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .terminal-tabs-chat-entry .terminal-tabs-entry { + border-radius: var(--vscode-cornerRadius-small); +} + +.style-override.monaco-workbench .terminal-resize-overlay { + border-radius: var(--vscode-cornerRadius-large); +} + +.style-override.monaco-workbench .monaco-count-badge { + border-radius: var(--vscode-cornerRadius-circle); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css b/src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css new file mode 100644 index 00000000000000..95ef28a899ff82 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/sashHandles.css @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Sash Handles". + * + * With the Modern UI floating-panel treatment the workbench parts read as + * separate cards with a gap between them, which hides where one part ends and + * the next begins. Sashes are invisible until hovered, so the resize boundary + * is hard to find. This module gives every inter-panel sash a persistent, + * low-contrast grip — three small dots — so users can see where parts meet and + * where to grab to resize. The grip is suppressed for sashes within a part + * (editor splits, tree/view resizers) so only the boundaries between top-level + * parts are marked. On hover/active the grip fades out in favour of the + * existing full-length highlight (see base sash.css), so behaviour while + * dragging is unchanged. + * + * All rules are gated behind the `.style-override` class, which the + * StyleOverridesContribution toggles onto the workbench container(s) when the + * `workbench.experimental.modernUI` (Modern UI Update) setting is enabled. + * Without that class these rules never apply. + */ + +/* Persistent grip handle, centered on the sash, drawn via the spare pseudo. */ +.style-override .monaco-sash:not(.disabled)::after { + content: ''; + position: absolute; + pointer-events: none; + width: 2px; + height: 2px; + border-radius: 50%; + background: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); + opacity: 0.5; +} + +.monaco-enable-motion .style-override .monaco-sash:not(.disabled)::after { + transition: opacity 0.1s ease-out; +} + +/* Vertical sash: three dots stacked vertically, centered. */ +.style-override .monaco-sash.vertical:not(.disabled)::after { + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 -5px currentColor, 0 5px currentColor; + color: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); +} + +/* Horizontal sash: three dots in a row, centered. */ +.style-override .monaco-sash.horizontal:not(.disabled)::after { + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + box-shadow: -5px 0 currentColor, 5px 0 currentColor; + color: color-mix(in srgb, var(--vscode-foreground) 30%, transparent); +} + +/* While hovering/dragging, defer to the base full-length highlight. */ +.style-override .monaco-sash.hover:not(.disabled)::after, +.style-override .monaco-sash.active:not(.disabled)::after { + opacity: 0; +} + +/* Only mark boundaries between top-level parts. Sashes inside a part (editor + * splits, tree/view resizers) live within `.part`, so suppress their grip. */ +.style-override .part .monaco-sash::after { + content: none; +} + +/* Hide grips for collapsed parts so a flush boundary isn't marked (and the + * panel grip can't be clipped by the status bar). The workbench container + * carries `nopanel`/`nosidebar` when those parts are hidden: the panel sash is + * horizontal, the primary side bar sash is vertical. */ +.style-override.nopanel .monaco-sash.horizontal::after, +.style-override.nosidebar .monaco-sash.vertical::after { + content: none; +} + +@media (forced-colors: active) { + .style-override .monaco-sash:not(.disabled)::after, + .style-override .monaco-sash.vertical:not(.disabled)::after, + .style-override .monaco-sash.horizontal:not(.disabled)::after { + background: CanvasText; + color: CanvasText; + opacity: 1; + } +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css index 69573363f6377f..978cb4884eca16 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/scrollShadows.css @@ -45,23 +45,6 @@ --scroll-shadow-surface: var(--vscode-sideBar-background, var(--vscode-editor-background)); } -/* ============================================================================= - * Lists and trees. - * ========================================================================== */ -.style-override .monaco-list { - position: relative; -} - -.style-override .monaco-list > .monaco-scrollable-element::before, -.style-override .monaco-list > .monaco-scrollable-element::after { - content: ""; - position: absolute; - left: 0; - right: 0; - height: 12px; - pointer-events: none; - z-index: 10; -} /* ============================================================================= * Editor area. The main editor viewport scrolls inside `.editor-scrollable`; @@ -69,7 +52,6 @@ * stacking context and sit below it. Scope to the editor / panel parts so the * fade never leaks onto inline editors such as the chat input. * ========================================================================== */ -.style-override .part.editor .monaco-editor .editor-scrollable::before, .style-override .part.editor .monaco-editor .editor-scrollable::after { content: ""; position: absolute; @@ -81,10 +63,23 @@ z-index: 10; } -.style-override .part.editor .monaco-editor .editor-scrollable::before, -.style-override .part.panel .monaco-editor .editor-scrollable::before { +/* Editor top shadow: delegate to the built-in scroll-decoration element. */ +.style-override .part.editor .monaco-editor .scroll-decoration { + position: absolute; top: 0; - background: linear-gradient(to bottom, var(--scroll-shadow-surface), transparent); + left: 0; + height: 8px; + box-shadow: var(--vscode-editor-background) 0 8px 8px -8px inset; +} + +.style-override .part.panel .monaco-editor .editor-scrollable::after { + content: ""; + position: absolute; + left: 0; + right: 0; + height: 8px; + pointer-events: none; + z-index: 10; } .style-override .part.editor .monaco-editor .editor-scrollable::after, diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css b/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css index 3d2ede1bfca46f..5d1636d11d9b0c 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/shadows.css @@ -42,3 +42,7 @@ .style-override.monaco-workbench .part.editor .tabs-container > .tab.active { box-shadow: none !important; } + +.style-override.monaco-workbench:not(.hc-black):not(.hc-light) .terminal-resize-overlay { + box-shadow: var(--vscode-shadow-lg); +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css index 8aad1d9ed51900..c9eeda8207098b 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/tabs.css @@ -21,6 +21,12 @@ --editor-group-tab-height: 24px !important; } +/* Compact tab height: 20px tab + 4px top + 4px bottom padding = 28px total. + * 20px is the minimum to fit the tab action icons (16px codicon + 2px padding on each side). */ +.style-override .part.editor .title.tabs.compact-height { + --editor-group-tab-height: 20px !important; +} + .style-override .part.editor .tabs-container > .tab { background-color: color-mix(in srgb, var(--vscode-foreground) 5%, transparent) !important; border-right: none !important; @@ -33,6 +39,16 @@ --tab-border-top-color: transparent !important; } +.style-override .part.editor .tabs-container > .tab.tab-actions-left:not(.sticky-compact) { + padding: 0 10px 0 0 !important; +} + +.style-override .part.editor .tabs-container > .tab.sizing-fit { + width: auto !important; + min-width: 0 !important; + flex: 0 0 auto !important; +} + /* Center tabs vertically and strip the bottom border so the pills float. */ .style-override .part.editor .tabs-and-actions-container { --tabs-border-bottom-color: transparent !important; @@ -205,3 +221,16 @@ .style-override .settings-editor > .settings-header > .settings-header-controls { border-bottom: none !important; } + +.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-container.has-text .tabs-list .terminal-tabs-entry { + padding-left: 8px; + padding-right: 8px; +} + +.style-override.monaco-workbench .pane-body.integrated-terminal .terminal-tabs-chat-entry { + padding: 4px; +} + +.style-override.monaco-workbench .pane-body.integrated-terminal .tabs-list .codicon { + vertical-align: sub; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css b/src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css new file mode 100644 index 00000000000000..b3c14a1db149e2 --- /dev/null +++ b/src/vs/workbench/contrib/styleOverrides/browser/media/titlebar.css @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Style-override module: "Title Bar". + * + * Adds a small amount of top padding to the title-bar left/center/right + * containers so their items read as vertically centered. Scoped to macOS for + * now. All rules are gated behind the `.style-override` class, which the + * StyleOverridesContribution toggles onto the workbench container(s) when the + * Modern UI Update setting is enabled. + */ +.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-left, +.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-center, +.style-override.monaco-workbench.mac .part.titlebar > .titlebar-container > .titlebar-right { + padding-top: 2px; +} diff --git a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts index 381293ce3766bc..6c2392934386cc 100644 --- a/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts +++ b/src/vs/workbench/contrib/styleOverrides/browser/styleOverrides.contribution.ts @@ -9,6 +9,10 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IWorkbenchLayoutService, LayoutSettings } from '../../../services/layout/browser/layoutService.js'; import { Extensions as WorkbenchExtensions, IWorkbenchContribution, IWorkbenchContributionsRegistry } from '../../../common/contributions.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; +import { DEFAULT_SCROLLBAR_SIZE, setGlobalDefaultScrollbarSize } from '../../../../base/browser/ui/scrollbar/scrollableElement.js'; + +/** Reduced scrollbar size (px) applied when the style-override experiment is on. */ +const SCROLLBAR_OVERRIDE_SIZE = 8; // Bundle the CSS for every style-override module. Every file gates all of its // rules behind the single `.style-override` ancestor class, so the styles are @@ -18,13 +22,16 @@ import './media/commandCenter.css'; import './media/editorBorder.css'; import './media/fontRamp.css'; import './media/keyboardFocusOnly.css'; +import './media/notificationsDialogs.css'; import './media/padding.css'; import './media/paneHeaders.css'; import './media/roundedCorners.css'; +import './media/sashHandles.css'; import './media/scrollShadows.css'; import './media/shadows.css'; import './media/statusBar.css'; import './media/tabs.css'; +import './media/titlebar.css'; interface IStyleOverrideModule { readonly id: string; @@ -61,10 +68,13 @@ const STYLE_OVERRIDE_MODULES: readonly IStyleOverrideModule[] = [ { id: 'padding' }, { id: 'paneHeaders', layoutAffecting: true }, { id: 'roundedCorners' }, + { id: 'sashHandles' }, { id: 'scrollShadows' }, { id: 'shadows' }, { id: 'statusBar' }, - { id: 'tabs' } + { id: 'tabs' }, + { id: 'titlebar' }, + { id: 'notificationsDialogs' }, ]; /** @@ -134,17 +144,23 @@ export class StyleOverridesContribution extends Disposable implements IWorkbench for (const container of this.layoutService.containers) { this.applyTo(container, enabled); } + this.applyScrollbarSize(enabled); } private applyTo(container: HTMLElement, enabled: boolean): void { container.classList.toggle(STYLE_OVERRIDE_CLASS, enabled); } + private applyScrollbarSize(enabled: boolean): void { + setGlobalDefaultScrollbarSize(enabled ? SCROLLBAR_OVERRIDE_SIZE : DEFAULT_SCROLLBAR_SIZE); + } + override dispose(): void { // Remove the class this contribution added so it leaves no DOM state behind. for (const container of this.layoutService.containers) { container.classList.remove(STYLE_OVERRIDE_CLASS); } + setGlobalDefaultScrollbarSize(DEFAULT_SCROLLBAR_SIZE); super.dispose(); } } diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 178cdf324c1d71..935bd5236ad5de 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -845,6 +845,8 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { return Promise.reject(new Error('No task previously run')); } const workspaceFolder = this._currentTask.workspaceFolder = lastTask.workspaceFolder; + // Carry systemInfo forward, else a later rerun resolves the shell on the local host (#175118). + this._currentTask.systemInfo = lastTask.systemInfo; const variables = new Set<string>(); this._collectTaskVariables(variables, task); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts index b9b41e62084b26..88df3908f169de 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/agentHostSandboxForwarder.ts @@ -5,7 +5,8 @@ import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; import { equals } from '../../../../../base/common/objects.js'; -import { AgentHostCustomTerminalToolEnabledSettingId, AgentHostSdkSandboxEnabledSettingId, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostSdkSandboxEnabledSettingId, IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AgentHostSandboxConfigKey, AgentHostSandboxKey } from '../../../../../platform/agentHost/common/sandboxConfigSchema.js'; import { AgentSandboxEnabledValue } from '../../../../../platform/sandbox/common/settings.js'; 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 434a29dfe6e909..9453f4ba87a71c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -32,6 +32,7 @@ import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/wi import { IChatService, ChatRequestQueueKind, ElicitationState, type IChatExternalToolInvocationUpdate, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; import { autorun, constObservable, type IObservable } from '../../../../../../base/common/observable.js'; import { ChatModel, type IChatRequestModeInfo } from '../../../../chat/common/model/chatModel.js'; +import { ChatConfiguration, ChatPermissionLevel, isAutoApproveLevel } from '../../../../chat/common/constants.js'; import type { UserSelectedTools } from '../../../../chat/common/participants/chatAgents.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; @@ -112,7 +113,7 @@ export interface ISandboxingDisabledOptions { } export type ISandboxingOptions = ISandboxingOnOptions | ISandboxingDisabledOptions; -function createPowerShellModelDescription(shell: string, sandboxingOptions: ISandboxingOptions): string { +function createPowerShellModelDescription(shell: string, sandboxingOptions: ISandboxingOptions, includeElevationGuidance: boolean): string { const isWinPwsh = isWindowsPowerShell(shell); const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, @@ -165,6 +166,9 @@ function createPowerShellModelDescription(shell: string, sandboxingOptions: ISan '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', + ...(includeElevationGuidance ? [ + '- Avoid commands that trigger an interactive elevation prompt, such as Start-Process -Verb RunAs or runas.exe. They block on a UAC/password prompt that cannot be answered in this mode, and secrets must never be routed through the model. If elevated privileges are required, tell the user to run the command themselves and stop — do NOT retry the command with variations.', + ] : []), `- NEVER run Start-Sleep or similar wait commands. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Do NOT poll for completion.`, '- NEVER pipe interactive commands through Select-Object, Where-Object, or other filters — this hides prompts and prevents the terminal from detecting when input is needed. Run interactive commands without pipes.', '', @@ -269,7 +273,7 @@ export function createSandboxProperties(sandboxingOptions: ISandboxingOnOptions) }; } -function createGenericDescription(sandboxingOptions: ISandboxingOptions): string { +function createGenericDescription(sandboxingOptions: ISandboxingOptions, includeElevationGuidance: boolean): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -312,7 +316,7 @@ Best Practices: - Use find with -exec or xargs for file operations - Be specific with commands to avoid excessive output - Avoid printing credentials unless absolutely required -- NEVER run sleep or similar wait commands in a terminal. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Do NOT poll for completion. +${includeElevationGuidance ? '- Avoid commands that require interactive privilege escalation, such as sudo/su/doas without a non-interactive flag (e.g. sudo -n). They block on a password prompt that cannot be answered in this mode, and secrets must never be routed through the model. If a command needs elevated privileges, tell the user to run it themselves in the terminal and stop — do NOT retry the command with variations.\n' : ''}- NEVER run sleep or similar wait commands in a terminal. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Do NOT poll for completion. - NEVER pipe interactive commands through tail, head, grep, or other filters — this hides prompts and prevents the terminal from detecting when input is needed. Run interactive commands without pipes. Interactive Input Handling: @@ -325,19 +329,19 @@ Interactive Input Handling: return parts.join(''); } -function createBashModelDescription(sandboxingOptions: ISandboxingOptions): string { +function createBashModelDescription(sandboxingOptions: ISandboxingOptions, includeElevationGuidance: boolean): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(sandboxingOptions), + createGenericDescription(sandboxingOptions, includeElevationGuidance), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution' ].join('\n'); } -function createZshModelDescription(sandboxingOptions: ISandboxingOptions): string { +function createZshModelDescription(sandboxingOptions: ISandboxingOptions, includeElevationGuidance: boolean): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(sandboxingOptions), + createGenericDescription(sandboxingOptions, includeElevationGuidance), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -350,10 +354,10 @@ function createZshModelDescription(sandboxingOptions: ISandboxingOptions): strin ].join('\n'); } -function createFishModelDescription(sandboxingOptions: ISandboxingOptions): string { +function createFishModelDescription(sandboxingOptions: ISandboxingOptions, includeElevationGuidance: boolean): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(sandboxingOptions), + createGenericDescription(sandboxingOptions, includeElevationGuidance), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -371,6 +375,23 @@ export async function createRunInTerminalToolData( const configurationService = accessor.get(IConfigurationService); const allowToRunUnsandboxedCommands = configurationService.getValue<boolean>(AgentSandboxSettingId.AgentSandboxAllowUnsandboxedCommands) === true; const retryWithAllowNetworkRequestsSetting = configurationService.getValue<boolean>(AgentSandboxSettingId.AgentSandboxRetryWithAllowNetworkRequests) === true; + // Only steer the model away from interactive privilege-escalation commands when the session is + // (or defaults to) an auto-approving mode. In interactive mode the user can focus the terminal and + // type a password/UAC prompt directly (bypassing the model), which is a supported flow; in + // auto-approve/Bypass Approvals/Autopilot mode such prompts are cancelled since no human is + // available to answer them. + // + // Note: the tool description is computed once at registration, so it cannot observe the live, + // per-session permission level (which can change mid-session via the picker). We therefore use the + // best available static signals: the terminal auto-approve setting, the global auto-approve + // setting, and the default permission level for new sessions. Sessions switched into Bypass + // Approvals/Autopilot mid-session from an otherwise-interactive default are not covered by this + // static description. + const defaultPermissionLevel = configurationService.getValue<ChatPermissionLevel | undefined>(ChatConfiguration.DefaultPermissionLevel); + const includeElevationGuidance = + configurationService.getValue(TerminalChatAgentToolsSettingId.EnableAutoApprove) === true || + configurationService.getValue(ChatConfiguration.GlobalAutoApprove) === true || + isAutoApproveLevel(defaultPermissionLevel); const profileFetcher = instantiationService.createInstance(TerminalProfileFetcher); const [shell, os, isSandboxEnabled, isSandboxAllowNetworkEnabled] = await Promise.all([ @@ -399,13 +420,13 @@ export async function createRunInTerminalToolData( let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell, sandboxingOptions); + modelDescription = createPowerShellModelDescription(shell, sandboxingOptions, includeElevationGuidance); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(sandboxingOptions); + modelDescription = createZshModelDescription(sandboxingOptions, includeElevationGuidance); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(sandboxingOptions); + modelDescription = createFishModelDescription(sandboxingOptions, includeElevationGuidance); } else { - modelDescription = createBashModelDescription(sandboxingOptions); + modelDescription = createBashModelDescription(sandboxingOptions, includeElevationGuidance); } const sharedProperties: IJSONSchemaMap = { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index bca4856990da7c..3b888c716685a7 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -179,14 +179,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const remoteEnv = await this._resolveRemoteEnv(); if (remoteEnv) { // Remote workbench: server resolves a real `node` binary, no env prefix needed. - return { appRoot: remoteEnv.os === OperatingSystem.Windows ? this._toWindowsPath(remoteEnv.appRoot) : remoteEnv.appRoot.path, execPath: remoteEnv.execPath, runAsNode: false, arch: remoteEnv.arch }; + return { appRoot: remoteEnv.os === OperatingSystem.Windows ? this._toWindowsPath(remoteEnv.appRoot) : remoteEnv.appRoot.path, execPath: remoteEnv.execPath, runAsNode: false, arch: remoteEnv.arch, nativeModulesDir: 'node_modules' }; } // Local workbench: app root is local and exec path points at the Electron binary, // so the engine must prefix `ELECTRON_RUN_AS_NODE=1` when invoking it. const localAppRootUri = FileAccess.asFileUri(''); const localAppRoot = OS === OperatingSystem.Windows ? dirname(localAppRootUri.fsPath) : dirname(localAppRootUri.path); const nativeEnv = this._environmentService as IEnvironmentService & { execPath?: string }; - return { appRoot: localAppRoot, execPath: nativeEnv.execPath, runAsNode: true, arch }; + const nativeModulesDir = this._environmentService.isBuilt ? 'node_modules.asar.unpacked' : 'node_modules'; + return { appRoot: localAppRoot, execPath: nativeEnv.execPath, runAsNode: true, arch, nativeModulesDir }; } private _toWindowsPath(uri: URI): string { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts index 5f497b1eacd4b6..c899aa80692c12 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/agentHostSandboxForwarder.test.ts @@ -12,7 +12,8 @@ import { TestConfigurationService } from '../../../../../../platform/configurati import { ConfigurationTarget, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; -import { AgentHostCustomTerminalToolEnabledSettingId, AgentHostSdkSandboxEnabledSettingId, IAgentConnection, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostSdkSandboxEnabledSettingId, IAgentConnection, IAgentHostService } from '../../../../../../platform/agentHost/common/agentService.js'; +import { AgentHostCustomTerminalToolEnabledSettingId } from '../../../../../../platform/agentHost/common/copilotCliConfig.js'; import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { AgentHostConnectionsService } from '../../../../../../platform/agentHost/browser/agentHostConnectionsService.js'; import { IRemoteAgentHostService, IRemoteAgentHostConnectionInfo } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; 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 2c194efdc0869c..d8629b23db49dc 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 @@ -418,7 +418,7 @@ suite('TerminalSandboxService - network domains', () => { ok(configContent, 'Config file should be created'); const config = JSON.parse(configContent); - deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [], enabled: false }); + deepStrictEqual(config.network, { allowedDomains: [], deniedDomains: [], enabled: false, allowUnixSockets: true }); strictEqual(config.allowPty, true, 'Non-network runtime settings should still be merged'); }); diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts index acda57497e91de..03fff8db95b8ef 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkDetectorAdapter.ts @@ -88,6 +88,9 @@ export class TerminalLinkDetectorAdapter extends Disposable implements ILinkProv } const detectedLinks = await this._detector.detect(lines, startLine, endLine); + if (this._store.isDisposed) { + return []; + } for (const link of detectedLinks) { const terminalLink = this._createTerminalLink(link, async (event) => this._onDidActivateLink.fire({ link, event })); links.push(terminalLink); diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts index 3e7002c6980857..9b0622ddf75e4f 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminalLinkManager.ts @@ -5,7 +5,7 @@ import { EventType } from '../../../../../base/browser/dom.js'; import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { DisposableStore, dispose, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, dispose, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { isMacintosh, OS } from '../../../../../base/common/platform.js'; import { URI } from '../../../../../base/common/uri.js'; import * as nls from '../../../../../nls.js'; @@ -47,6 +47,7 @@ export class TerminalLinkManager extends DisposableStore { private readonly _linkProvidersDisposables: IDisposable[] = []; private readonly _externalLinkProviders: IDisposable[] = []; private readonly _openers: Map<TerminalLinkType, ITerminalLinkOpener> = new Map(); + private readonly _linkHoverInvalidationDisposable = this.add(new MutableDisposable<IDisposable>()); externalProvideLinksCb?: OmitFirstArg<ITerminalExternalLinkProvider['provideLinks']>; @@ -405,9 +406,14 @@ export class TerminalLinkManager extends DisposableStore { const widget = this._instantiationService.createInstance(TerminalHover, targetOptions, text, actions, linkHandler); const attached = this._widgetManager.attachWidget(widget); if (attached) { - link?.onInvalidated(() => attached.dispose()); + const store = new DisposableStore(); + store.add(attached); + if (link) { + store.add(link.onInvalidated(() => store.dispose())); + } + this._linkHoverInvalidationDisposable.value = store; + return store; } - return attached; } return undefined; } diff --git a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts index 51a7e4fbb8126d..7132cb0d596618 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/test/browser/terminalLinkManager.test.ts @@ -30,6 +30,12 @@ import { TestXtermLogger } from '../../../../../../platform/terminal/test/common import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { timeout } from '../../../../../../base/common/async.js'; import { IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { ILinkHoverTargetOptions } from '../../../../terminal/browser/widgets/terminalHoverWidget.js'; +import { TerminalWidgetManager } from '../../../../terminal/browser/widgets/widgetManager.js'; +import { TerminalLink } from '../../browser/terminalLink.js'; const defaultTerminalConfig: Partial<ITerminalConfiguration> = { fontFamily: 'monospace', @@ -221,6 +227,43 @@ suite('TerminalLinkManager', () => { })); }); + suite('link hover invalidation', () => { + type ShowHover = ( + targetOptions: ILinkHoverTargetOptions, + text: MarkdownString, + actions: undefined, + linkHandler: (url: string) => void, + link?: TerminalLink + ) => IDisposable | undefined; + + test('replacing or invalidating a link hover disposes the previous hover and its invalidation listener', () => { + instantiationService.stub(IHoverService, upcastPartial<IHoverService>({})); + + // Fake widget manager that records disposal of each attached hover and disposes the widget + const disposedAttached: boolean[] = []; + linkManager.setWidgetManager(upcastPartial<TerminalWidgetManager>({ + attachWidget: widget => { + const index = disposedAttached.push(false) - 1; + return { dispose: () => { disposedAttached[index] = true; widget.dispose(); } }; + } + })); + + const showHover = (linkManager as unknown as { _showHover: ShowHover })._showHover.bind(linkManager); + const onInvalidated1 = store.add(new Emitter<void>()); + const onInvalidated2 = store.add(new Emitter<void>()); + const link1 = upcastPartial<TerminalLink>({ onInvalidated: onInvalidated1.event }); + const link2 = upcastPartial<TerminalLink>({ onInvalidated: onInvalidated2.event }); + const targetOptions = upcastPartial<ILinkHoverTargetOptions>({}); + + // Showing a second link hover should dispose the first, then invalidating the second disposes it + showHover(targetOptions, new MarkdownString('hover'), undefined, () => { }, link1); + showHover(targetOptions, new MarkdownString('hover'), undefined, () => { }, link2); + onInvalidated2.fire(); + + deepStrictEqual(disposedAttached, [true, true]); + }); + }); + suite('getLinks and open recent link', () => { test('should return no links', async () => { const links = await linkManager.getLinks(); diff --git a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts index 60c417da5bbfde..01ce5480db2bbb 100644 --- a/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts +++ b/src/vs/workbench/contrib/terminalContrib/stickyScroll/browser/terminalStickyScrollOverlay.ts @@ -158,6 +158,7 @@ export class TerminalStickyScrollOverlay extends Disposable { if (this._state === state) { return; } + this._state = state; switch (state) { case OverlayState.Off: { this._setVisible(false); diff --git a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts index 6943deb22174cb..5fa0fd05f02ae1 100644 --- a/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/suggest/browser/terminalSuggestAddon.ts @@ -782,7 +782,6 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._logService.trace('SuggestAddon#_showCompletions setCompletionModel'); suggestWidget.setCompletionModel(model); - this._register(suggestWidget.onDidFocus(() => this._terminal?.focus())); if (!this._promptInputModel || !explicitlyInvoked && model.items.length === 0) { return; } @@ -825,6 +824,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest this._register(this._suggestWidget.onDidSelect(async e => this.acceptSelectedSuggestion(e))); this._register(this._suggestWidget.onDidHide(() => this._terminalSuggestWidgetVisibleContextKey.reset())); this._register(this._suggestWidget.onDidShow(() => this._terminalSuggestWidgetVisibleContextKey.set(true))); + this._register(this._suggestWidget.onDidFocus(() => this._terminal?.focus())); this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(TerminalSettingId.FontFamily) || e.affectsConfiguration(TerminalSettingId.FontSize) || e.affectsConfiguration(TerminalSettingId.LineHeight) || e.affectsConfiguration(TerminalSettingId.FontFamily) || e.affectsConfiguration('editor.fontSize') || e.affectsConfiguration('editor.fontFamily')) { this._onDidFontConfigurationChange.fire(); @@ -1045,7 +1045,7 @@ export class SuggestAddon extends Disposable implements ITerminalAddon, ISuggest hideSuggestWidget(cancelAnyRequest: boolean): void { this._discoverability?.resetTimer(); if (cancelAnyRequest) { - this._cancellationTokenSource?.cancel(); + this._cancellationTokenSource?.dispose(true); this._cancellationTokenSource = undefined; // Also cancel any pending resolution requests this._currentSuggestionDetails?.cancel(); diff --git a/src/vs/workbench/contrib/update/browser/update.ts b/src/vs/workbench/contrib/update/browser/update.ts index b757b86b82c53a..02d318ec8ad1a0 100644 --- a/src/vs/workbench/contrib/update/browser/update.ts +++ b/src/vs/workbench/contrib/update/browser/update.ts @@ -135,6 +135,16 @@ export function appendUpdateMenuItems(menuId: MenuId, group: string): void { when: CONTEXT_UPDATE_STATE.isEqualTo(StateType.Updating) }); + MenuRegistry.appendMenuItem(menuId, { + group, + command: { + id: 'update.cancelling', + title: nls.localize('cancellingUpdateMenuEntry', "Cancelling Update..."), + precondition: ContextKeyExpr.false() + }, + when: CONTEXT_UPDATE_STATE.isEqualTo(StateType.Cancelling) + }); + MenuRegistry.appendMenuItem(menuId, { group, order: 2, @@ -280,6 +290,8 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu badge = new ProgressBadge(() => nls.localize('downloading', "Downloading {0} update...", this.productService.nameShort)); } else if (state.type === StateType.Updating) { badge = new ProgressBadge(() => nls.localize('updating', "Updating {0}...", this.productService.nameShort)); + } else if (state.type === StateType.Cancelling) { + badge = new ProgressBadge(() => nls.localize('cancellingUpdate', "Cancelling {0} update...", this.productService.nameShort)); } this.badgeDisposable.clear(); @@ -298,6 +310,7 @@ export class UpdateContribution extends Disposable implements IWorkbenchContribu CommandsRegistry.registerCommand('update.downloading', () => { }); CommandsRegistry.registerCommand('update.install', () => this.updateService.applyUpdate()); CommandsRegistry.registerCommand('update.updating', () => { }); + CommandsRegistry.registerCommand('update.cancelling', () => { }); CommandsRegistry.registerCommand('update.restart', () => this.updateService.quitAndInstall()); CommandsRegistry.registerCommand('_update.state', () => { return this.state; diff --git a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts index a152d2bbac3d15..51aadc34eaaa97 100644 --- a/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts +++ b/src/vs/workbench/contrib/update/browser/updateTitleBarEntry.ts @@ -39,7 +39,7 @@ const DISABLED_REMINDER_PERIOD = 30 * 24 * 60 * 60 * 1000; // 30 days const UPDATE_TITLE_BAR_SETTING = 'update.titleBar'; const ACTIONABLE_STATES: readonly StateType[] = [StateType.AvailableForDownload, StateType.Downloaded, StateType.Ready]; -const DETAILED_STATES: readonly StateType[] = [...ACTIONABLE_STATES, StateType.CheckingForUpdates, StateType.Downloading, StateType.Updating, StateType.Overwriting]; +const DETAILED_STATES: readonly StateType[] = [...ACTIONABLE_STATES, StateType.CheckingForUpdates, StateType.Downloading, StateType.Updating, StateType.Overwriting, StateType.Cancelling]; /** * Optional secondary placement for the update indicator (e.g. used by the Agents @@ -193,6 +193,9 @@ export class UpdateTitleBarContribution extends Disposable implements IWorkbench case StateType.Overwriting: context = this.state.explicit; break; + case StateType.Cancelling: + context = true; + break; case StateType.Restarting: context = true; break; @@ -345,6 +348,11 @@ export class UpdateTitleBarEntry extends BaseActionViewItem { this.renderProgressState(this.content); break; + case StateType.Cancelling: + label.textContent = localize('updateIndicator.cancelling', "Cancelling..."); + this.renderProgressState(this.content); + break; + default: label.textContent = localize('updateIndicator.update', "Update"); break; diff --git a/src/vs/workbench/contrib/update/browser/updateTooltip.ts b/src/vs/workbench/contrib/update/browser/updateTooltip.ts index 85e34cca1a3936..a2230caed6f6c5 100644 --- a/src/vs/workbench/contrib/update/browser/updateTooltip.ts +++ b/src/vs/workbench/contrib/update/browser/updateTooltip.ts @@ -194,6 +194,9 @@ export class UpdateTooltip extends Disposable { case StateType.Overwriting: this.renderOverwriting(state); break; + case StateType.Cancelling: + this.renderCancelling(); + break; case StateType.Restarting: this.renderRestarting(state); break; @@ -365,6 +368,11 @@ export class UpdateTooltip extends Disposable { this.renderMessage(localize('updateTooltip.restartingPleaseWait', "Restarting to update, please wait...")); } + private renderCancelling() { + this.renderTitleAndInfo(localize('updateTooltip.cancellingTitle', "Cancelling Update")); + this.renderMessage(localize('updateTooltip.cancellingPleaseWait', "Cancelling update, please wait...")); + } + private renderTitleAndInfo(title: string, update?: IUpdate) { this.titleNode.textContent = title; diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css index 71733ede6473da..d2310e2c2e722d 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/media/gettingStarted.css @@ -187,8 +187,8 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .category-title { margin: 4px 0 4px; - font-size: 14px; - font-weight: 500; + font-size: var(--vscode-bodyFontSize); + font-weight: var(--vscode-agents-fontWeight-semiBold); text-align: left; display: inline-block; overflow: hidden; @@ -204,7 +204,7 @@ } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category.no-progress { - padding: 3px 6px; + padding: 4px 4px 4px 6px; } .monaco-workbench .part.editor > .content .gettingStartedContainer .getting-started-category.no-progress .category-progress { @@ -247,8 +247,8 @@ right: 0; top: 50%; transform: translateY(-50%); - padding: 3px; - border-radius: 5px; + padding: 4px; + border-radius: var(--vscode-cornerRadius-small); } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .recently-opened li { @@ -312,11 +312,11 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category { width: calc(100% - 16px); - font-size: 13px; + font-size: var(--vscode-bodyFontSize); box-sizing: border-box; line-height: normal; - margin: 8px 8px 8px 1px; - padding: 3px 6px 6px; + margin: 8px 8px 8px 0; + padding: 4px 6px; text-align: left; } @@ -340,7 +340,7 @@ } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .description-content > .codicon { - padding-right: 1px; + padding-right: 2px; font-size: 16px; } @@ -351,32 +351,41 @@ .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .new-badge { justify-self: flex-end; align-self: flex-start; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); padding: 2px 4px; margin: 4px; - font-size: 11px; + font-size: var(--vscode-bodyFontSize-xSmall); white-space: nowrap; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured-badge { - position: relative; - top: -4px; - left: -8px; + position: absolute; + top: 0; + left: 0; + z-index: 1; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured { - border-top: 30px solid var(--vscode-activityBarBadge-background); - width: 30px; - box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + width: 20px; height: 20px; - border-right: 40px solid transparent; - position: absolute; + border-top-left-radius: var(--vscode-cornerRadius-medium, 6px); + border-bottom-right-radius: var(--vscode-cornerRadius-medium, 6px); + background-color: var(--vscode-activityBarBadge-background); + box-sizing: border-box; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .featured .featured-icon { - top: -30px; - left: 4px; - font-size: 14px; + top: 0; + left: 0; + padding-right: 0; + font-size: 12px; + line-height: 12px; + height: 20px; + display: flex; + align-items: center; } .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlide .getting-started-category .codicon.hide-category-button { @@ -401,7 +410,7 @@ padding-right: 8px; max-width: 24px; max-height: 24px; - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-small); position: relative; top: auto; } @@ -1107,10 +1116,6 @@ border: inherit; } -.monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .progress-bar-outer { - background-color: var(--vscode-welcomePage-progress-background); -} - .monaco-workbench .part.editor > .content .gettingStartedContainer .gettingStartedSlideCategories .progress-bar-inner { background-color: var(--vscode-welcomePage-progress-foreground); } diff --git a/src/vs/workbench/electron-browser/actions/windowActions.ts b/src/vs/workbench/electron-browser/actions/windowActions.ts index e7b1f851486982..7cabd5de204f16 100644 --- a/src/vs/workbench/electron-browser/actions/windowActions.ts +++ b/src/vs/workbench/electron-browser/actions/windowActions.ts @@ -17,7 +17,8 @@ import { getIconClasses } from '../../../editor/common/services/getIconClasses.j import { ICommandHandler } from '../../../platform/commands/common/commands.js'; import { ServicesAccessor } from '../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; -import { INativeHostService } from '../../../platform/native/common/native.js'; +import { INativeHostService, FocusMode } from '../../../platform/native/common/native.js'; +import { IHostService } from '../../services/host/browser/host.js'; import { Codicon } from '../../../base/common/codicons.js'; import { ThemeIcon } from '../../../base/common/themables.js'; import { isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier } from '../../../platform/workspace/common/workspace.js'; @@ -406,6 +407,30 @@ export class SwitchToMainWindowAction extends Action2 { } } +export class FocusWindowAction extends Action2 { + + static readonly ID = 'workbench.action.focusWindow'; + + constructor() { + super({ + id: FocusWindowAction.ID, + title: localize2('focusWindow', "Focus Window"), + f1: true + }); + } + + override async run(accessor: ServicesAccessor): Promise<void> { + const hostService = accessor.get(IHostService); + + // Bring the current window to the foreground and focus it. `FocusMode.Force` is used because + // the application may not be active (for example when this runs from a system-wide keybinding + // while another app owns OS focus). This makes it usable as the first step of a `runCommands` + // chain that reveals the window before running a command which surfaces UI in it (e.g. Quick + // Open). + await hostService.focus(getActiveWindow(), { mode: FocusMode.Force }); + } +} + function canRunNativeTabsHandler(accessor: ServicesAccessor): boolean { if (!isMacintosh) { return false; diff --git a/src/vs/workbench/electron-browser/desktop.contribution.ts b/src/vs/workbench/electron-browser/desktop.contribution.ts index 9db62d8fa42efe..41250d7b714865 100644 --- a/src/vs/workbench/electron-browser/desktop.contribution.ts +++ b/src/vs/workbench/electron-browser/desktop.contribution.ts @@ -10,7 +10,7 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { KeyMod, KeyCode } from '../../base/common/keyCodes.js'; import { isLinux, isMacintosh, isWindows } from '../../base/common/platform.js'; import { ConfigureRuntimeArgumentsAction, ToggleDevToolsAction, ReloadWindowWithExtensionsDisabledAction, OpenUserDataFolderAction, ShowGPUInfoAction, ShowContentTracingAction, StopTracing, StartTracing, StartHeapTracing } from './actions/developerActions.js'; -import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, SwitchToMainWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler, ToggleWindowAlwaysOnTopAction, DisableWindowAlwaysOnTopAction, EnableWindowAlwaysOnTopAction, CloseOtherWindowsAction } from './actions/windowActions.js'; +import { ZoomResetAction, ZoomOutAction, ZoomInAction, CloseWindowAction, SwitchWindowAction, QuickSwitchWindowAction, SwitchToMainWindowAction, FocusWindowAction, NewWindowTabHandler, ShowPreviousWindowTabHandler, ShowNextWindowTabHandler, MoveWindowTabToNewWindowHandler, MergeWindowTabsHandlerHandler, ToggleWindowTabsBarHandler, ToggleWindowAlwaysOnTopAction, DisableWindowAlwaysOnTopAction, EnableWindowAlwaysOnTopAction, CloseOtherWindowsAction } from './actions/windowActions.js'; import { ContextKeyExpr } from '../../platform/contextkey/common/contextkey.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../platform/keybinding/common/keybindingsRegistry.js'; import { CommandsRegistry } from '../../platform/commands/common/commands.js'; @@ -42,6 +42,7 @@ import product from '../../platform/product/common/product.js'; registerAction2(SwitchWindowAction); registerAction2(QuickSwitchWindowAction); registerAction2(SwitchToMainWindowAction); + registerAction2(FocusWindowAction); registerAction2(CloseWindowAction); registerAction2(CloseOtherWindowsAction); registerAction2(ToggleWindowAlwaysOnTopAction); diff --git a/src/vs/workbench/electron-browser/desktop.main.ts b/src/vs/workbench/electron-browser/desktop.main.ts index 00f3ee08665271..03ed0459d0e98a 100644 --- a/src/vs/workbench/electron-browser/desktop.main.ts +++ b/src/vs/workbench/electron-browser/desktop.main.ts @@ -61,8 +61,6 @@ import { IUserDataProfileService } from '../services/userDataProfile/common/user import { BrowserSocketFactory } from '../../platform/remote/browser/browserSocketFactory.js'; import { RemoteSocketFactoryService, IRemoteSocketFactoryService } from '../../platform/remote/common/remoteSocketFactoryService.js'; import { ElectronRemoteResourceLoader } from '../../platform/remote/electron-browser/electronRemoteResourceLoader.js'; -import { RemoteFileSystemProxyServer } from '../../platform/files/electron-browser/remoteFileSystemProxyServer.js'; -import { RemoteFileSystemProxyClient } from '../../platform/files/electron-browser/remoteFileSystemProxyClient.js'; import { IConfigurationService } from '../../platform/configuration/common/configuration.js'; import { applyZoom } from '../../platform/window/electron-browser/window.js'; import { mainWindow } from '../../base/browser/window.js'; @@ -292,12 +290,6 @@ export class DesktopMain extends Disposable { // Remote Files this._register(RemoteFileSystemProviderClient.register(remoteAgentService, fileService, logService)); - // Remote File System Proxy - // Server: allows other windows to read remote files from this window's providers - this._register(new RemoteFileSystemProxyServer(mainProcessService, fileService)); - // Client: allows this window to read remote files from other windows' providers - this._register(RemoteFileSystemProxyClient.register(fileService, mainProcessService, logService, environmentService.remoteAuthority)); - // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // // NOTE: Please do NOT register services here. Use `registerSingleton()` diff --git a/src/vs/workbench/electron-browser/window.ts b/src/vs/workbench/electron-browser/window.ts index 1fd3b3c7380020..b60c0adc62c0fe 100644 --- a/src/vs/workbench/electron-browser/window.ts +++ b/src/vs/workbench/electron-browser/window.ts @@ -169,6 +169,11 @@ export class NativeWindow extends BaseWindow { args.push(resource); } } + } else if (request.from === 'systemWideKeybinding') { + // A system-wide (OS global) keybinding runs the command with exactly the arguments + // configured in `keybindings.json` (already in `request.args`). We intentionally do + // not append a `{ from }` sentinel so that commands taking positional arguments + // receive the same payload they would from a regular in-window keybinding. } else { args.push({ from: request.from }); } diff --git a/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts b/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts new file mode 100644 index 00000000000000..a7e8b530d5af85 --- /dev/null +++ b/src/vs/workbench/services/agentEditorComments/common/agentEditorComments.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { IRange } from '../../../../editor/common/core/range.js'; +import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const IAgentEditorCommentsBridge = createDecorator<IAgentEditorCommentsBridge>('agentEditorCommentsBridge'); + +/** A comment to render on top of an editor for a session-scoped resource. */ +export interface IAgentEditorComment { + readonly id: string; + readonly range: IRange; + readonly body: string; +} + +/** + * Supplies the session comments for a resource. Implemented by the sessions + * layer (backed by the agent feedback store) and registered into the bridge. + */ +export interface IAgentEditorCommentsProvider { + readonly onDidChangeComments: Event<void>; + /** Whether new comments can be added for the resource (i.e. it is in scope for a session). */ + acceptsComments(resource: URI): boolean; + getComments(resource: URI): readonly IAgentEditorComment[]; + addComment(resource: URI, range: IRange, body: string): void; + deleteComment(resource: URI, id: string): void; +} + +/** + * Workbench-layer seam that lets the (globally registered) main-thread + * extension host customer read and contribute session editor comments without + * depending on the sessions layer directly. When no provider is registered + * (e.g. the regular workbench window) the bridge is a no-op, so the customer + * degrades gracefully. + */ +export interface IAgentEditorCommentsBridge { + readonly _serviceBrand: undefined; + + /** Fired when comments change, or when a provider is registered/unregistered. */ + readonly onDidChangeComments: Event<void>; + + /** Whether new comments can be added for the resource. `false` when no provider is registered. */ + acceptsComments(resource: URI): boolean; + getComments(resource: URI): readonly IAgentEditorComment[]; + addComment(resource: URI, range: IRange, body: string): void; + deleteComment(resource: URI, id: string): void; + + /** Install the provider that backs this bridge. Only one provider is active at a time. */ + registerProvider(provider: IAgentEditorCommentsProvider): IDisposable; +} + +export class AgentEditorCommentsBridge extends Disposable implements IAgentEditorCommentsBridge { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeComments = this._register(new Emitter<void>()); + readonly onDidChangeComments = this._onDidChangeComments.event; + + private _provider: IAgentEditorCommentsProvider | undefined; + private readonly _providerListener = this._register(new MutableDisposable()); + + registerProvider(provider: IAgentEditorCommentsProvider): IDisposable { + this._provider = provider; + this._providerListener.value = provider.onDidChangeComments(() => this._onDidChangeComments.fire()); + this._onDidChangeComments.fire(); + return toDisposable(() => { + if (this._provider === provider) { + this._provider = undefined; + this._providerListener.clear(); + this._onDidChangeComments.fire(); + } + }); + } + + acceptsComments(resource: URI): boolean { + return this._provider?.acceptsComments(resource) ?? false; + } + + getComments(resource: URI): readonly IAgentEditorComment[] { + return this._provider?.getComments(resource) ?? []; + } + + addComment(resource: URI, range: IRange, body: string): void { + this._provider?.addComment(resource, range, body); + } + + deleteComment(resource: URI, id: string): void { + this._provider?.deleteComment(resource, id); + } +} + +registerSingleton(IAgentEditorCommentsBridge, AgentEditorCommentsBridge, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 3460d5048833d6..9733cc1425bb0e 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -13,10 +13,10 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { Disposable, IReference } from '../../../../base/common/lifecycle.js'; import { IObservable, ISettableObservable, observableValue } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, isAgentHostEnabled, IMcpNotification } from '../../../../platform/agentHost/common/agentService.js'; +import { AgentHostIpcChannels, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentHostInspectInfo, IAgentHostService, IAgentHostSocketInfo, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../../../../platform/agentHost/common/agentService.js'; +import { IAgentHostEnablementService } from '../../../../platform/agentHost/common/agentHostEnablementService.js'; import { AgentHostIpcChannelTransport } from '../../../../platform/agentHost/browser/agentHostIpcChannelTransport.js'; import { RemoteAgentHostProtocolClient } from '../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import type { IActiveSubscriptionInfo, IAgentSubscription } from '../../../../platform/agentHost/common/state/agentSubscription.js'; @@ -64,18 +64,18 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA constructor( @IRemoteAgentService remoteAgentService: IRemoteAgentService, - @IConfigurationService configurationService: IConfigurationService, + @IAgentHostEnablementService agentHostEnablementService: IAgentHostEnablementService, @IInstantiationService instantiationService: IInstantiationService, @ILogService private readonly _logService: ILogService, ) { super(); - const enabled = isAgentHostEnabled(configurationService); + const enabled = agentHostEnablementService.enabled; const connection = remoteAgentService.getConnection(); this._logService.info(`${LOG_PREFIX} Initializing (enabled=${enabled}, remoteAuthority=${connection?.remoteAuthority ?? 'none'})`); if (!enabled) { - this._logService.info(`${LOG_PREFIX} Disabled via "${AgentHostEnabledSettingId}" or web runtime. Not connecting.`); + this._logService.info(`${LOG_PREFIX} Disabled via "chat.agentHost.enabled" or web runtime. Not connecting.`); this.setAuthenticationPending(false); return; } diff --git a/src/vs/workbench/services/assignment/common/assignmentContextFilter.ts b/src/vs/workbench/services/assignment/common/assignmentContextFilter.ts new file mode 100644 index 00000000000000..20f085e050766d --- /dev/null +++ b/src/vs/workbench/services/assignment/common/assignmentContextFilter.ts @@ -0,0 +1,193 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import type { IAssignmentFilter } from './assignmentService.js'; + +/** + * Applies the registered {@link IAssignmentFilter assignment filters} to the assignment context + * and remembers, per filter, which assignment-context ids each filter excluded. + * + * The excluded ids are persisted so that they stay filtered out across reloads even before the + * owning filter has been registered again. Once a filter is registered it becomes authoritative + * for its own id: the persisted state is repopulated from the ids it currently excludes and any + * id that is no longer excluded is dropped from storage. + */ +export class AssignmentContextFilter extends Disposable { + + private static readonly STORAGE_KEY = 'workbench.assignment.filteredOutAssignmentContextIds'; + + private readonly _filters: IAssignmentFilter[] = []; + private readonly _filterDisposables = this._register(new DisposableStore()); + + /** + * Assignment-context ids that were filtered out, keyed by the id of the filter that + * excluded them. Entries owned by a filter that has not been registered yet are honored + * across reloads so those ids stay excluded until the filter takes over. + */ + private _cachedFilteredOutIds: Map<string, Set<string>>; + + /** + * Ids of filters that have been registered this session. Once a filter is registered it + * becomes authoritative for its own id and the persisted cache is repopulated from the + * ids it actually excludes. + */ + private readonly _registeredFilterIds = new Set<string>(); + + private readonly _onDidChange = this._register(new Emitter<void>()); + /** Fires when a filter is added or an already registered filter changes its exclusions. */ + readonly onDidChange: Event<void> = this._onDidChange.event; + + constructor( + private readonly storageService: IStorageService + ) { + super(); + + this._cachedFilteredOutIds = this._loadFilteredOutIds(); + } + + addFilter(filter: IAssignmentFilter): void { + this._filters.push(filter); + + // This filter now owns exclusion decisions for its id: drop the state persisted for it + // in a previous session and let it repopulate the cache as ids actually get filtered out. + this._registeredFilterIds.add(filter.id); + if (this._cachedFilteredOutIds.has(filter.id)) { + const next = new Map(this._cachedFilteredOutIds); + next.delete(filter.id); + this._storeFilteredOutIds(next); + } + + this._filterDisposables.add(filter.onDidChange(() => this._onDidChange.fire())); + this._onDidChange.fire(); + } + + /** + * Removes the excluded assignment-context ids from the given context and persists the + * reconciled per-filter cache. + */ + filter(assignmentContext: string): string { + const assignments = assignmentContext.split(';'); + + // Fresh exclusions produced by the currently registered filters, keyed by filter id. + const freshFilteredOut = new Map<string, Set<string>>(); + + const filteredAssignments = assignments.filter(assignment => { + let excluded = false; + for (const filter of this._filters) { + if (filter.exclude(assignment)) { + let set = freshFilteredOut.get(filter.id); + if (!set) { + set = new Set<string>(); + freshFilteredOut.set(filter.id, set); + } + set.add(assignment); + excluded = true; + } + } + if (excluded) { + return false; + } + + // Honor ids cached by filters that have not been registered yet, so they remain + // excluded until the owning filter takes over. + for (const [filterId, ids] of this._cachedFilteredOutIds) { + if (!this._registeredFilterIds.has(filterId) && ids.has(assignment)) { + return false; + } + } + + return true; + }); + + // Persist the reconciled cache: registered filters contribute exactly what they + // currently exclude (dropping ids that are no longer filtered out), while entries owned + // by not-yet-registered filters are preserved. + const next = new Map<string, Set<string>>(); + for (const [filterId, ids] of this._cachedFilteredOutIds) { + if (!this._registeredFilterIds.has(filterId)) { + next.set(filterId, ids); + } + } + for (const [filterId, ids] of freshFilteredOut) { + next.set(filterId, ids); + } + this._storeFilteredOutIds(next); + + return filteredAssignments.join(';'); + } + + private _loadFilteredOutIds(): Map<string, Set<string>> { + const result = new Map<string, Set<string>>(); + const raw = this.storageService.get(AssignmentContextFilter.STORAGE_KEY, StorageScope.APPLICATION); + if (!raw) { + return result; + } + + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object') { + for (const [filterId, ids] of Object.entries(parsed)) { + if (Array.isArray(ids)) { + const set = new Set(ids.filter((id): id is string => typeof id === 'string')); + if (set.size > 0) { + result.set(filterId, set); + } + } + } + } + } catch { + // Ignore malformed cache. + } + + return result; + } + + private _storeFilteredOutIds(next: Map<string, Set<string>>): void { + // Drop empty entries so an id disappears from storage once nothing is filtered out for it. + const normalized = new Map<string, Set<string>>(); + for (const [filterId, ids] of next) { + if (ids.size > 0) { + normalized.set(filterId, ids); + } + } + + if (areCachesEqual(normalized, this._cachedFilteredOutIds)) { + return; + } + + this._cachedFilteredOutIds = normalized; + + if (normalized.size === 0) { + this.storageService.remove(AssignmentContextFilter.STORAGE_KEY, StorageScope.APPLICATION); + } else { + const serializable: Record<string, string[]> = {}; + for (const [filterId, ids] of normalized) { + serializable[filterId] = Array.from(ids); + } + this.storageService.store(AssignmentContextFilter.STORAGE_KEY, JSON.stringify(serializable), StorageScope.APPLICATION, StorageTarget.MACHINE); + } + } +} + +function areCachesEqual(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean { + if (a.size !== b.size) { + return false; + } + for (const [filterId, ids] of a) { + const other = b.get(filterId); + if (!other || other.size !== ids.size) { + return false; + } + for (const id of ids) { + if (!other.has(id)) { + return false; + } + } + } + return true; +} diff --git a/src/vs/workbench/services/assignment/common/assignmentService.ts b/src/vs/workbench/services/assignment/common/assignmentService.ts index 9fc26ab4cabef6..a75afa8aa6bcf5 100644 --- a/src/vs/workbench/services/assignment/common/assignmentService.ts +++ b/src/vs/workbench/services/assignment/common/assignmentService.ts @@ -20,12 +20,19 @@ import { IConfigurationRegistry, Extensions as ConfigurationExtensions, Configur import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { importAMDNodeModule } from '../../../../amdX.js'; import { timeout } from '../../../../base/common/async.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; import { CopilotAssignmentFilterProvider } from './assignmentFilters.js'; +import { AssignmentContextFilter } from './assignmentContextFilter.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { experimentsEnabled } from '../../telemetry/common/workbenchTelemetryUtils.js'; export interface IAssignmentFilter { + /** + * Stable identifier for this filter. Used to persist and reconcile the set of + * assignment-context ids this filter has excluded, independently of other filters. + */ + readonly id: string; exclude(assignment: string): boolean; onDidChange: Event<void>; } @@ -68,33 +75,23 @@ class WorkbenchAssignmentServiceTelemetry extends Disposable implements IExperim return this._lastAssignmentContext?.split(';'); } - private _assignmentFilters: IAssignmentFilter[] = []; - private _assignmentFilterDisposables = this._register(new DisposableStore()); - constructor( private readonly telemetryService: ITelemetryService, - private readonly productService: IProductService + private readonly productService: IProductService, + private readonly contextFilter: AssignmentContextFilter ) { super(); - } - private _filterAssignmentContext(assignmentContext: string): string { - const assignments = assignmentContext.split(';'); - - const filteredAssignments = assignments.filter(assignment => { - for (const filter of this._assignmentFilters) { - if (filter.exclude(assignment)) { - return false; - } + // Re-apply the filters whenever a filter is added or changes its exclusion decisions. + this._register(this.contextFilter.onDidChange(() => { + if (this._previousAssignmentContext) { + this._setAssignmentContext(this._previousAssignmentContext); } - return true; - }); - - return filteredAssignments.join(';'); + })); } private _setAssignmentContext(value: string): void { - const filteredValue = this._filterAssignmentContext(value); + const filteredValue = this.contextFilter.filter(value); this._lastAssignmentContext = filteredValue; this._onDidUpdateAssignmentContext.fire(); @@ -103,18 +100,6 @@ class WorkbenchAssignmentServiceTelemetry extends Disposable implements IExperim } } - addAssignmentFilter(filter: IAssignmentFilter): void { - this._assignmentFilters.push(filter); - this._assignmentFilterDisposables.add(filter.onDidChange(() => { - if (this._previousAssignmentContext) { - this._setAssignmentContext(this._previousAssignmentContext); - } - })); - if (this._previousAssignmentContext) { - this._setAssignmentContext(this._previousAssignmentContext); - } - } - // __GDPR__COMMON__ "abexp.assignmentcontext" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } setSharedProperty(name: string, value: string): void { if (name === this.productService.tasConfig?.assignmentContextTelemetryPropertyName) { @@ -152,6 +137,7 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen private networkInitialized = false; private readonly overrideInitDelay: Promise<void>; + private readonly contextFilter: AssignmentContextFilter; private readonly telemetry: WorkbenchAssignmentServiceTelemetry; private readonly keyValueStorage: IKeyValueStorage; @@ -176,7 +162,8 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen this.tasClient = this.setupTASClient(); } - this.telemetry = this._register(new WorkbenchAssignmentServiceTelemetry(telemetryService, productService)); + this.contextFilter = this._register(new AssignmentContextFilter(storageService)); + this.telemetry = this._register(new WorkbenchAssignmentServiceTelemetry(telemetryService, productService, this.contextFilter)); this._register(this.telemetry.onDidUpdateAssignmentContext(() => this._onDidRefetchAssignments.fire())); this._register(this.configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('experiments.override')) { @@ -270,7 +257,15 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen this.tasSetupDisposables.add(extensionsFilterProvider.onDidChangeFilters(() => this.refetchAssignments())); const tasConfig = this.productService.tasConfig!; - const tasClient = new (await importAMDNodeModule<typeof import('tas-client')>('tas-client', 'dist/tas-client.min.js')).ExperimentationService({ + + const tasClientModule = await importAMDNodeModule<typeof import('tas-client')>('tas-client', 'dist/tas-client.min.js'); + + // Measure the client-side latency of the first network call to the + // Treatment Assignment Service. The fetch is triggered by constructing + // the client, so start timing right before construction to exclude + // module loading time from the measurement. + const fetchStopWatch = StopWatch.create(); + const tasClient = new tasClientModule.ExperimentationService({ filterProviders: [filterProvider, extensionsFilterProvider], telemetry: this.telemetry, storageKey: ASSIGNMENT_STORAGE_KEY, @@ -284,11 +279,31 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen await tasClient.initializePromise; tasClient.initialFetch.then(() => { this.networkInitialized = true; - }); + this.logFetchLatency('initial', fetchStopWatch.elapsed()); + }).catch(() => undefined); return tasClient; } + private logFetchLatency(fetchType: 'initial' | 'refetch', durationMs: number): void { + type TASClientFetchLatencyData = { + fetchType: string; + durationMs: number; + }; + + type TASClientFetchLatencyClassification = { + owner: 'sbatten'; + comment: 'Measures the client-side latency of fetching treatment assignments from the experiment service (TAS)'; + fetchType: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Whether this was the initial fetch or a refetch' }; + durationMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; isMeasurement: true; comment: 'Time in milliseconds the fetch took to complete' }; + }; + + this.telemetryService.publicLog2<TASClientFetchLatencyData, TASClientFetchLatencyClassification>('tasClientFetchLatency', { + fetchType, + durationMs + }); + } + private async refetchAssignments(): Promise<void> { if (!this.tasClient) { return; // Setup has not started, assignments will use latest filters @@ -298,8 +313,10 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen const tasClient = await this.tasClient; await tasClient.initialFetch; - // Refresh the assignments + // Refresh the assignments and measure the network latency of the refetch. + const refetchStopWatch = StopWatch.create(); await tasClient.getTreatmentVariableAsync('vscode', 'refresh', false); + this.logFetchLatency('refetch', refetchStopWatch.elapsed()); } async getCurrentExperiments(): Promise<string[] | undefined> { @@ -317,7 +334,7 @@ export class WorkbenchAssignmentService extends Disposable implements IAssignmen } addTelemetryAssignmentFilter(filter: IAssignmentFilter): void { - this.telemetry.addAssignmentFilter(filter); + this.contextFilter.addFilter(filter); } } diff --git a/src/vs/workbench/services/assignment/test/common/assignmentContextFilter.test.ts b/src/vs/workbench/services/assignment/test/common/assignmentContextFilter.test.ts new file mode 100644 index 00000000000000..12eb0f0e7979c3 --- /dev/null +++ b/src/vs/workbench/services/assignment/test/common/assignmentContextFilter.test.ts @@ -0,0 +1,124 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Emitter } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { InMemoryStorageService, IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { AssignmentContextFilter } from '../../common/assignmentContextFilter.js'; +import { IAssignmentFilter } from '../../common/assignmentService.js'; + +const FILTERED_OUT_IDS_STORAGE_KEY = 'workbench.assignment.filteredOutAssignmentContextIds'; + +suite('AssignmentContextFilter', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let storageService: IStorageService; + + setup(() => { + storageService = disposables.add(new InMemoryStorageService()); + }); + + function createContextFilter(): AssignmentContextFilter { + return disposables.add(new AssignmentContextFilter(storageService)); + } + + /** Creates a filter whose exclusion predicate can be swapped at runtime. */ + function createFilter(id: string, initialExclude: (assignment: string) => boolean, store: DisposableStore): { filter: IAssignmentFilter; setExclude(fn: (assignment: string) => boolean): void } { + const onDidChange = store.add(new Emitter<void>()); + let excludeFn = initialExclude; + return { + filter: { id, exclude: assignment => excludeFn(assignment), onDidChange: onDidChange.event }, + setExclude(fn) { + excludeFn = fn; + onDidChange.fire(); + } + }; + } + + function readCache(): Record<string, string[]> | undefined { + const raw = storageService.get(FILTERED_OUT_IDS_STORAGE_KEY, StorageScope.APPLICATION); + return raw ? JSON.parse(raw) : undefined; + } + + test('persists filtered-out ids keyed by filter id and removes them from the context', () => { + const store = disposables.add(new DisposableStore()); + const contextFilter = createContextFilter(); + contextFilter.addFilter(createFilter('onboarding', a => a === 'onb-test1', store).filter); + + const result = contextFilter.filter('onb-test1;onb-test2;keep'); + + assert.strictEqual(result, 'onb-test2;keep'); + assert.deepStrictEqual(readCache(), { onboarding: ['onb-test1'] }); + }); + + test('honors cached ids across reload before the owning filter is registered', () => { + // Simulate a previous session that persisted a filtered-out id. + storageService.store(FILTERED_OUT_IDS_STORAGE_KEY, JSON.stringify({ onboarding: ['onb-test1'] }), StorageScope.APPLICATION, StorageTarget.MACHINE); + + const contextFilter = createContextFilter(); + + assert.strictEqual(contextFilter.filter('onb-test1;keep'), 'keep'); + }); + + test('registering a filter drops only its own cached state and preserves other filters', () => { + storageService.store(FILTERED_OUT_IDS_STORAGE_KEY, JSON.stringify({ onboarding: ['onb-test1'], unification: ['cmp-ext-a'] }), StorageScope.APPLICATION, StorageTarget.MACHINE); + + const store = disposables.add(new DisposableStore()); + const contextFilter = createContextFilter(); + // The onboarding filter is registered but no longer excludes onb-test1. + contextFilter.addFilter(createFilter('onboarding', () => false, store).filter); + + const result = contextFilter.filter('onb-test1;cmp-ext-a;keep'); + + // onb-test1 is no longer excluded (its filter took over), but cmp-ext-a stays excluded + // because the unification filter has not been registered yet. + assert.strictEqual(result, 'onb-test1;keep'); + assert.deepStrictEqual(readCache(), { unification: ['cmp-ext-a'] }); + }); + + test('removes an id from storage once it is no longer filtered out', () => { + const store = disposables.add(new DisposableStore()); + const contextFilter = createContextFilter(); + const onboarding = createFilter('onboarding', a => a === 'onb-test1', store); + contextFilter.addFilter(onboarding.filter); + + contextFilter.filter('onb-test1;keep'); + assert.deepStrictEqual(readCache(), { onboarding: ['onb-test1'] }); + + // The gate opens: the filter no longer excludes the id. + onboarding.setExclude(() => false); + + assert.strictEqual(contextFilter.filter('onb-test1;keep'), 'onb-test1;keep'); + assert.strictEqual(readCache(), undefined); + }); + + test('tracks multiple registered filters independently', () => { + const store = disposables.add(new DisposableStore()); + const contextFilter = createContextFilter(); + contextFilter.addFilter(createFilter('onboarding', a => a.startsWith('onb-'), store).filter); + contextFilter.addFilter(createFilter('unification', a => a.startsWith('cmp-'), store).filter); + + const result = contextFilter.filter('onb-a;cmp-b;keep'); + + assert.strictEqual(result, 'keep'); + assert.deepStrictEqual(readCache(), { onboarding: ['onb-a'], unification: ['cmp-b'] }); + }); + + test('fires onDidChange when a filter is added and when a filter changes', () => { + const store = disposables.add(new DisposableStore()); + const contextFilter = createContextFilter(); + let changes = 0; + disposables.add(contextFilter.onDidChange(() => changes++)); + + const onboarding = createFilter('onboarding', () => false, store); + contextFilter.addFilter(onboarding.filter); + onboarding.setExclude(a => a === 'onb-test1'); + + assert.strictEqual(changes, 2); + }); +}); diff --git a/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts b/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts index 7fc1a9c6df8485..58f7006250eb4c 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationMcpAccessService.ts @@ -48,6 +48,15 @@ export interface AllowedMcpServer { * Undefined for stdio servers, which have no URL. */ url?: string; + /** + * Set when the grant is for an MCP server hosted by an agent host (which runs the server in the + * CLI/agent-host process rather than registering it with the workbench `IMcpService`). Carries the + * host `authority` and its display `label` so management surfaces can present agent-host servers in + * their own section instead of filtering them out as "unknown". Agent hosts + * may be ephemeral and not always connected, but we still want to be able to + * display grants we know about and will respect. + */ + agentHost?: { authority: string; label: string }; } export const IAuthenticationMcpAccessService = createDecorator<IAuthenticationMcpAccessService>('IAuthenticationMcpAccessService'); @@ -189,6 +198,11 @@ export class AuthenticationMcpAccessService extends Disposable implements IAuthe if (mcpServer.url !== undefined) { allowList[index].url = mcpServer.url; } + // Preserve agent-host metadata across re-grants; only set when provided so management + // toggles (which omit it) don't clear it. + if (mcpServer.agentHost !== undefined) { + allowList[index].agentHost = mcpServer.agentHost; + } } } diff --git a/src/vs/workbench/services/authentication/browser/authenticationQueryService.ts b/src/vs/workbench/services/authentication/browser/authenticationQueryService.ts index 5a58e96a99996a..c851741fae6e69 100644 --- a/src/vs/workbench/services/authentication/browser/authenticationQueryService.ts +++ b/src/vs/workbench/services/authentication/browser/authenticationQueryService.ts @@ -297,7 +297,7 @@ class AccountMcpServersQuery extends BaseQuery implements IAccountMcpServersQuer super(providerId, queryService); } - getAllowedMcpServers(): { id: string; name: string; allowed?: boolean; lastUsed?: number; trusted?: boolean }[] { + getAllowedMcpServers(): { id: string; name: string; allowed?: boolean; lastUsed?: number; trusted?: boolean; url?: string; agentHost?: { authority: string; label: string } }[] { return this.queryService.authenticationMcpAccessService.readAllowedMcpServers(this.providerId, this.accountName) .filter(server => server.allowed !== false); } diff --git a/src/vs/workbench/services/authentication/common/authenticationQuery.ts b/src/vs/workbench/services/authentication/common/authenticationQuery.ts index af6aeefb85c177..850c6fee921a22 100644 --- a/src/vs/workbench/services/authentication/common/authenticationQuery.ts +++ b/src/vs/workbench/services/authentication/common/authenticationQuery.ts @@ -240,7 +240,7 @@ export interface IAccountMcpServersQuery extends IBaseQuery { * Get all MCP servers that have access to this account with their trusted state * @returns Array of objects containing MCP server data including trusted state */ - getAllowedMcpServers(): { id: string; name: string; allowed?: boolean; lastUsed?: number; trusted?: boolean }[]; + getAllowedMcpServers(): { id: string; name: string; allowed?: boolean; lastUsed?: number; trusted?: boolean; url?: string; agentHost?: { authority: string; label: string } }[]; /** * Grant access to this account for all specified MCP servers diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts b/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts index 38071d9b6e3507..5b4a92462d51e9 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationMcpAccessService.test.ts @@ -448,6 +448,21 @@ suite('AuthenticationMcpAccessService', () => { assert.strictEqual(allServers.length, 2); }); + test('persists agentHost metadata and preserves it when a later toggle omits it', () => { + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'agent-host-mcp:remote/GitHub/https://api.example/mcp', name: 'GitHub', allowed: true, url: 'https://api.example/mcp', agentHost: { authority: 'remote', label: 'SSH: my-host' } } + ]); + + // A management toggle (no agentHost/url) must not clear the stored metadata. + authenticationMcpAccessService.updateAllowedMcpServers('github', 'user@example.com', [ + { id: 'agent-host-mcp:remote/GitHub/https://api.example/mcp', name: 'GitHub', allowed: true } + ]); + + const stored = authenticationMcpAccessService.readAllowedMcpServers('github', 'user@example.com') + .find(s => s.id === 'agent-host-mcp:remote/GitHub/https://api.example/mcp'); + assert.deepStrictEqual(stored?.agentHost, { authority: 'remote', label: 'SSH: my-host' }); + }); + test('fires onDidChangeMcpSessionAccess event', () => { let eventFired = false; let eventData: { providerId: string; accountName: string } | undefined; diff --git a/src/vs/workbench/services/authentication/test/browser/authenticationQueryService.test.ts b/src/vs/workbench/services/authentication/test/browser/authenticationQueryService.test.ts index c819b314b78725..a0488518ba48b2 100644 --- a/src/vs/workbench/services/authentication/test/browser/authenticationQueryService.test.ts +++ b/src/vs/workbench/services/authentication/test/browser/authenticationQueryService.test.ts @@ -1095,6 +1095,19 @@ suite('AuthenticationQueryService Integration Tests', () => { assert.strictEqual(userServer.allowed, true); }); + test('getAllowedMcpServers method exposes agentHost metadata', () => { + authService.registerAuthenticationProvider('github', createProvider({ id: 'github', label: 'GitHub' })); + authService.addAccounts('github', [{ id: 'user1', label: 'user@github.com' }]); + + mcpAccessService.updateAllowedMcpServers('github', 'user@github.com', [ + { id: 'agent-host-mcp:remote/GitHub/https://api.example/mcp', name: 'GitHub', allowed: true, agentHost: { authority: 'remote', label: 'SSH: my-host' } } + ]); + + const allowedServers = queryService.provider('github').account('user@github.com').mcpServers().getAllowedMcpServers(); + const agentHostServer = allowedServers.find(s => s.id === 'agent-host-mcp:remote/GitHub/https://api.example/mcp'); + assert.deepStrictEqual(agentHostServer?.agentHost, { authority: 'remote', label: 'SSH: my-host' }); + }); + test('getAllowedExtensions returns extension data with trusted state', () => { // Set up some extension access data const accountQuery = queryService.provider('github').account('user@example.com'); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 5db126030fc80f..edff01b34b2a5f 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -206,7 +206,6 @@ export interface IChatEntitlementService { readonly entitlement: ChatEntitlement; readonly entitlementObs: IObservable<ChatEntitlement>; - readonly previewFeaturesDisabled: boolean; readonly clientByokEnabled: boolean; readonly hasByokModels: boolean; @@ -513,10 +512,6 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme return this.context?.value.state.copilotTrackingId; } - get previewFeaturesDisabled(): boolean { - return this.contextKeyService.getContextKeyValue<boolean>('github.copilot.previewFeaturesDisabled') === true; - } - get clientByokEnabled(): boolean { return this.contextKeyService.getContextKeyValue<boolean>('github.copilot.clientByokEnabled') === true; } @@ -802,6 +797,7 @@ export interface IQuotaSnapshot { readonly usageBasedBilling?: boolean; readonly entitlement?: number; readonly quotaRemaining?: number; + readonly creditsUsed?: number; } export interface IRateLimitSnapshot { @@ -859,6 +855,7 @@ export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { continue; } const parsedEntitlement = rawQuotaSnapshot.entitlement !== undefined ? Number(rawQuotaSnapshot.entitlement) : undefined; + const parsedCreditsUsed = rawQuotaSnapshot.credits_used !== undefined ? Number(rawQuotaSnapshot.credits_used) : undefined; // Skip snapshots where the user has no allocated entitlement for this // category (e.g. free tier premium_interactions with 0 credits). Under @@ -877,6 +874,7 @@ export function parseQuotas(entitlementsData: IEntitlementsData): IQuotas { resetAt: rawQuotaSnapshot.quota_reset_at || undefined, entitlement: parsedEntitlement !== undefined && Number.isFinite(parsedEntitlement) && parsedEntitlement >= 0 ? parsedEntitlement : undefined, quotaRemaining: parsedQuotaRemaining !== undefined && Number.isFinite(parsedQuotaRemaining) && parsedQuotaRemaining >= 0 ? parsedQuotaRemaining : undefined, + creditsUsed: parsedCreditsUsed !== undefined && Number.isFinite(parsedCreditsUsed) && parsedCreditsUsed >= 0 ? parsedCreditsUsed : undefined, }; switch (quotaType) { diff --git a/src/vs/workbench/services/clipboard/electron-browser/clipboardService.ts b/src/vs/workbench/services/clipboard/electron-browser/clipboardService.ts index fbdf70cd759cc6..98cca5604bba3e 100644 --- a/src/vs/workbench/services/clipboard/electron-browser/clipboardService.ts +++ b/src/vs/workbench/services/clipboard/electron-browser/clipboardService.ts @@ -5,8 +5,7 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { URI } from '../../../../base/common/uri.js'; -import { isMacintosh, isLinux, isWindows } from '../../../../base/common/platform.js'; -import { Schemas } from '../../../../base/common/network.js'; +import { isMacintosh } from '../../../../base/common/platform.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { VSBuffer } from '../../../../base/common/buffer.js'; @@ -15,9 +14,6 @@ import { ILogService } from '../../../../platform/log/common/log.js'; export class NativeClipboardService implements IClipboardService { private static readonly FILE_FORMAT = 'code/file-list'; // Clipboard format for files - private static readonly MAC_FILE_FORMAT = 'NSFilenamesPboardType'; // macOS Finder clipboard format - private static readonly LINUX_FILE_FORMAT = 'text/uri-list'; // freedesktop.org clipboard format for file managers - private static readonly WINDOWS_FILE_FORMAT = 'FileNameW'; // Windows Explorer clipboard format (single file, UTF-16LE) declare readonly _serviceBrand: undefined; @@ -60,94 +56,21 @@ export class NativeClipboardService implements IClipboardService { } async writeResources(resources: URI[]): Promise<void> { - if (!resources.length) { - return; + if (resources.length) { + return this.nativeHostService.writeClipboardBuffer(NativeClipboardService.FILE_FORMAT, this.resourcesToBuffer(resources)); } - - const allLocal = resources.every(r => r.scheme === Schemas.file); - - // When all resources are local files, write in the platform's native - // file clipboard format so that native file managers can paste them. - // Electron's clipboard API only supports one buffer format per call, - // so we pick the most useful one. - if (allLocal) { - if (isMacintosh) { - const plist = this.filesToPlist(resources.map(r => r.fsPath)); - return this.nativeHostService.writeClipboardBuffer( - NativeClipboardService.MAC_FILE_FORMAT, - VSBuffer.fromString(plist) - ); - } - if (isLinux) { - const uriList = resources.map(r => r.toString()).join('\r\n'); - return this.nativeHostService.writeClipboardBuffer( - NativeClipboardService.LINUX_FILE_FORMAT, - VSBuffer.fromString(uriList) - ); - } - if (isWindows && resources.length === 1) { - // FileNameW supports a single null-terminated UTF-16LE file path. - // For multiple files CF_HDROP would be needed, which requires - // a predefined format ID that Electron cannot write. - return this.nativeHostService.writeClipboardBuffer( - NativeClipboardService.WINDOWS_FILE_FORMAT, - this.filePathToUtf16LE(resources[0].fsPath) - ); - } - } - - // Default (Windows, or mixed local/remote): write VS Code custom format - return this.nativeHostService.writeClipboardBuffer( - NativeClipboardService.FILE_FORMAT, - VSBuffer.fromString(resources.map(r => r.toString()).join('\n')) - ); } async readResources(): Promise<URI[]> { - // Try VS Code's custom format first - const codeBuffer = await this.nativeHostService.readClipboardBuffer(NativeClipboardService.FILE_FORMAT); - const codeResources = this.bufferToResources(codeBuffer); - if (codeResources.length > 0) { - return codeResources; - } - - // Fall back to reading platform-native file clipboard formats - if (isMacintosh) { - const macBuffer = await this.nativeHostService.readClipboardBuffer(NativeClipboardService.MAC_FILE_FORMAT); - return this.plistToFiles(macBuffer); - } - - if (isLinux) { - const linuxBuffer = await this.nativeHostService.readClipboardBuffer(NativeClipboardService.LINUX_FILE_FORMAT); - return this.uriListToFiles(linuxBuffer); - } - - if (isWindows) { - const winBuffer = await this.nativeHostService.readClipboardBuffer(NativeClipboardService.WINDOWS_FILE_FORMAT); - return this.fileNameWToFile(winBuffer); - } - - return []; + return this.bufferToResources(await this.nativeHostService.readClipboardBuffer(NativeClipboardService.FILE_FORMAT)); } async hasResources(): Promise<boolean> { - if (await this.nativeHostService.hasClipboard(NativeClipboardService.FILE_FORMAT)) { - return true; - } - - if (isMacintosh) { - return this.nativeHostService.hasClipboard(NativeClipboardService.MAC_FILE_FORMAT); - } - - if (isLinux) { - return this.nativeHostService.hasClipboard(NativeClipboardService.LINUX_FILE_FORMAT); - } - - if (isWindows) { - return this.nativeHostService.hasClipboard(NativeClipboardService.WINDOWS_FILE_FORMAT); - } + return this.nativeHostService.hasClipboard(NativeClipboardService.FILE_FORMAT); + } - return false; + private resourcesToBuffer(resources: URI[]): VSBuffer { + return VSBuffer.fromString(resources.map(r => r.toString()).join('\n')); } private bufferToResources(buffer: VSBuffer): URI[] { @@ -166,96 +89,6 @@ export class NativeClipboardService implements IClipboardService { return []; // do not trust clipboard data } } - - private filesToPlist(paths: string[]): string { - return '<?xml version="1.0" encoding="UTF-8"?>\n' - + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' - + '<plist version="1.0">\n<array>\n' - + paths.map(p => `<string>${p.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</string>`).join('\n') - + '\n</array>\n</plist>'; - } - - private plistToFiles(buffer: VSBuffer): URI[] { - if (!buffer) { - return []; - } - - const content = buffer.toString(); - if (!content) { - return []; - } - - try { - // Extract <string>...</string> values from the plist - const paths: URI[] = []; - const regex = /<string>([^<]+)<\/string>/g; - let match: RegExpExecArray | null; - while ((match = regex.exec(content)) !== null) { - const path = match[1] - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/&/g, '&'); - paths.push(URI.file(path)); - } - return paths; - } catch (error) { - return []; // do not trust clipboard data - } - } - - private uriListToFiles(buffer: VSBuffer): URI[] { - if (!buffer) { - return []; - } - - const content = buffer.toString(); - if (!content) { - return []; - } - - try { - // text/uri-list: lines starting with # are comments, entries separated by \r\n. - // Only include file:// URIs — non-file URIs are not meaningful for - // file paste flows and could cause confusing errors. - return content.split(/\r?\n/) - .filter(line => line.length > 0 && !line.startsWith('#')) - .map(line => URI.parse(line)) - .filter(uri => uri.scheme === Schemas.file); - } catch (error) { - return []; // do not trust clipboard data - } - } - - private filePathToUtf16LE(path: string): VSBuffer { - // FileNameW expects a null-terminated UTF-16LE encoded string. - // Uint16Array naturally uses the platform's char encoding (UTF-16). - const encoded = new Uint16Array(path.length + 1); - for (let i = 0; i < path.length; i++) { - encoded[i] = path.charCodeAt(i); - } - // Last element is already 0 (null terminator) - return VSBuffer.wrap(new Uint8Array(encoded.buffer)); - } - - private fileNameWToFile(buffer: VSBuffer): URI[] { - if (!buffer || buffer.byteLength < 4) { - return []; - } - - try { - // Read null-terminated UTF-16LE string - const u16 = new Uint16Array(buffer.buffer.buffer, buffer.buffer.byteOffset, Math.floor(buffer.byteLength / 2)); - const nullIdx = u16.indexOf(0); - const path = String.fromCharCode(...u16.subarray(0, nullIdx === -1 ? u16.length : nullIdx)); - if (path.length > 0) { - return [URI.file(path)]; - } - } catch (error) { - // do not trust clipboard data - } - - return []; - } } registerSingleton(IClipboardService, NativeClipboardService, InstantiationType.Delayed); diff --git a/src/vs/workbench/services/editor/browser/editorResolverService.ts b/src/vs/workbench/services/editor/browser/editorResolverService.ts index 8a8b5caefe18c3..60392f8498fe97 100644 --- a/src/vs/workbench/services/editor/browser/editorResolverService.ts +++ b/src/vs/workbench/services/editor/browser/editorResolverService.ts @@ -61,6 +61,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // Constants private static readonly configureDefaultID = 'promptOpenWith.configureDefault'; + private static readonly configureDefaultDiffID = 'promptOpenWith.configureDefaultDiff'; private static readonly cacheStorageID = 'editorOverrideService.cache'; private static readonly conflictingDefaultsStorageID = 'editorOverrideService.conflictingDefaults'; @@ -282,7 +283,14 @@ export class EditorResolverService extends Disposable implements IEditorResolver return this.getAssociationsForResourceFromSetting(resource, editorsAssociationsSettingId); } + getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined { + const settingId = forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId; + return this.getAssociationsForResourceFromSetting(resource, settingId)[0]?.viewType; + } + private getAssociationsForResourceByType(resource: URI, associationType: EditorAssociationType): EditorAssociations { + // The specialized diff/merge associations win over the general ones and are allowed to target + // an editor even if that editor opted out of diffs/merges through a `never` priority. if (associationType === EditorAssociationType.DiffEditor || associationType === EditorAssociationType.MergeEditor) { const diffAssociations = this.getAssociationsForResourceFromSetting(resource, diffEditorsAssociationsSettingId); if (diffAssociations.length) { @@ -290,7 +298,21 @@ export class EditorResolverService extends Disposable implements IEditorResolver } } - return this.getAssociationsForResource(resource); + // General `editorAssociations` entries must not select an editor that opted out of this kind of + // input via a `never` priority (e.g. a custom editor that only handles the normal editor, not diffs). + const r = this.getAssociationsForResource(resource); + return r.filter(association => !this.isNeverForAssociationType(association.viewType, associationType)); + } + + /** + * Whether every editor registered under `viewType` opts out of the given kind of input via a + * `never` priority. Such editors can only be selected explicitly (e.g. `Reopen Editor With`) or + * through the specialized `workbench.diffEditorAssociations` setting, never through a general + * `workbench.editorAssociations` entry. + */ + private isNeverForAssociationType(viewType: string, associationType: EditorAssociationType): boolean { + const editor = this._registeredEditors.filter(editor => editor.editorInfo.id === viewType).at(0); + return !!editor && this.getEffectivePriority(editor.editorInfo, associationType) === RegisteredEditorPriority.never; } private getAssociationsForResourceFromSetting(resource: URI, settingId: string): EditorAssociations { @@ -388,8 +410,8 @@ export class EditorResolverService extends Disposable implements IEditorResolver return Array.from(this._flattenedEditors.values()).flat(); } - updateUserAssociations(globPattern: string, editorID: string): void { - this.updateUserAssociationsForSetting(editorsAssociationsSettingId, globPattern, editorID); + updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void { + this.updateUserAssociationsForSetting(forDiffEditor ? diffEditorsAssociationsSettingId : editorsAssociationsSettingId, globPattern, editorID); } private updateUserAssociationsForType(associationType: EditorAssociationType, globPattern: string, editorID: string): void { @@ -409,6 +431,20 @@ export class EditorResolverService extends Disposable implements IEditorResolver this.configurationService.updateValue(settingId, newSettingObject); } + private removeUserAssociationForSetting(settingId: string, globPattern: string): void { + const currentAssociations = this.getAllUserAssociationsForSetting(settingId); + if (!currentAssociations.some(association => association.filenamePattern === globPattern)) { + return; + } + const newSettingObject = Object.create(null); + for (const association of currentAssociations) { + if (association.filenamePattern && association.filenamePattern !== globPattern) { + newSettingObject[association.filenamePattern] = association.viewType; + } + } + this.configurationService.updateValue(settingId, newSettingObject); + } + private findMatchingEditors(resource: URI, associationType: EditorAssociationType = EditorAssociationType.Editor): RegisteredEditor[] { // The user setting should be respected even if the editor doesn't specify that resource in package.json const userSettings = this.getAssociationsForResourceByType(resource, associationType); @@ -457,6 +493,20 @@ export class EditorResolverService extends Disposable implements IEditorResolver return distinct(this._registeredEditors.map(editor => editor.editorInfo), editor => editor.id); } + getBinaryDiffFallbackEditor(resource: URI): string | undefined { + this._flattenedEditors = this._flattenEditorsMap(); + + // `findMatchingEditors(..., DiffEditor)` only keeps editors that provide a diff editor factory + // and sorts them by their diff priority. It still includes `never` editors (they match by glob), + // which is exactly what we want here: a `never` editor opts out of diffs for text files, but is + // the better choice than the generic binary fallback when the text diff editor cannot render the + // content. We exclude the built-in default text editor since that is the editor that already + // failed to render the binary content. + const editors = this.findMatchingEditors(resource, EditorAssociationType.DiffEditor) + .filter(editor => editor.editorInfo.id !== DEFAULT_EDITOR_ASSOCIATION.id); + return editors[0]?.editorInfo.id; + } + /** * Given a resource and an editorId selects the best possible editor * @returns The editor and whether there was another default which conflicted with it @@ -784,11 +834,20 @@ export class EditorResolverService extends Disposable implements IEditorResolver label: localize('promptOpenWith.configureDefault', "Configure default editor for '{0}'...", `*${extname(resource)}`), }; quickPickEntries.push(configureDefaultEntry); + // For diffs, additionally offer to configure a diff-only default so the choice does not + // affect how the resource opens as a normal editor (writes to `diffEditorAssociations`). + if (associationType === EditorAssociationType.DiffEditor) { + const configureDefaultDiffEntry = { + id: EditorResolverService.configureDefaultDiffID, + label: localize('promptOpenWith.configureDefaultDiff', "Configure default editor (diff only) for '{0}'...", `*${extname(resource)}`), + }; + quickPickEntries.push(configureDefaultDiffEntry); + } } return quickPickEntries; } - private async doPickEditor(editor: IUntypedEditorInput, showDefaultPicker?: boolean): Promise<IEditorOptions | undefined> { + private async doPickEditor(editor: IUntypedEditorInput, showDefaultPicker?: boolean, updateAssociationType?: EditorAssociationType): Promise<IEditorOptions | undefined> { type EditorPick = { readonly item: IQuickPickItem; @@ -802,6 +861,21 @@ export class EditorResolverService extends Disposable implements IEditorResolver resource = URI.from({ scheme: Schemas.untitled }); } const associationType = isResourceDiffEditorInput(editor) ? EditorAssociationType.DiffEditor : EditorAssociationType.Editor; + // Which setting the default picker should write to. Defaults to the resource's association type + // so that the per-item gear button keeps writing to the matching setting, but the "Configure + // default editor" entries can target a specific setting (general vs. diff-only). + const updateSettingType = updateAssociationType ?? associationType; + + // Persists the picked editor as the default for this resource's glob. When the user configures + // the general default from a diff context, any diff-only override for the same glob is cleared + // so that the general default also takes effect for diffs. + const persistDefaultAssociation = (editorID: string) => { + const globPattern = `*${extname(resource)}`; + this.updateUserAssociationsForType(updateSettingType, globPattern, editorID); + if (updateSettingType === EditorAssociationType.Editor && associationType === EditorAssociationType.DiffEditor) { + this.removeUserAssociationForSetting(diffEditorsAssociationsSettingId, globPattern); + } + }; // Get all the editors for the resource as quickpick entries const editorPicks = this.mapEditorsToQuickPickEntry(resource, showDefaultPicker, associationType); @@ -810,7 +884,9 @@ export class EditorResolverService extends Disposable implements IEditorResolver const disposables = new DisposableStore(); const editorPicker = disposables.add(this.quickInputService.createQuickPick<IQuickPickItem>({ useSeparators: true })); const placeHolderMessage = showDefaultPicker ? - localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", `*${extname(resource)}`) : + (updateSettingType === EditorAssociationType.DiffEditor ? + localize('promptOpenWith.updateDefaultDiffPlaceHolder', "Select new default editor (diff only) for '{0}'", `*${extname(resource)}`) : + localize('promptOpenWith.updateDefaultPlaceHolder', "Select new default editor for '{0}'", `*${extname(resource)}`)) : localize('promptOpenWith.placeHolder', "Select editor for '{0}'", basename(resource)); editorPicker.placeholder = placeHolderMessage; editorPicker.canAcceptInBackground = true; @@ -835,7 +911,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // If asked to always update the setting then update it even if the gear isn't clicked if (resource && showDefaultPicker && result?.item.id) { - this.updateUserAssociationsForType(associationType, `*${extname(resource)}`, result.item.id); + persistDefaultAssociation(result.item.id); } resolve(result); @@ -853,7 +929,7 @@ export class EditorResolverService extends Disposable implements IEditorResolver // Persist setting if (resource && e.item?.id) { - this.updateUserAssociationsForType(associationType, `*${extname(resource)}`, e.item.id); + persistDefaultAssociation(e.item.id); } })); @@ -870,7 +946,12 @@ export class EditorResolverService extends Disposable implements IEditorResolver // If the user selected to configure default we trigger this picker again and tell it to show the default picker if (picked.item.id === EditorResolverService.configureDefaultID) { - return this.doPickEditor(editor, true); + return this.doPickEditor(editor, true, EditorAssociationType.Editor); + } + // The diff-only variant writes to `diffEditorAssociations` so it does not change how the + // resource opens as a normal editor. + if (picked.item.id === EditorResolverService.configureDefaultDiffID) { + return this.doPickEditor(editor, true, EditorAssociationType.DiffEditor); } // Figure out options diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index 130b6c8b172c63..138808be5f4790 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -9,7 +9,7 @@ import { ServicesAccessor } from '../../../../platform/instantiation/common/inst import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService, IModalEditorPart } from './editorGroupsService.js'; -import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; +import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP, USE_MODAL_EDITOR_SETTING, UseModalEditorMode } from './editorService.js'; type FindGroupResult = Promise<[IEditorGroup, EditorActivation | undefined]> | [IEditorGroup, EditorActivation | undefined]; @@ -39,7 +39,7 @@ export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOpt function handleGroupResult(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService, configurationService: IConfigurationService): FindGroupResult { const modalEditorPart = editorGroupService.activeModalEditorPart; - const modalEditorMode = configurationService.getValue<string>('workbench.editor.useModal'); + const modalEditorMode = configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING); const editorInput = isEditorInputWithOptions(editor) ? editor.editor : isEditorInput(editor) ? editor : undefined; // The `RequiresModal` capability is honored unless the user has explicitly // disabled modal editors via `workbench.editor.useModal: 'off'`, in which @@ -105,7 +105,7 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer // Group: Force modal if the editor has the RequiresModal capability, // but respect `workbench.editor.useModal: 'off'` as an explicit opt-out. - if (isEditorInput(editor) && editor.hasCapability(EditorInputCapabilities.RequiresModal) && configurationService.getValue<string>('workbench.editor.useModal') !== 'off') { + if (isEditorInput(editor) && editor.hasCapability(EditorInputCapabilities.RequiresModal) && configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING) !== 'off') { group = editorGroupService.createModalEditorPart(options?.modal) .then(part => part.activeGroup); } @@ -141,7 +141,7 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer } // Group: Modal (gated behind a setting) - else if (preferredGroup === MODAL_GROUP && configurationService.getValue<string>('workbench.editor.useModal') !== 'off') { + else if (preferredGroup === MODAL_GROUP && configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING) !== 'off') { group = editorGroupService.createModalEditorPart(options?.modal) .then(part => part.activeGroup); } @@ -192,7 +192,7 @@ function doFindGroup(input: EditorInputWithOptions | IUntypedEditorInput, prefer } // Force modal editor part: redirect to the modal group when setting is 'on' - if (!group && configurationService.getValue<string>('workbench.editor.useModal') === 'all') { + if (!group && configurationService.getValue<UseModalEditorMode>(USE_MODAL_EDITOR_SETTING) === 'all') { group = editorGroupService.createModalEditorPart(options?.modal) .then(part => part.activeGroup); } diff --git a/src/vs/workbench/services/editor/common/editorResolverService.ts b/src/vs/workbench/services/editor/common/editorResolverService.ts index 83332416098eab..9f6c26195c5a4d 100644 --- a/src/vs/workbench/services/editor/common/editorResolverService.ts +++ b/src/vs/workbench/services/editor/common/editorResolverService.ts @@ -38,18 +38,39 @@ export const editorsAssociationsSettingId = 'workbench.editorAssociations'; export const diffEditorsAssociationsSettingId = 'workbench.diffEditorAssociations'; /** - * Default value for `workbench.editorAssociations` in the Agents window. + * Setting that controls whether the Markdown editor is the default editor for + * `*.md` files in the Agents window. Gated behind an experiment so it can be + * rolled out gradually. Defaults to off. + */ +export const markdownDefaultEditorAgentsWindowSettingId = 'workbench.editor.markdownDefaultEditorInAgentsWindow'; + +/** + * Builds the default value for `workbench.editorAssociations` in the Agents window. * Shared so that dynamic re-registrations of the setting preserve the override. + * + * Each editor association can be toggled independently. Passing `undefined` + * leaves the association at its enabled default, so the static registration + * ends up with all defaults registered. Pass `false` to fall back to the + * markdown preview editor for `*.md` files. */ -export const editorsAssociationsAgentsWindowDefault: Readonly<Record<string, string>> = Object.freeze({ - '*.md': 'vscode.markdown.editor' -}); +export function editorsAssociationsAgentsWindowDefault(options?: { markdownDefaultEditor?: boolean }): Record<string, string> { + return { + '*.md': options?.markdownDefaultEditor === true ? 'vscode.markdown.editor' : 'vscode.markdown.preview.editor' + }; +} const configurationRegistry = Registry.as<IConfigurationRegistry>(ConfigurationExtensions.Configuration); const editorAssociationsConfigurationNode: IConfigurationNode = { ...workbenchConfigurationNodeBase, properties: { + [markdownDefaultEditorAgentsWindowSettingId]: { + type: 'boolean', + default: true, + tags: ['experimental'], + experiment: { mode: 'startup' }, + markdownDescription: localize('editor.markdownDefaultEditorInAgentsWindow', "Controls whether the Markdown editor is used as the default editor for Markdown files in the Agents window."), + }, [editorsAssociationsSettingId]: { type: 'object', markdownDescription: localize('editor.editorAssociations', "Configure [glob patterns](https://aka.ms/vscode-glob-patterns) to editors (for example `\"*.hex\": \"hexEditor.hexedit\"`). These have precedence over the default behavior."), @@ -57,7 +78,7 @@ const editorAssociationsConfigurationNode: IConfigurationNode = { type: 'string' }, agentsWindow: { - default: editorsAssociationsAgentsWindowDefault + default: editorsAssociationsAgentsWindowDefault() } }, [diffEditorsAssociationsSettingId]: { @@ -84,7 +105,15 @@ export enum RegisteredEditorPriority { builtin = 'builtin', option = 'option', exclusive = 'exclusive', - default = 'default' + default = 'default', + /** + * The editor is never automatically used for this kind of input, and it is + * also skipped when the user points a `workbench.editorAssociations` entry at + * it. Unlike `option`, a `never` editor is only used when it is the target of + * the specialized `workbench.diffEditorAssociations` setting or when the user + * explicitly opens it (for example via `Reopen Editor With`). + */ + never = 'never' } /** @@ -171,12 +200,23 @@ export interface IEditorResolverService { */ getAssociationsForResource(resource: URI): EditorAssociations; + /** + * Returns the view type of the user-configured default editor for a resource, or `undefined` when + * none is configured. When `forDiffEditor` is `true` the diff editor association setting + * (`workbench.diffEditorAssociations`) is consulted instead of the general one. + * @param resource The resource to match + * @param forDiffEditor Whether to read the diff editor association setting + */ + getConfiguredDefaultEditor(resource: URI, forDiffEditor?: boolean): string | undefined; + /** * Updates the user's association to include a specific editor ID as a default for the given glob pattern * @param globPattern The glob pattern (must be a string as settings don't support relative glob) * @param editorID The ID of the editor to make a user default + * @param forDiffEditor When `true`, the diff editor association (`workbench.diffEditorAssociations`) + * is updated instead of the general editor association (`workbench.editorAssociations`). */ - updateUserAssociations(globPattern: string, editorID: string): void; + updateUserAssociations(globPattern: string, editorID: string, forDiffEditor?: boolean): void; /** * Emitted when an editor is registered or unregistered. @@ -223,6 +263,16 @@ export interface IEditorResolverService { */ getEditors(): RegisteredEditorInfo[]; + /** + * Returns the id of the best editor that can render a *diff* for the resource, excluding the + * built-in default text editor. This intentionally includes editors that opted out of diffs via a + * `never` priority: such editors opt out for text files, but when the default text diff editor + * cannot render the content (e.g. it is binary) a custom diff editor is preferable to the generic + * "cannot display" fallback. Returns `undefined` when no such (diff-capable) editor exists. + * @param resource The resource being diffed + */ + getBinaryDiffFallbackEditor(resource: URI): string | undefined; + /** * Get a complete list of editor associations. */ @@ -242,6 +292,9 @@ export function priorityToRank(priority: RegisteredEditorPriority): number { return 3; // Text editor is priority 2 case RegisteredEditorPriority.option: + return 1; + case RegisteredEditorPriority.never: + return 0; default: return 1; } diff --git a/src/vs/workbench/services/editor/common/editorService.ts b/src/vs/workbench/services/editor/common/editorService.ts index ac9788f7d5cc78..3527e7ed84cad4 100644 --- a/src/vs/workbench/services/editor/common/editorService.ts +++ b/src/vs/workbench/services/editor/common/editorService.ts @@ -40,6 +40,19 @@ export type AUX_WINDOW_GROUP_TYPE = typeof AUX_WINDOW_GROUP; export const MODAL_GROUP = -4; export type MODAL_GROUP_TYPE = typeof MODAL_GROUP; +/** + * Setting that controls whether editors open in a modal editor part. + */ +export const USE_MODAL_EDITOR_SETTING = 'workbench.editor.useModal'; + +/** + * Possible values for the `workbench.editor.useModal` setting: + * - `'off'`: never open editors modal (user opt-out, honored over `RequiresModal`) + * - `'some'`: open modal only for editors that request it (e.g. `RequiresModal`) + * - `'all'`: open all editors modal + */ +export type UseModalEditorMode = 'off' | 'some' | 'all'; + export type PreferredGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE | AUX_WINDOW_GROUP_TYPE | MODAL_GROUP_TYPE; export function isPreferredGroup(obj: unknown): obj is PreferredGroup { diff --git a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts index 2055733576b803..0883de52895fdd 100644 --- a/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorResolverService.test.ts @@ -348,6 +348,204 @@ suite('EditorResolverService', () => { diffAssociationRegisteredEditor.dispose(); }); + test('Diff editor Resolve - editorAssociations does not force a `never` diff editor', async () => { + const DEFAULT_DIFF_INPUT_ID = 'testDefaultDiffInput'; + const NEVER_DIFF_INPUT_ID = 'testNeverDiffInput'; + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [editorsAssociationsSettingId]: { + '*.test-never-diff': 'NEVER_DIFF_EDITOR' + } + }) + }, disposables); + const [part, service, accessor] = await createEditorResolverService(instantiationService); + let defaultDiffCounter = 0; + let neverDiffCounter = 0; + + const defaultRegisteredEditor = service.registerEditor('*', + { + id: 'default', + label: 'Default Editor', + detail: 'Default', + priority: RegisteredEditorPriority.builtin + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, TEST_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + defaultDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, DEFAULT_DIFF_INPUT_ID) }; + } + } + ); + + // An editor that handles the normal editor but explicitly opts out of diffs via a `never` priority. + const neverDiffRegisteredEditor = service.registerEditor('*.test-never-diff', + { + id: 'NEVER_DIFF_EDITOR', + label: 'Never Diff Editor Label', + detail: 'Never Diff Editor Details', + priority: { + editor: RegisteredEditorPriority.option, + diff: RegisteredEditorPriority.never, + merge: RegisteredEditorPriority.never + } + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, NEVER_DIFF_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + neverDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, NEVER_DIFF_INPUT_ID) }; + } + } + ); + + // The diff must fall back to the default diff editor, not the `never` editor. + const diffResolution = await service.resolveEditor({ + original: { resource: URI.file('resource-basics.test-never-diff') }, + modified: { resource: URI.file('resource-basics.test-never-diff') } + }, part.activeGroup); + assert.ok(diffResolution); + assert.notStrictEqual(typeof diffResolution, 'number'); + if (diffResolution !== ResolvedStatus.ABORT && diffResolution !== ResolvedStatus.NONE) { + assert.strictEqual(neverDiffCounter, 0); + assert.strictEqual(defaultDiffCounter, 1); + diffResolution.editor.dispose(); + } else { + assert.fail(); + } + + // The normal editor association is still honored (`editor` priority is `option`, not `never`). + const editorResolution = await service.resolveEditor({ resource: URI.file('resource-basics.test-never-diff') }, part.activeGroup); + assert.ok(editorResolution); + assert.notStrictEqual(typeof editorResolution, 'number'); + if (editorResolution !== ResolvedStatus.ABORT && editorResolution !== ResolvedStatus.NONE) { + assert.strictEqual(editorResolution.editor.typeId, NEVER_DIFF_INPUT_ID); + editorResolution.editor.dispose(); + } else { + assert.fail(); + } + + defaultRegisteredEditor.dispose(); + neverDiffRegisteredEditor.dispose(); + }); + + test('Diff editor Resolve - diffEditorAssociations force a `never` diff editor', async () => { + const DEFAULT_DIFF_INPUT_ID = 'testDefaultDiffInput'; + const NEVER_DIFF_INPUT_ID = 'testNeverDiffInput'; + const instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [diffEditorsAssociationsSettingId]: { + '*.test-never-diff': 'NEVER_DIFF_EDITOR' + } + }) + }, disposables); + const [part, service, accessor] = await createEditorResolverService(instantiationService); + let defaultDiffCounter = 0; + let neverDiffCounter = 0; + + const defaultRegisteredEditor = service.registerEditor('*', + { + id: 'default', + label: 'Default Editor', + detail: 'Default', + priority: RegisteredEditorPriority.builtin + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, TEST_EDITOR_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + defaultDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, DEFAULT_DIFF_INPUT_ID) }; + } + } + ); + + const neverDiffRegisteredEditor = service.registerEditor('*.test-never-diff', + { + id: 'NEVER_DIFF_EDITOR', + label: 'Never Diff Editor Label', + detail: 'Never Diff Editor Details', + priority: { + editor: RegisteredEditorPriority.option, + diff: RegisteredEditorPriority.never, + merge: RegisteredEditorPriority.never + } + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, NEVER_DIFF_INPUT_ID, disposables) }), + createDiffEditorInput: ({ modified, original }) => { + neverDiffCounter++; + return { editor: constructDisposableDiffEditorInput(accessor, original, modified, NEVER_DIFF_INPUT_ID) }; + } + } + ); + + // The specialized diff association forces the `never` editor even though it opted out of diffs. + const diffResolution = await service.resolveEditor({ + original: { resource: URI.file('resource-basics.test-never-diff') }, + modified: { resource: URI.file('resource-basics.test-never-diff') } + }, part.activeGroup); + assert.ok(diffResolution); + assert.notStrictEqual(typeof diffResolution, 'number'); + if (diffResolution !== ResolvedStatus.ABORT && diffResolution !== ResolvedStatus.NONE) { + assert.strictEqual(defaultDiffCounter, 0); + assert.strictEqual(neverDiffCounter, 1); + diffResolution.editor.dispose(); + } else { + assert.fail(); + } + + defaultRegisteredEditor.dispose(); + neverDiffRegisteredEditor.dispose(); + }); + + test('getBinaryDiffFallbackEditor returns a diff-capable `never` editor and ignores non-diff editors', async () => { + const [, service] = await createEditorResolverService(); + + // A custom editor that opts out of diffs (`never`) but *does* provide a diff editor factory. + const neverWithDiff = service.registerEditor('*.bin', + { + id: 'BINARY_EDITOR', + label: 'Binary Editor', + detail: 'Binary Editor Details', + priority: { + editor: RegisteredEditorPriority.default, + diff: RegisteredEditorPriority.never, + merge: RegisteredEditorPriority.never + } + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, 'binaryInput', disposables) }), + createDiffEditorInput: ({ modified, original }) => ({ editor: constructDisposableFileEditorInput(modified.resource ?? original.resource!, 'binaryDiffInput', disposables) }) + } + ); + + // A custom editor that provides no diff factory must never be used as a binary diff fallback. + const noDiff = service.registerEditor('*.noDiff', + { + id: 'NO_DIFF_EDITOR', + label: 'No Diff Editor', + detail: 'No Diff Editor Details', + priority: RegisteredEditorPriority.default + }, + {}, + { + createEditorInput: ({ resource }) => ({ editor: constructDisposableFileEditorInput(resource, 'noDiffInput', disposables) }) + } + ); + + assert.strictEqual(service.getBinaryDiffFallbackEditor(URI.file('file.bin')), 'BINARY_EDITOR'); + assert.strictEqual(service.getBinaryDiffFallbackEditor(URI.file('file.noDiff')), undefined); + assert.strictEqual(service.getBinaryDiffFallbackEditor(URI.file('file.unrelated')), undefined); + + neverWithDiff.dispose(); + noDiff.dispose(); + }); + test('Diff editor Resolve - Different Types', async () => { const [part, service, accessor] = await createEditorResolverService(); let diffOneCounter = 0; diff --git a/src/vs/workbench/services/history/browser/historyService.ts b/src/vs/workbench/services/history/browser/historyService.ts index 1aed5533b6805e..e576f211a53622 100644 --- a/src/vs/workbench/services/history/browser/historyService.ts +++ b/src/vs/workbench/services/history/browser/historyService.ts @@ -22,7 +22,7 @@ import { IInstantiationService } from '../../../../platform/instantiation/common import { EditorServiceImpl } from '../../../browser/parts/editor/editor.js'; import { IWorkbenchLayoutService } from '../../layout/browser/layoutService.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { coalesce, remove } from '../../../../base/common/arrays.js'; +import { coalesce } from '../../../../base/common/arrays.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { addDisposableListener, EventType, EventHelper, WindowIdleValue } from '../../../../base/browser/dom.js'; import { IWorkspacesService } from '../../../../platform/workspaces/common/workspaces.js'; @@ -48,6 +48,13 @@ interface IRecentlyClosedEditor { readonly index: number; readonly sticky: boolean; + + /** + * Identifies the batch of editors that were closed together (e.g. via + * "Close All Editors" or "Close Others"). Editors sharing the same batch + * identifier are reopened together by "Reopen Closed Editor". + */ + readonly batchId: number; } export class HistoryService extends Disposable implements IHistoryService { @@ -659,6 +666,9 @@ export class HistoryService extends Disposable implements IHistoryService { private recentlyClosedEditors: IRecentlyClosedEditor[] = []; private ignoreEditorCloseEvent = false; + private recentlyClosedEditorsBatchId = 0; + private recentlyClosedEditorsBatchScheduled = false; + private handleEditorCloseEventInReopen(event: IEditorCloseEvent): void { if (this.ignoreEditorCloseEvent) { return; // blocked @@ -696,7 +706,8 @@ export class HistoryService extends Disposable implements IHistoryService { resource: EditorResourceAccessor.getOriginalUri(editor), associatedResources, index: event.index, - sticky: event.sticky + sticky: event.sticky, + batchId: this.currentRecentlyClosedEditorsBatchId() }); // Bounding @@ -708,13 +719,29 @@ export class HistoryService extends Disposable implements IHistoryService { this.canReopenClosedEditorContextKey.set(true); } + private currentRecentlyClosedEditorsBatchId(): number { + + // All editors that are closed within the same synchronous turn + // (e.g. "Close All Editors" or "Close Others") share the same batch + // identifier so that they are reopened together. We open a new batch + // on the first close event and reset it on the next microtask, after + // all synchronously fired close events have been handled. + if (!this.recentlyClosedEditorsBatchScheduled) { + this.recentlyClosedEditorsBatchScheduled = true; + this.recentlyClosedEditorsBatchId++; + queueMicrotask(() => this.recentlyClosedEditorsBatchScheduled = false); + } + + return this.recentlyClosedEditorsBatchId; + } + async reopenLastClosedEditor(): Promise<void> { - // Open editor if we have one - const lastClosedEditor = this.recentlyClosedEditors.pop(); + // Reopen the last batch of editors that were closed together + const lastClosedEditors = this.takeLastClosedEditorsBatch(); let reopenClosedEditorPromise: Promise<void> | undefined = undefined; - if (lastClosedEditor) { - reopenClosedEditorPromise = this.doReopenLastClosedEditor(lastClosedEditor); + if (lastClosedEditors.length) { + reopenClosedEditorPromise = this.doReopenLastClosedEditors(lastClosedEditors); } // Update context @@ -723,7 +750,44 @@ export class HistoryService extends Disposable implements IHistoryService { return reopenClosedEditorPromise; } - private async doReopenLastClosedEditor(lastClosedEditor: IRecentlyClosedEditor): Promise<void> { + private takeLastClosedEditorsBatch(): IRecentlyClosedEditor[] { + const lastClosedEditor = this.recentlyClosedEditors.at(-1); + if (!lastClosedEditor) { + return []; + } + + // Collect all trailing editors that belong to the same batch. They are + // contiguous at the end of the list because editors are appended in the + // order they are closed. + const batch: IRecentlyClosedEditor[] = []; + while (this.recentlyClosedEditors.length && this.recentlyClosedEditors[this.recentlyClosedEditors.length - 1].batchId === lastClosedEditor.batchId) { + batch.unshift(this.recentlyClosedEditors.pop()!); + } + + return batch; + } + + private async doReopenLastClosedEditors(lastClosedEditors: IRecentlyClosedEditor[]): Promise<void> { + + // Reopen all editors of the batch in the order they were originally closed + let anyReopened = false; + for (const lastClosedEditor of lastClosedEditors) { + const editorPane = await this.doReopenLastClosedEditor(lastClosedEditor); + if (editorPane) { + anyReopened = true; + } + } + + // Fix for https://github.com/microsoft/vscode/issues/67882 + // If none of the editors in the batch could be reopened, make sure to + // try the previous batch. The failing editors have already been removed + // from the list of recently closed editors to prevent endless loops. + if (!anyReopened && this.recentlyClosedEditors.length) { + return this.reopenLastClosedEditor(); + } + } + + private async doReopenLastClosedEditor(lastClosedEditor: IRecentlyClosedEditor): Promise<IEditorPane | undefined> { const options: IEditorOptions = { pinned: true, sticky: lastClosedEditor.sticky, index: lastClosedEditor.index, ignoreError: true }; // Special sticky handling: remove the index property from options @@ -760,18 +824,7 @@ export class HistoryService extends Disposable implements IHistoryService { } } - // If no editor was opened, try with the next one - if (!editorPane) { - - // Fix for https://github.com/microsoft/vscode/issues/67882 - // If opening of the editor fails, make sure to try the next one - // but make sure to remove this one from the list to prevent - // endless loops. - remove(this.recentlyClosedEditors, lastClosedEditor); - - // Try with next one - this.reopenLastClosedEditor(); - } + return editorPane; } private removeFromRecentlyClosedEditors(arg1: EditorInput | FileChangesEvent | FileOperationEvent): void { diff --git a/src/vs/workbench/services/history/test/browser/historyService.test.ts b/src/vs/workbench/services/history/test/browser/historyService.test.ts index 229b95e85dfe77..dab1db135df18a 100644 --- a/src/vs/workbench/services/history/test/browser/historyService.test.ts +++ b/src/vs/workbench/services/history/test/browser/historyService.test.ts @@ -652,6 +652,32 @@ suite('HistoryService', function () { return workbenchTeardown(instantiationService); }); + test('reopen closed editors as a batch when closed together', async function () { + const [part, historyService, editorService, , instantiationService] = await createServices(); + + const resource1 = toResource.call(this, '/path/one.txt'); + const resource2 = toResource.call(this, '/path/two.txt'); + const resource3 = toResource.call(this, '/path/three.txt'); + + await editorService.openEditor({ resource: resource1, options: { pinned: true } }); + await editorService.openEditor({ resource: resource2, options: { pinned: true } }); + await editorService.openEditor({ resource: resource3, options: { pinned: true } }); + + assert.strictEqual(part.activeGroup.count, 3); + + // Bulk close: all editors closed together should reopen together + await part.activeGroup.closeAllEditors(); + assert.strictEqual(part.activeGroup.count, 0); + + await historyService.reopenLastClosedEditor(); + + assert.strictEqual(part.activeGroup.count, 3); + const reopened = part.activeGroup.editors.map(editor => editor.resource?.toString()).sort(); + assert.deepStrictEqual(reopened, [resource1, resource2, resource3].map(resource => resource.toString()).sort()); + + return workbenchTeardown(instantiationService); + }); + test('getHistory', async () => { class TestFileEditorInputWithUntyped extends TestFileEditorInput { diff --git a/src/vs/workbench/services/inlineCompletions/common/inlineCompletionsUnification.ts b/src/vs/workbench/services/inlineCompletions/common/inlineCompletionsUnification.ts index 16802452df2b14..b3b6f2ca3d94cb 100644 --- a/src/vs/workbench/services/inlineCompletions/common/inlineCompletionsUnification.ts +++ b/src/vs/workbench/services/inlineCompletions/common/inlineCompletionsUnification.ts @@ -76,6 +76,7 @@ export class InlineCompletionsUnificationImpl extends Disposable implements IInl this.isRunningUnificationExperiment = isRunningUnificationExperiment.bindTo(this._contextKeyService); this._assignmentService.addTelemetryAssignmentFilter({ + id: 'inlineCompletionsUnification', exclude: (assignment) => assignment.startsWith(EXTENSION_UNIFICATION_PREFIX) && this._state.extensionUnification !== this._configurationService.getValue<boolean>(ExtensionUnificationSetting), onDidChange: Event.any(this._onDidChangeExtensionUnificationState.event, this._onDidChangeExtensionUnificationSetting.event) }); diff --git a/src/vs/workbench/services/keybinding/browser/keybindingService.ts b/src/vs/workbench/services/keybinding/browser/keybindingService.ts index bb0fb4f26430ee..5db49b2be12174 100644 --- a/src/vs/workbench/services/keybinding/browser/keybindingService.ts +++ b/src/vs/workbench/services/keybinding/browser/keybindingService.ts @@ -487,11 +487,11 @@ export class WorkbenchKeybindingService extends AbstractKeybindingService { const when = item.when || undefined; if (!item.keybinding) { // This might be a removal keybinding item in user settings => accept it - result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, null, false); + result[resultLen++] = new ResolvedKeybindingItem(undefined, item.command, item.commandArgs, when, isDefault, null, false, item.systemWide); } else { const resolvedKeybindings = this._keyboardMapper.resolveKeybinding(item.keybinding); for (const resolvedKeybinding of resolvedKeybindings) { - result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, null, false); + result[resultLen++] = new ResolvedKeybindingItem(resolvedKeybinding, item.command, item.commandArgs, when, isDefault, null, false, item.systemWide); } } } @@ -926,6 +926,11 @@ class KeybindingsJsonSchema { }, 'args': { 'description': nls.localize('keybindings.json.args', "Arguments to pass to the command to execute.") + }, + 'systemWide': { + 'type': 'boolean', + 'default': false, + 'markdownDescription': nls.localize('keybindings.json.systemWide', "When `true`, registers this keybinding as a system-wide (OS global) shortcut that fires even when the application is not focused. Desktop only. Only single key combinations are supported (no chords), and any `when` clause is ignored for the global trigger.") } }, '$ref': '#/definitions/commandsSchemas' diff --git a/src/vs/workbench/services/keybinding/common/keybindingIO.ts b/src/vs/workbench/services/keybinding/common/keybindingIO.ts index df8e1b3c9b8fb4..e23a47e5569b61 100644 --- a/src/vs/workbench/services/keybinding/common/keybindingIO.ts +++ b/src/vs/workbench/services/keybinding/common/keybindingIO.ts @@ -13,6 +13,7 @@ export interface IUserKeybindingItem { command: string | null; commandArgs?: unknown; when: ContextKeyExpression | undefined; + systemWide: boolean; _sourceKey: string | undefined; /** captures `key` field from `keybindings.json`; `this.keybinding !== null` implies `_sourceKey !== null` */ } @@ -39,6 +40,11 @@ export class KeybindingIO { out.writeLine(); out.write(` "args": ${JSON.stringify(item.commandArgs)}`); } + if (item.systemWide) { + out.write(','); + out.writeLine(); + out.write(` "systemWide": true`); + } out.write(' }'); } @@ -55,11 +61,15 @@ export class KeybindingIO { const commandArgs = 'args' in input && typeof input.args !== 'undefined' ? input.args : undefined; + const systemWide = 'systemWide' in input && typeof input.systemWide === 'boolean' + ? input.systemWide + : false; return { keybinding, command, commandArgs, when, + systemWide, _sourceKey: 'key' in input && typeof input.key === 'string' ? input.key : undefined, }; } diff --git a/src/vs/workbench/services/keybinding/test/browser/keybindingEditing.test.ts b/src/vs/workbench/services/keybinding/test/browser/keybindingEditing.test.ts index e0c19e38865bec..752464c9810c11 100644 --- a/src/vs/workbench/services/keybinding/test/browser/keybindingEditing.test.ts +++ b/src/vs/workbench/services/keybinding/test/browser/keybindingEditing.test.ts @@ -170,6 +170,13 @@ suite('KeybindingsEditing', () => { assert.deepStrictEqual(await getUserKeybindings(), expected); }); + test('edit a user keybinding preserves systemWide on other entries', async () => { + await writeToKeybindingsFile({ key: 'escape', command: 'b' }, { key: 'alt+shift+g', command: 'c', systemWide: true }); + const expected: IUserFriendlyKeybinding[] = [{ key: 'alt+c', command: 'b' }, { key: 'alt+shift+g', command: 'c', systemWide: true }]; + await testObject.editKeybinding(aResolvedKeybindingItem({ firstChord: { keyCode: KeyCode.Escape }, command: 'b', isDefault: false }), 'alt+c', undefined); + assert.deepStrictEqual(await getUserKeybindings(), expected); + }); + test('remove a default keybinding', async () => { const expected: IUserFriendlyKeybinding[] = [{ key: 'alt+c', command: '-a' }]; await testObject.removeKeybinding(aResolvedKeybindingItem({ command: 'a', firstChord: { keyCode: KeyCode.KeyC, modifiers: { altKey: true } } })); diff --git a/src/vs/workbench/services/keybinding/test/browser/keybindingIO.test.ts b/src/vs/workbench/services/keybinding/test/browser/keybindingIO.test.ts index e3bceda70b044e..1aa7ed0800187e 100644 --- a/src/vs/workbench/services/keybinding/test/browser/keybindingIO.test.ts +++ b/src/vs/workbench/services/keybinding/test/browser/keybindingIO.test.ts @@ -7,8 +7,9 @@ import { KeyChord, KeyCode, KeyMod, ScanCode } from '../../../../../base/common/ import { KeyCodeChord, decodeKeybinding, ScanCodeChord, Keybinding } from '../../../../../base/common/keybindings.js'; import { KeybindingParser } from '../../../../../base/common/keybindingParser.js'; import { OperatingSystem } from '../../../../../base/common/platform.js'; -import { KeybindingIO } from '../../common/keybindingIO.js'; +import { KeybindingIO, OutputBuilder } from '../../common/keybindingIO.js'; import { createUSLayoutResolvedKeybinding } from '../../../../../platform/keybinding/test/common/keybindingsTestUtils.js'; +import { ResolvedKeybindingItem } from '../../../../../platform/keybinding/common/resolvedKeybindingItem.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; suite('keybindingIO', () => { @@ -158,4 +159,31 @@ suite('keybindingIO', () => { const keybindingItem = KeybindingIO.readUserKeybindingItem(userKeybinding); assert.strictEqual((keybindingItem.commandArgs as unknown as { text: string }).text, 'theText'); }); + + test('systemWide - read defaults to false when absent or invalid', () => { + const absent = KeybindingIO.readUserKeybindingItem(<Object>JSON.parse(`{ "key": "ctrl+cmd+a", "command": "firstcommand" }`)); + assert.strictEqual(absent.systemWide, false); + + const invalid = KeybindingIO.readUserKeybindingItem(<Object>JSON.parse(`{ "key": "ctrl+cmd+a", "command": "firstcommand", "systemWide": "yes" }`)); + assert.strictEqual(invalid.systemWide, false); + }); + + test('systemWide - read parses boolean value', () => { + const item = KeybindingIO.readUserKeybindingItem(<Object>JSON.parse(`{ "key": "ctrl+cmd+a", "command": "firstcommand", "systemWide": true }`)); + assert.strictEqual(item.systemWide, true); + }); + + test('systemWide - write/export preserves the flag (roundtrip)', () => { + const resolvedKeybinding = createUSLayoutResolvedKeybinding(KeyMod.CtrlCmd | KeyMod.WinCtrl | KeyCode.KeyA, OperatingSystem.Macintosh)!; + const item = new ResolvedKeybindingItem(resolvedKeybinding, 'workbench.action.openAgentsWindow', undefined, undefined, false, null, false, /* systemWide */ true); + + const out = new OutputBuilder(); + KeybindingIO.writeKeybindingItem(out, item); + const serialized = out.toString(); + assert.ok(serialized.includes('"systemWide": true'), `expected serialized keybinding to include systemWide, got: ${serialized}`); + + const readBack = KeybindingIO.readUserKeybindingItem(<Object>JSON.parse(serialized)); + assert.strictEqual(readBack.systemWide, true); + assert.strictEqual(readBack.command, 'workbench.action.openAgentsWindow'); + }); }); diff --git a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts index bfad4bc79cad91..8168326416130b 100644 --- a/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts +++ b/src/vs/workbench/services/languageDetection/browser/languageDetectionWorkerServiceImpl.ts @@ -21,7 +21,6 @@ import { IEditorService } from '../../editor/common/editorService.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { LRUCache } from '../../../../base/common/map.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { canASAR } from '../../../../amdX.js'; import { WebWorkerDescriptor } from '../../../../platform/webWorker/browser/webWorkerDescriptor.js'; import { IWebWorkerService } from '../../../../platform/webWorker/browser/webWorkerService.js'; import { WorkerTextModelSyncClient } from '../../../../editor/common/services/textModelSync/textModelSync.impl.js'; @@ -68,7 +67,7 @@ export class LanguageDetectionService extends Disposable implements ILanguageDet ) { super(); - const useAsar = canASAR && this._environmentService.isBuilt && !isWeb; + const useAsar = this._environmentService.isBuilt && !isWeb; this._languageDetectionWorkerClient = this._register(new LanguageDetectionWorkerClient( modelService, languageService, diff --git a/src/vs/workbench/services/layout/browser/layoutService.ts b/src/vs/workbench/services/layout/browser/layoutService.ts index c8a921507134c5..45a96404752f41 100644 --- a/src/vs/workbench/services/layout/browser/layoutService.ts +++ b/src/vs/workbench/services/layout/browser/layoutService.ts @@ -10,7 +10,7 @@ import { Part } from '../../../browser/part.js'; import { IDimension } from '../../../../base/browser/dom.js'; import { Direction, IViewSize } from '../../../../base/browser/ui/grid/grid.js'; import { isMacintosh, isNative, isWeb } from '../../../../base/common/platform.js'; -import { isAuxiliaryWindow } from '../../../../base/browser/window.js'; +import { isAuxiliaryWindow, mainWindow } from '../../../../base/browser/window.js'; import { CustomTitleBarVisibility, TitleBarSetting, getMenuBarVisibility, hasCustomTitlebar, hasNativeMenu, hasNativeTitlebar } from '../../../../platform/window/common/window.js'; import { isFullscreen, isWCOEnabled } from '../../../../base/browser/browser.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; @@ -58,9 +58,9 @@ export const enum LayoutSettings { * experiment (`LayoutSettings.MODERN_UI`) is enabled. Parts grow or shrink their * content by this amount to leave room for the margin/border applied in CSS * (`src/vs/workbench/browser/media/floatingPanels.css`, `.floating-panels`). - * Keep in sync with the `--vscode-spacing-size60` (6px) token used there. + * Keep in sync with the `--vscode-spacing-size40` (4px) token used there. */ -export const FLOATING_PANEL_MARGIN = 6; +export const FLOATING_PANEL_MARGIN = 4; export const enum ActivityBarPosition { DEFAULT = 'default', @@ -120,10 +120,11 @@ export function positionToString(position: Position): string { * * The horizontal order of the parts is reconstructed from the same inputs the grid * layout uses (mirrors `Layout.adjustPartPositions` in `src/vs/workbench/browser/layout.ts`): the activity bar and primary side bar sit - * on `getSideBarPosition()`, the secondary side bar on the opposite side, and a - * vertical (left/right) panel sits immediately next to the editor on its placement - * side. The outermost *visible* part on each edge wins; the activity bar is not a - * floating card, so it yields no owner. + * on `getSideBarPosition()`, the secondary side bar on the opposite side, the editor in + * the middle, and a vertical (left/right) panel immediately next to the editor on its + * placement side. The outermost *visible* part on each edge wins; the activity bar is not + * a floating card, so it yields no owner. A hidden editor is skipped, so a maximized side + * bar (which spans the full content width) is correctly detected as the owner on both edges. * * Consumed by `AbstractPaneCompositePart` (side bars and panel) and `EditorPart` * (main editor) so the doubled-gutter decision stays in sync between them. @@ -142,23 +143,51 @@ export function getFloatingOuterEdgeOwners(layoutService: IWorkbenchLayoutServic const panelInLeftSequence = verticalPanelVisible && panelPosition === Position.LEFT; const panelInRightSequence = verticalPanelVisible && panelPosition === Position.RIGHT; - // Parts that can sit at each window edge outside the editor, ordered outermost -> - // innermost. The editor is the innermost terminal owner (handled in the resolver). - const sideBarSideParts: SINGLE_WINDOW_PARTS[] = [Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART]; - const auxSideParts: SINGLE_WINDOW_PARTS[] = [Parts.AUXILIARYBAR_PART]; - const panelParts: SINGLE_WINDOW_PARTS[] = [Parts.PANEL_PART]; - const leftOuterParts: SINGLE_WINDOW_PARTS[] = [...(sideBarLeft ? sideBarSideParts : auxSideParts), ...(panelInLeftSequence ? panelParts : [])]; - const rightOuterParts: SINGLE_WINDOW_PARTS[] = [...(sideBarLeft ? auxSideParts : sideBarSideParts), ...(panelInRightSequence ? panelParts : [])]; + // The full window order of the floatable parts, left -> right: the activity bar and + // primary side bar sit together on `getSideBarPosition()` (activity bar outermost), the + // secondary side bar on the opposite side, a vertical panel immediately beside the editor + // on its placement side, and the editor in the middle. Each edge is resolved by walking + // this order inward to the first *visible* card, so a hidden editor (e.g. a maximized side + // bar that spans the full content width) is skipped and the spanning card is detected on + // both edges. + const sideBarGroup: Parts[] = [Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART]; + const panelGroup: Parts[] = [Parts.PANEL_PART]; + const fullOrder: Parts[] = sideBarLeft + ? [ + ...sideBarGroup, + ...(panelInLeftSequence ? panelGroup : []), + Parts.EDITOR_PART, + ...(panelInRightSequence ? panelGroup : []), + Parts.AUXILIARYBAR_PART + ] + : [ + Parts.AUXILIARYBAR_PART, + ...(panelInLeftSequence ? panelGroup : []), + Parts.EDITOR_PART, + ...(panelInRightSequence ? panelGroup : []), + ...[...sideBarGroup].reverse() // activity bar is outermost on the right edge + ]; return { - left: resolveFloatingOuterOwner(layoutService, leftOuterParts), - right: resolveFloatingOuterOwner(layoutService, rightOuterParts) + left: resolveFloatingOuterOwner(layoutService, fullOrder), + right: resolveFloatingOuterOwner(layoutService, [...fullOrder].reverse()) }; } -function resolveFloatingOuterOwner(layoutService: IWorkbenchLayoutService, outerParts: SINGLE_WINDOW_PARTS[]): Parts | undefined { - for (const part of outerParts) { - if (!layoutService.isVisible(part)) { +/** + * Walks the given window order (outermost -> innermost from a window edge) and returns the + * first visible part as the owner of that edge. The activity bar hugs the window edge but is + * not a floating card, so a visible activity bar yields no owner. Returns `undefined` when no + * visible card sits on the edge. + */ +function resolveFloatingOuterOwner(layoutService: IWorkbenchLayoutService, orderedParts: Parts[]): Parts | undefined { + for (const part of orderedParts) { + // The editor is the only multi-window part in this order; its main-window visibility + // is what matters for the main-window floating layout. + const visible = part === Parts.EDITOR_PART + ? layoutService.isVisible(Parts.EDITOR_PART, mainWindow) + : layoutService.isVisible(part as SINGLE_WINDOW_PARTS); + if (!visible) { continue; } @@ -166,8 +195,7 @@ function resolveFloatingOuterOwner(layoutService: IWorkbenchLayoutService, outer return part === Parts.ACTIVITYBAR_PART ? undefined : part; } - // Nothing else sits on this edge: the editor is the outermost (central) card. - return Parts.EDITOR_PART; + return undefined; } /** @@ -191,6 +219,24 @@ export function getFloatingOuterGutterEdges(layoutService: IWorkbenchLayoutServi return { left: owners.left === partId, right: owners.right === partId }; } +/** + * Whether the primary sidebar and auxiliary bar are each in the same grid row as the + * editor (sibling to the editor) for a horizontal panel. A bar that is a sibling is not + * full-height; it sits above or below the panel row rather than spanning the full height. + * Mirrors the sideBarSiblingToEditor / auxiliaryBarSiblingToEditor formula used in + * adjustPartPositions() in layout.ts. + */ +export function getFloatingSidebarSiblingToEditorStatus( + layoutService: IWorkbenchLayoutService +): { sideBar: boolean; auxBar: boolean } { + const alignment = layoutService.getPanelAlignment(); + const sideBarOnLeft = layoutService.getSideBarPosition() === Position.LEFT; + return { + sideBar: !(alignment === 'center' || (sideBarOnLeft && alignment === 'right') || (!sideBarOnLeft && alignment === 'left')), + auxBar: !(alignment === 'center' || (!sideBarOnLeft && alignment === 'right') || (sideBarOnLeft && alignment === 'left')), + }; +} + /** * Whether a visible horizontal (bottom/top) panel reaches each window edge and should * therefore receive a doubled outer gutter so it aligns with the editor card above it. @@ -204,12 +250,7 @@ function getFloatingHorizontalPanelOuterEdges(layoutService: IWorkbenchLayoutSer } const sideBarLeft = layoutService.getSideBarPosition() === Position.LEFT; - const alignment = layoutService.getPanelAlignment(); - - // A bar that is a sibling of the editor is not full-height, so the panel spans - // underneath it to the window edge (panel is horizontal, so `isPanelVertical` is false). - const sideBarSiblingToEditor = !(alignment === 'center' || (sideBarLeft && alignment === 'right') || (!sideBarLeft && alignment === 'left')); - const auxSiblingToEditor = !(alignment === 'center' || (!sideBarLeft && alignment === 'right') || (sideBarLeft && alignment === 'left')); + const { sideBar: sideBarSiblingToEditor, auxBar: auxSiblingToEditor } = getFloatingSidebarSiblingToEditorStatus(layoutService); const sideBarSideReached = !layoutService.isVisible(Parts.ACTIVITYBAR_PART) && (!layoutService.isVisible(Parts.SIDEBAR_PART) || sideBarSiblingToEditor); const auxSideReached = !layoutService.isVisible(Parts.AUXILIARYBAR_PART) || auxSiblingToEditor; diff --git a/src/vs/workbench/services/layout/test/browser/layoutService.test.ts b/src/vs/workbench/services/layout/test/browser/layoutService.test.ts new file mode 100644 index 00000000000000..132e2cb1737566 --- /dev/null +++ b/src/vs/workbench/services/layout/test/browser/layoutService.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { getFloatingOuterEdgeOwners, getFloatingSidebarSiblingToEditorStatus, type PanelAlignment, Parts, Position } from '../../browser/layoutService.js'; +import { TestLayoutService } from '../../../../test/browser/workbenchTestServices.js'; + +suite('LayoutService - getFloatingOuterEdgeOwners', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + class ConfigurableLayoutService extends TestLayoutService { + floatingPanelsEnabled = true; + sideBarPosition = Position.LEFT; + panelPosition = Position.BOTTOM; + visibleParts = new Set<Parts>(); + + override isFloatingPanelsEnabled(): boolean { return this.floatingPanelsEnabled; } + override getSideBarPosition(): Position { return this.sideBarPosition; } + override getPanelPosition(): Position { return this.panelPosition; } + override isVisible(part: Parts): boolean { return this.visibleParts.has(part); } + } + + function owners(configure: (service: ConfigurableLayoutService) => void): { left: Parts | undefined; right: Parts | undefined } { + const service = new ConfigurableLayoutService(); + configure(service); + return getFloatingOuterEdgeOwners(service); + } + + test('edge ownership across layouts', () => { + const actual = { + // Experiment disabled: no owners regardless of layout. + disabled: owners(s => { s.floatingPanelsEnabled = false; s.visibleParts = new Set([Parts.AUXILIARYBAR_PART]); }), + + // Default full layout (side bar left): activity bar hugs the left edge (no owner), + // the secondary side bar owns the right edge. + defaultFull: owners(s => { s.visibleParts = new Set([Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART, Parts.EDITOR_PART, Parts.AUXILIARYBAR_PART]); }), + + // Maximized aux bar with the activity bar in its default (visible) position: the + // activity bar still hugs the left edge, the aux bar owns the right edge. + maximizedAuxWithActivityBar: owners(s => { s.visibleParts = new Set([Parts.ACTIVITYBAR_PART, Parts.AUXILIARYBAR_PART]); }), + + // Maximized aux bar with the activity bar not in its default position (hidden from + // the side column): the aux bar spans the full width and owns both edges. + maximizedAuxNoActivityBar: owners(s => { s.visibleParts = new Set([Parts.AUXILIARYBAR_PART]); }), + + // Same, but the side bar is on the right: the aux bar still spans and owns both edges. + maximizedAuxNoActivityBarSideBarRight: owners(s => { s.sideBarPosition = Position.RIGHT; s.visibleParts = new Set([Parts.AUXILIARYBAR_PART]); }), + + // Only the editor visible with the activity bar hidden: the editor is the sole card + // and owns both edges. + editorOnly: owners(s => { s.visibleParts = new Set([Parts.EDITOR_PART]); }), + + // Full layout with a visible left vertical panel: the panel sits between the editor + // and the side bar, so it never reaches an edge. + verticalPanelFull: owners(s => { s.panelPosition = Position.LEFT; s.visibleParts = new Set([Parts.ACTIVITYBAR_PART, Parts.SIDEBAR_PART, Parts.PANEL_PART, Parts.EDITOR_PART, Parts.AUXILIARYBAR_PART]); }), + + // Maximized left vertical panel with the activity bar hidden: the panel spans the + // full width and owns both edges. + maximizedVerticalPanel: owners(s => { s.panelPosition = Position.LEFT; s.visibleParts = new Set([Parts.PANEL_PART]); }), + + // Visible horizontal (bottom) panel: not part of the vertical order, so it owns no + // edge; the secondary side bar still owns the right edge. + horizontalPanelVisible: owners(s => { s.panelPosition = Position.BOTTOM; s.visibleParts = new Set([Parts.SIDEBAR_PART, Parts.EDITOR_PART, Parts.PANEL_PART, Parts.AUXILIARYBAR_PART]); }), + }; + + assert.deepStrictEqual(actual, { + disabled: { left: undefined, right: undefined }, + defaultFull: { left: undefined, right: Parts.AUXILIARYBAR_PART }, + maximizedAuxWithActivityBar: { left: undefined, right: Parts.AUXILIARYBAR_PART }, + maximizedAuxNoActivityBar: { left: Parts.AUXILIARYBAR_PART, right: Parts.AUXILIARYBAR_PART }, + maximizedAuxNoActivityBarSideBarRight: { left: Parts.AUXILIARYBAR_PART, right: Parts.AUXILIARYBAR_PART }, + editorOnly: { left: Parts.EDITOR_PART, right: Parts.EDITOR_PART }, + verticalPanelFull: { left: undefined, right: Parts.AUXILIARYBAR_PART }, + maximizedVerticalPanel: { left: Parts.PANEL_PART, right: Parts.PANEL_PART }, + horizontalPanelVisible: { left: Parts.SIDEBAR_PART, right: Parts.AUXILIARYBAR_PART }, + }); + }); +}); + +suite('LayoutService - getFloatingSidebarSiblingToEditorStatus', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + class SiblingStatusLayoutService extends TestLayoutService { + sideBarPosition = Position.LEFT; + panelAlignment: PanelAlignment = 'center'; + + override getSideBarPosition(): Position { return this.sideBarPosition; } + override getPanelAlignment(): PanelAlignment { return this.panelAlignment; } + } + + function siblingStatus(configure: (s: SiblingStatusLayoutService) => void): { sideBar: boolean; auxBar: boolean } { + const s = new SiblingStatusLayoutService(); + configure(s); + return getFloatingSidebarSiblingToEditorStatus(s); + } + + test('sibling-to-editor status across alignment and sidebar-position combinations', () => { + const actual = { + // center: neither bar is a sibling (both span full height) + centerLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'center'; }), + centerRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'center'; }), + // justify: both bars are siblings (panel spans the full width) + justifyLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'justify'; }), + justifyRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'justify'; }), + // left alignment, sidebar on LEFT: sidebar IS sibling, aux bar is NOT + leftAlignSidebarLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'left'; }), + // left alignment, sidebar on RIGHT: sidebar is NOT sibling, aux bar IS + leftAlignSidebarRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'left'; }), + // right alignment, sidebar on LEFT: sidebar is NOT sibling, aux bar IS + rightAlignSidebarLeft: siblingStatus(s => { s.sideBarPosition = Position.LEFT; s.panelAlignment = 'right'; }), + // right alignment, sidebar on RIGHT: sidebar IS sibling, aux bar is NOT + rightAlignSidebarRight: siblingStatus(s => { s.sideBarPosition = Position.RIGHT; s.panelAlignment = 'right'; }), + }; + + assert.deepStrictEqual(actual, { + centerLeft: { sideBar: false, auxBar: false }, + centerRight: { sideBar: false, auxBar: false }, + justifyLeft: { sideBar: true, auxBar: true }, + justifyRight: { sideBar: true, auxBar: true }, + leftAlignSidebarLeft: { sideBar: true, auxBar: false }, + leftAlignSidebarRight: { sideBar: false, auxBar: true }, + rightAlignSidebarLeft: { sideBar: false, auxBar: true }, + rightAlignSidebarRight: { sideBar: true, auxBar: false }, + }); + }); +}); diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index ac62b6661f835c..81dc15fb056c6b 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -11,7 +11,7 @@ import { localize } from '../../../../nls.js'; import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { INativeManagedSettingsService, IFileManagedSettingsService, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, selectManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; +import { INativeManagedSettingsService, IFileManagedSettingsService, collectManagedSettingsDefinitions, hasManagedSettingsDefinitions, projectManagedSettings, pickManagedSettings } from '../../../../platform/policy/common/copilotManagedSettings.js'; import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; @@ -190,17 +190,18 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli const nativeManagedSettings = mdmManagedSettings ?? this.nativeManagedSettingsService?.managedSettings; const fileManagedSettings = this.fileManagedSettingsService?.managedSettings; - // Single authoritative source: server-delivered managed settings win over native MDM, which - // in turn win over the file-based channel. The channels are never merged. - // See `.github/skills/add-policy/github-managed-settings.md` for the precedence rationale. - const selection = selectManagedSettings(accountPolicyData?.managedSettings, nativeManagedSettings, fileManagedSettings); - if (!accountPolicyData && selection.source === 'none') { + // Per-key precedence: native MDM wins over the server-delivered channel, which in turn wins + // over the file-based channel — but resolved key-by-key, so a key left unset by a higher + // channel is still filled in by a lower one. A key locked by a higher channel cannot be + // overwritten. See `.github/skills/add-policy/github-managed-settings.md` for the rationale. + const pick = pickManagedSettings(nativeManagedSettings, accountPolicyData?.managedSettings, fileManagedSettings); + if (!accountPolicyData && pick.activeSources.length === 0) { return undefined; } const declaredManagedSettings = collectManagedSettingsDefinitions(this.policyDefinitions); const managedSettingsData = projectManagedSettings( - { ...selection.values }, + pick.values, declaredManagedSettings, msg => this.logService.warn(`[AccountPolicy] ${msg}`) ); diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cfcca769187a9d..c0509a94cef6d6 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -284,9 +284,9 @@ suite('AccountPolicyService', () => { }); }); - test('managed settings: server value wins over native MDM for the same declared key', async () => { - // Server says 'enable', native MDM says 'disable'. The server is the authoritative - // source when present, so native MDM is ignored entirely and the gated policy is NOT + test('managed settings: native MDM value wins over server for the same declared key', async () => { + // Server says 'enable', native MDM says 'disable'. Native MDM is the authoritative + // source when present, so the server value is ignored entirely and the gated policy IS // forced to `false`. const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService)); @@ -300,7 +300,7 @@ suite('AccountPolicyService', () => { await policyConfiguration.initialize(); - assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); test('managed settings: native MDM applies when the server provides no managed settings', async () => { @@ -320,10 +320,10 @@ suite('AccountPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); - test('managed settings: three-channel precedence Server > MDM > File', async () => { + test('managed settings: three-channel precedence native MDM > Server > File', async () => { // All three channels provide the same key with different values. // Server says 'enable', MDM says 'disable', File says 'file-value'. - // Server should win. + // Native MDM should win. const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'file-value' }); const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); @@ -337,8 +337,8 @@ suite('AccountPolicyService', () => { await policyConfiguration.initialize(); - // Server value 'enable' wins — policy is not forced to false - assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), undefined); + // Native MDM value 'disable' wins — policy is forced to false + assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); test('managed settings: file-based settings apply when server and MDM are empty', async () => { @@ -359,6 +359,32 @@ suite('AccountPolicyService', () => { assert.strictEqual(policyService.getPolicyValue('PolicySettingF'), false); }); + test('managed settings: per-key precedence merges across channels — different keys win from different channels', async () => { + // Native MDM supplies only the disableBypass key; the file supplies only the enabledPlugins + // key. Neither overrides the other, so BOTH reach policy evaluation: setting F resolves from + // native MDM and setting G resolves from the file. This is the per-key fill-down behavior. + const enabledPluginsJson = '{"assign-issue@skills":true}'; + const fileManagedSettingsService = new FakeFileManagedSettingsService({ [COPILOT_ENABLED_PLUGINS_KEY]: enabledPluginsJson }); + const nativeManagedSettingsService = disposables.add(new FakeNativeManagedSettingsService({ [COPILOT_DISABLE_BYPASS_PERMISSIONS_MODE_KEY]: 'disable' })); + policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService, undefined, nativeManagedSettingsService, fileManagedSettingsService)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); + + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(BASE_DEFAULT_ACCOUNT, {})); + await defaultAccountService.refresh(); + + await policyConfiguration.initialize(); + + assert.deepStrictEqual({ + settingF: policyConfiguration.configurationModel.getValue('setting.F'), + settingG: policyConfiguration.configurationModel.getValue('setting.G'), + }, { + settingF: false, + settingG: { 'assign-issue@skills': true }, + }); + }); + test('managed settings: an object-typed setting resolves identically from server and native MDM JSON strings', async () => { // Structured-setting invariant: whether the canonical JSON string arrives via the server // account policy bag or via native MDM, PolicyConfiguration must parse it back into the diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts index 9f8b2fdc9d32a3..3ba89e08eda8a4 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/threadedBackgroundTokenizerFactory.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { canASAR } from '../../../../../amdX.js'; import { DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { AppResourcePath, FileAccess, nodeModulesAsarPath, nodeModulesPath } from '../../../../../base/common/network.js'; import { IObservable } from '../../../../../base/common/observable.js'; @@ -133,7 +132,7 @@ export class ThreadedBackgroundTokenizerFactory implements IDisposable { const onigurumaModuleLocation: AppResourcePath = `${nodeModulesPath}/vscode-oniguruma`; const onigurumaModuleLocationAsar: AppResourcePath = `${nodeModulesAsarPath}/vscode-oniguruma`; - const useAsar = canASAR && this._environmentService.isBuilt && !isWeb; + const useAsar = this._environmentService.isBuilt && !isWeb; const onigurumaLocation: AppResourcePath = useAsar ? onigurumaModuleLocationAsar : onigurumaModuleLocation; const onigurumaWASM: AppResourcePath = `${onigurumaLocation}/release/onig.wasm`; diff --git a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts index 43b68e0e1333cd..15afd968006e15 100644 --- a/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts +++ b/src/vs/workbench/services/textMate/browser/textMateTokenizationFeatureImpl.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { canASAR, importAMDNodeModule, resolveAmdNodeModulePath } from '../../../../amdX.js'; +import { importAMDNodeModule, resolveAmdNodeModulePath } from '../../../../amdX.js'; import * as domStylesheets from '../../../../base/browser/domStylesheets.js'; import { equals as equalArray } from '../../../../base/common/arrays.js'; import { Color } from '../../../../base/common/color.js'; @@ -399,7 +399,7 @@ export class TextMateTokenizationFeature extends Disposable implements ITextMate // We therefore use the non-streaming compiler :(. return await response.arrayBuffer(); } else { - const response = await fetch(canASAR && this._environmentService.isBuilt + const response = await fetch(this._environmentService.isBuilt ? FileAccess.asBrowserUri(`${nodeModulesAsarUnpackedPath}/vscode-oniguruma/release/onig.wasm`).toString(true) : FileAccess.asBrowserUri(`${nodeModulesPath}/vscode-oniguruma/release/onig.wasm`).toString(true)); return response; diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index 99405e3d8552a8..44f587aeb63b0f 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -450,7 +450,7 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme private installPreferredSchemeListener() { this._register(this.hostColorService.onDidChangeColorScheme(() => { - if (this.settings.isDetectingColorScheme()) { + if (this.settings.isDetectingColorScheme() || this.settings.isDetectingHighContrast()) { this.restoreColorTheme(); } })); diff --git a/src/vs/workbench/services/themes/common/themeConfiguration.ts b/src/vs/workbench/services/themes/common/themeConfiguration.ts index 34902a14c7965c..4e2a83c10dd870 100644 --- a/src/vs/workbench/services/themes/common/themeConfiguration.ts +++ b/src/vs/workbench/services/themes/common/themeConfiguration.ts @@ -343,6 +343,10 @@ export class ThemeConfiguration { return undefined; } + public isDetectingHighContrast(): boolean { + return this.configurationService.getValue(ThemeSettings.DETECT_HC); + } + public isDetectingColorScheme(): boolean { return this.configurationService.getValue(ThemeSettings.DETECT_COLOR_SCHEME); } diff --git a/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts b/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts index a3a921f7b7cfed..5805db6a6ef279 100644 --- a/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts +++ b/src/vs/workbench/services/treeSitter/browser/treeSitterLibraryService.ts @@ -7,7 +7,7 @@ import type { Parser, Language, Query } from '@vscode/tree-sitter-wasm'; import { IReader, ObservablePromise } from '../../../../base/common/observable.js'; import { ITreeSitterLibraryService } from '../../../../editor/common/services/treeSitter/treeSitterLibraryService.js'; -import { canASAR, importAMDNodeModule } from '../../../../amdX.js'; +import { importAMDNodeModule } from '../../../../amdX.js'; import { Lazy } from '../../../../base/common/lazy.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { FileOperationResult, IFileContent, IFileService, toFileOperationResult } from '../../../../platform/files/common/files.js'; @@ -16,6 +16,7 @@ import { CachedFunction } from '../../../../base/common/cache.js'; import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { AppResourcePath, FileAccess, nodeModulesAsarUnpackedPath, nodeModulesPath } from '../../../../base/common/network.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; export const EDITOR_EXPERIMENTAL_PREFER_TREESITTER = 'editor.experimental.preferTreeSitter'; @@ -25,7 +26,8 @@ const MODULE_LOCATION_SUBPATH = `@vscode/tree-sitter-wasm/wasm`; const FILENAME_TREESITTER_WASM = `tree-sitter.wasm`; export function getModuleLocation(environmentService: IEnvironmentService): AppResourcePath { - return `${(canASAR && environmentService.isBuilt) ? nodeModulesAsarUnpackedPath : nodeModulesPath}/${MODULE_LOCATION_SUBPATH}`; + const useAsarUnpacked = environmentService.isBuilt && !isWeb; + return `${useAsarUnpacked ? nodeModulesAsarUnpackedPath : nodeModulesPath}/${MODULE_LOCATION_SUBPATH}`; } export class TreeSitterLibraryService extends Disposable implements ITreeSitterLibraryService { diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts index c9709070c7ff02..cbee33300401db 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatFixtureUtils.ts @@ -4,7 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from '../../../../../base/common/event.js'; +import { IReference } from '../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -33,11 +35,16 @@ import { INotebookDocumentService } from '../../../../services/notebook/common/n import { IViewDescriptorService } from '../../../../common/views.js'; import { ISCMService } from '../../../../contrib/scm/common/scm.js'; import { IAgentHostService } from '../../../../../platform/agentHost/common/agentService.js'; +import { IAgentSubscription } from '../../../../../platform/agentHost/common/state/agentSubscription.js'; +import { StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { IAgentHostUntitledProvisionalSessionService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.js'; import { IAgentHostSessionWorkingDirectoryResolver } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostSessionWorkingDirectoryResolver.js'; import { IAgentHostNewSessionFolderService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostNewSessionFolderService.js'; import { IChatAccessibilityService, IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; +import { IChatOutputRendererService } from '../../../../contrib/chat/browser/chatOutputItemRenderer.js'; +import { IAiEditTelemetryService } from '../../../../contrib/editTelemetry/browser/telemetry/aiEditTelemetry/aiEditTelemetryService.js'; +import { EditSuggestionId } from '../../../../../editor/common/textModelEditSource.js'; import { IChatAttachmentResolveService } from '../../../../contrib/chat/browser/attachments/chatAttachmentResolveService.js'; import { IChatAttachmentWidgetRegistry } from '../../../../contrib/chat/browser/attachments/chatAttachmentWidgetRegistry.js'; import { IChatContextPickService } from '../../../../contrib/chat/browser/attachments/chatContextPickService.js'; @@ -213,6 +220,15 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I }()); reg.defineInstance(IChatContextService, new class extends mock<IChatContextService>() { }()); reg.defineInstance(IChatContextPickService, new class extends mock<IChatContextPickService>() { }()); + // Needed whenever chat markdown contains a code block; returns no custom renderer so + // code blocks fall back to the normal editor-backed CodeBlockPart. + reg.defineInstance(IChatOutputRendererService, new class extends mock<IChatOutputRendererService>() { + override hasCodeBlockRenderer() { return false; } + }()); + // Chat code blocks generate a suggestion id for edit telemetry when the response completes. + reg.defineInstance(IAiEditTelemetryService, new class extends mock<IAiEditTelemetryService>() { + override createSuggestionId() { return EditSuggestionId.newId(); } + }()); reg.defineInstance(IChatAttachmentWidgetRegistry, new class extends mock<IChatAttachmentWidgetRegistry>() { }()); reg.defineInstance(IChatAttachmentResolveService, new class extends mock<IChatAttachmentResolveService>() { }()); reg.defineInstance(IChatWidgetHistoryService, new class extends mock<IChatWidgetHistoryService>() { override getHistory() { return []; } override readonly onDidChangeHistory = Event.None; }()); @@ -223,7 +239,27 @@ export function registerChatFixtureServices(reg: ServiceRegistration, options: I override getActiveNotification() { return undefined; } }()); reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { override readonly model = new class extends mock<IAgentSessionsService['model']>() { override readonly onDidChangeSessions = Event.None; }(); }()); - reg.defineInstance(IAgentHostService, new class extends mock<IAgentHostService>() { }()); + // Agent-host chat widgets (e.g. the turn changes summary fixtures) create the + // generic config chips lane, which opens a session subscription. Return an + // inert, never-hydrating subscription (value `undefined`) so no config chips + // render and nothing crashes. + reg.defineInstance(IAgentHostService, new class extends mock<IAgentHostService>() { + override getSubscription<T>(_kind: StateComponents, _resource: URI): IReference<IAgentSubscription<T>> { + return { + object: { + value: undefined, + verifiedValue: undefined, + onDidChange: Event.None, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }, + dispose: () => { }, + }; + } + override getSubscriptionUnmanaged<T>(_kind: StateComponents, _resource: URI): IAgentSubscription<T> | undefined { + return undefined; + } + }()); reg.defineInstance(IAgentHostUntitledProvisionalSessionService, new class extends mock<IAgentHostUntitledProvisionalSessionService>() { override readonly onDidChange = Event.None; override get() { return undefined; } diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts index efc4972b0b4939..5d72b3a71ccdd1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatInput.fixture.ts @@ -7,9 +7,12 @@ import { Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; import { ChatEditingSessionState, IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatRequestDisablement } from '../../../../contrib/chat/common/model/chatModel.js'; import { IChatTodo } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; +import { ILanguageModelChatMetadataAndIdentifier } from '../../../../contrib/chat/common/languageModels.js'; +import { ChatAgentLocation } from '../../../../contrib/chat/common/constants.js'; import { defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { renderChatInput } from './renderChatInput.js'; @@ -58,9 +61,28 @@ const sampleTodos: IChatTodo[] = [ { id: 3, title: 'Add unit tests', status: 'not-started' }, ]; +const sampleModels: ILanguageModelChatMetadataAndIdentifier[] = [ + { + identifier: 'openai-gpt-5.3-codex', + metadata: { + extension: new ExtensionIdentifier('fixture.extension'), + id: 'gpt-5.3-codex', + name: 'GPT-5.3-Codex', + vendor: 'openai', + family: 'gpt', + version: '1', + maxInputTokens: 128000, + maxOutputTokens: 4096, + isDefaultForLocation: { [ChatAgentLocation.Chat]: true }, + }, + }, +]; + export default defineThemedFixtureGroup({ path: 'chat/input/' }, { Default: defineComponentFixture({ render: context => renderChatInput(context) }), WithSandboxing: defineComponentFixture({ render: context => renderChatInput(context, { sandboxingEnabled: true }) }), + WithProviderIcon: defineComponentFixture({ render: context => renderChatInput(context, { models: sampleModels }) }), + CompactWithProviderIcon: defineComponentFixture({ render: context => renderChatInput(context, { models: sampleModels, width: 260 }) }), WithArtifacts: defineComponentFixture({ render: context => renderChatInput(context, { artifacts: sampleArtifacts }) }), WithFileChanges: defineComponentFixture({ render: context => renderChatInput(context, { editingSession: createMockEditingSession([{ uri: 'file:///workspace/src/fibon.ts', added: 21, removed: 1 }]) }) diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts index f17a9cabe2adb2..227decea621da7 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatTerminalCollapsible.fixture.ts @@ -31,7 +31,7 @@ function createMockContext(): IChatContentPartRenderContext { }; } -function renderCollapsible(context: ComponentFixtureContext, commandText: string, isSandboxWrapped: boolean, isComplete: boolean, isSkipped: boolean = false, isRunningInBackground: boolean = false): void { +function renderCollapsible(context: ComponentFixtureContext, commandText: string, isSandboxWrapped: boolean, isComplete: boolean, isSkipped: boolean = false, isRunningInBackground: boolean = false, intention: string | undefined = undefined): void { const { container, disposableStore } = context; const instantiationService = createEditorServices(disposableStore, { @@ -40,10 +40,10 @@ function renderCollapsible(context: ComponentFixtureContext, commandText: string container.style.width = '500px'; container.style.padding = '8px'; - container.classList.add('monaco-workbench'); + container.classList.add('monaco-workbench', 'interactive-session'); - const session = dom.$('.interactive-session'); - container.appendChild(session); + const itemContainer = dom.$('.interactive-item-container'); + container.appendChild(itemContainer); const contentElement = dom.$('.chat-terminal-output-placeholder'); contentElement.textContent = '(terminal output would appear here)'; @@ -53,6 +53,7 @@ function renderCollapsible(context: ComponentFixtureContext, commandText: string const wrapper = disposableStore.add(instantiationService.createInstance( ChatTerminalThinkingCollapsibleWrapper, commandText, + intention, isSandboxWrapped, contentElement, createMockContext(), @@ -63,7 +64,7 @@ function renderCollapsible(context: ComponentFixtureContext, commandText: string undefined, )); - session.appendChild(wrapper.domNode); + itemContainer.appendChild(wrapper.domNode); } export default defineThemedFixtureGroup({ path: 'chat/terminalCollapsible/' }, { @@ -94,4 +95,28 @@ export default defineThemedFixtureGroup({ path: 'chat/terminalCollapsible/' }, { 'Ran sandbox - powershell backticks': defineComponentFixture({ render: ctx => renderCollapsible(ctx, 'Get-Process | Where-Object {$_.Name -eq `"notepad`"}', true, true), }), + 'Ran - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', false, true, false, false, 'List files in the repo root'), + }), + 'Height parity - with and without intention': defineComponentFixture({ + render: ctx => { + renderCollapsible(ctx, 'ls -lh', false, true); + renderCollapsible(ctx, 'ls -lh', false, true, false, false, 'List files in the repo root'); + }, + }), + 'Running - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'npm test', false, false, false, false, 'Run the test suite'), + }), + 'Ran sandbox - with intention': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'ls -lh', true, true, false, false, 'List files in the repo root'), + }), + 'Ran - long intention and command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'grep -rn deprecatedHelper ./src --include=*.ts --color=never | head -50', false, true, false, false, 'Search the entire repository for references to the deprecated helper function'), + }), + 'Ran - long intention short command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'pwd', false, true, false, false, 'Print the absolute path of the current working directory so I know where I am'), + }), + 'Ran - short intention long command': defineComponentFixture({ + render: ctx => renderCollapsible(ctx, 'find . -type f -name "*.ts" -not -path "*/node_modules/*" -newer package.json', false, true, false, false, 'Find changed files'), + }), }); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts new file mode 100644 index 00000000000000..90497090d368b1 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatTurnPills.fixture.ts @@ -0,0 +1,234 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../base/test/common/mock.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IEditSessionEntryDiff } from '../../../../contrib/chat/common/editing/chatEditingService.js'; +import { IChatResponseFileChangesService } from '../../../../contrib/chat/browser/chatResponseFileChangesService.js'; +import { ChatTurnPillsContentPart } from '../../../../contrib/chat/browser/widget/chatContentParts/chatTurnPillsPart.js'; +import { IChatContentPartRenderContext } from '../../../../contrib/chat/browser/widget/chatContentParts/chatContentParts.js'; +import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; +import { IChatTurnPillsPart } from '../../../../contrib/chat/common/model/chatViewModel.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { registerChatFixtureServices } from './chatFixtureUtils.js'; +import { renderChatWidget } from './chatWidget.fixture.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +/** + * A per-request file diff. A created file has no before-content, so the agent + * host provider maps its `originalURI` to the `modifiedURI` (equal URIs); an + * edited file keeps a distinct original. + */ +function fileDiff(name: string, added: number, removed: number, created: boolean): IEditSessionEntryDiff { + const modifiedURI = URI.file(`/repo/${name}`); + const originalURI = created ? modifiedURI : URI.file(`/repo/.original/${name}`); + return { originalURI, modifiedURI, added, removed, quitEarly: false, identical: false, isFinal: true, isBusy: false }; +} + +function stubFileChangesService(diffs: readonly IEditSessionEntryDiff[]): IChatResponseFileChangesService { + return new class extends mock<IChatResponseFileChangesService>() { + override getChangesForRequest() { + return constObservable(diffs); + } + }(); +} + +// ============================================================================ +// Render helper (standalone content part) +// ============================================================================ + +interface IRenderTurnPillsOptions { + readonly diffs: readonly IEditSessionEntryDiff[]; + /** Per-pill visibility, mirroring the `chat.turnStatusPills` setting. */ + readonly config?: { readonly changes?: boolean; readonly preview?: boolean }; + /** When `true`, the changed-files disclosure is expanded. */ + readonly expanded?: boolean; +} + +function renderTurnPills(ctx: ComponentFixtureContext, options: IRenderTurnPillsOptions): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Broad chat service graph: IContextMenuService, IEditorService and the + // ResourceLabels dependencies the preview action needs. + registerChatFixtureServices(reg); + reg.defineInstance(IChatResponseFileChangesService, stubFileChangesService(options.diffs)); + }, + }); + + // Both pills are off by default; enable the requested ones so the fixture renders. + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, { + changes: options.config?.changes ?? true, + preview: options.config?.preview ?? true, + }); + + const content: IChatTurnPillsPart = { + kind: 'turnPills', + requestId: 'request-1', + sessionResource: URI.parse('vscode-chat-session://agent-host/session-1'), + }; + const partContext = upcastPartial<IChatContentPartRenderContext>({ container }); + + const part = disposableStore.add(instantiationService.createInstance(ChatTurnPillsContentPart, content, partContext)); + + if (options.expanded) { + part.domNode.querySelector<HTMLDetailsElement>('.checkpoint-file-changes-disclosure')!.open = true; + } + + // The turn changes summary reuses the checkpoint summary styling, which is + // scoped under `.interactive-session` (and relies on `.monaco-workbench` for + // codicon sizing custom properties). + container.classList.add('monaco-workbench', 'interactive-session'); + container.style.padding = '12px'; + container.style.backgroundColor = 'var(--vscode-editor-background)'; + container.appendChild(part.domNode); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +const CHANGES_ONLY = { changes: true, preview: false } as const; +const PREVIEW_ONLY = { changes: false, preview: true } as const; + +export default defineThemedFixtureGroup({ path: 'chat/' }, { + + // --- Standalone content part in each of its states --- + + part: defineThemedFixtureGroup({ + ChangesOnly_SingleFile: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { config: CHANGES_ONLY, diffs: [fileDiff('app.ts', 12, 5, false)] }), + }), + + ChangesOnly_MultipleFiles: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + config: CHANGES_ONLY, + diffs: [ + fileDiff('app.ts', 42, 7, false), + fileDiff('util.ts', 118, 64, false), + fileDiff('index.ts', 5, 0, true), + ], + }), + }), + + ChangesOnly_Expanded: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + config: CHANGES_ONLY, + expanded: true, + diffs: [ + fileDiff('app.ts', 42, 7, false), + fileDiff('util.ts', 118, 64, false), + fileDiff('index.ts', 5, 0, true), + ], + }), + }), + + ChangesAndPreview_Markdown: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + diffs: [ + fileDiff('README.md', 20, 0, true), + fileDiff('app.ts', 8, 3, false), + ], + }), + }), + + // Expanded list showing the per-row "Preview" action on the markdown and + // HTML rows (edited `.ts`/`.css` rows have no preview action). + ChangesAndPreview_Expanded: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + expanded: true, + diffs: [ + fileDiff('README.md', 20, 0, true), + fileDiff('index.html', 30, 4, true), + fileDiff('app.ts', 8, 3, false), + fileDiff('styles.css', 4, 1, false), + ], + }), + }), + + ChangesAndPreview_Html: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + diffs: [ + fileDiff('index.html', 30, 4, true), + fileDiff('styles.css', 8, 3, false), + ], + }), + }), + + // With several previewable files only the first is offered. + ChangesAndPreview_MultiplePreviewable: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + diffs: [ + fileDiff('app.ts', 8, 3, false), + fileDiff('README.md', 20, 0, true), + fileDiff('index.html', 30, 4, true), + fileDiff('CHANGELOG.md', 6, 1, false), + ], + }), + }), + + PreviewOnly: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { + config: PREVIEW_ONLY, + diffs: [ + fileDiff('README.md', 20, 0, true), + fileDiff('app.ts', 8, 3, false), + ], + }), + }), + + NoChanges_Hidden: defineComponentFixture({ + render: (ctx) => renderTurnPills(ctx, { diffs: [] }), + }), + }), + + // --- Turn changes summary inside the entire chat --- + + inChat: defineThemedFixtureGroup({ + Changes: defineComponentFixture({ + render: (ctx) => renderChatWidget(ctx, { + turnStatusPills: { changes: true }, + messages: [ + { + user: 'Refactor the fibonacci helper to be iterative', + assistant: [ + { kind: 'markdown', text: 'I rewrote `fibonacci(n)` to use an iterative loop and updated its callers, avoiding the exponential recursion.' }, + ], + fileChanges: [ + { name: 'fibon.ts', added: 12, removed: 8, created: false }, + { name: 'app.ts', added: 3, removed: 1, created: false }, + ], + }, + ], + }), + }), + + ChangesAndPreview: defineComponentFixture({ + render: (ctx) => renderChatWidget(ctx, { + turnStatusPills: { changes: true, preview: true }, + messages: [ + { + user: 'Add a README describing the project', + assistant: [ + { kind: 'markdown', text: 'I added a `README.md` with an overview, setup steps, and usage notes, and linked it from the docs index.' }, + ], + fileChanges: [ + { name: 'README.md', added: 42, removed: 0, created: true }, + { name: 'docs/index.md', added: 4, removed: 1, created: false }, + ], + }, + ], + }), + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts index 57d9cb8b25441d..0af33927af3c46 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/chatWidget.fixture.ts @@ -6,8 +6,10 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { constObservable } from '../../../../../base/common/observable.js'; import { mock } from '../../../../../base/test/common/mock.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { Range } from '../../../../../editor/common/core/range.js'; @@ -17,6 +19,7 @@ import { ChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; import { ChatViewModel } from '../../../../contrib/chat/common/model/chatViewModel.js'; import { ChatListWidget } from '../../../../contrib/chat/browser/widget/chatListWidget.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../../../contrib/chat/browser/widget/input/chatInputPart.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatWidget, IChatWidgetService } from '../../../../contrib/chat/browser/chat.js'; import { ElicitationState, IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; import { ChatElicitationRequestPart } from '../../../../contrib/chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; @@ -26,12 +29,23 @@ import { IChatToolRiskAssessmentService, IToolRiskAssessment, ToolRiskLevel } fr import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../../contrib/chat/common/constants.js'; +import { SessionType } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IEditSessionEntryDiff } from '../../../../contrib/chat/common/editing/chatEditingService.js'; +import { IChatResponseFileChangesService } from '../../../../contrib/chat/browser/chatResponseFileChangesService.js'; import { MockChatService } from '../../../../contrib/chat/test/common/chatService/mockChatService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; import { FixtureMenuService, registerChatFixtureServices } from './chatFixtureUtils.js'; import '../../../../contrib/chat/browser/widget/media/chat.css'; +export interface IFixtureFileChange { + readonly name: string; + readonly added: number; + readonly removed: number; + /** Whether the file was created (vs. edited) during the turn. */ + readonly created: boolean; +} + export interface IFixtureMessage { readonly user: string; // user prompt text readonly assistant?: ReadonlyArray< @@ -41,6 +55,12 @@ export interface IFixtureMessage { | { kind: 'elicitation'; title: string; message: string; confirmation?: { commandLine: string; cwdLabel?: string; cdPrefix?: string }; riskAssessment?: { risk: ToolRiskLevel; explanation: string }; riskLoading?: boolean } >; readonly responseComplete?: boolean; + /** + * Per-turn file changes surfaced via {@link IChatResponseFileChangesService}, + * used by the turn changes summary. Requires `turnStatusPills` on the fixture + * options to be rendered. + */ + readonly fileChanges?: ReadonlyArray<IFixtureFileChange>; } export interface IChatWidgetFixtureOptions { @@ -53,6 +73,29 @@ export interface IChatWidgetFixtureOptions { * When omitted, behaves like today (auto-detected from message risk data). */ readonly riskAssessmentEnabled?: boolean; + /** + * Optional hook invoked after the chat input part renders, e.g. to mount + * widgets above the input. Receives the rendered input part and the fixture's + * instantiation service so callers can create instances against the same + * service graph. + */ + readonly decorateInputPart?: (inputPart: ChatInputPart, instantiationService: IInstantiationService) => void; + /** + * When set, renders the chat as an agent host session and enables the turn + * changes summary (`chat.turnStatusPills`), so completed turns with + * {@link IFixtureMessage.fileChanges} show the summary/preview under the + * response. + */ + readonly turnStatusPills?: { readonly changes?: boolean; readonly preview?: boolean }; +} + +function makeFileDiff(change: IFixtureFileChange): IEditSessionEntryDiff { + // A created file has no before-content, so the agent host provider maps its + // `originalURI` to the `modifiedURI` (equal URIs); an edited file keeps a + // distinct original. + const modifiedURI = URI.file(`/repo/${change.name}`); + const originalURI = change.created ? modifiedURI : URI.file(`/repo/.original/${change.name}`); + return { originalURI, modifiedURI, added: change.added, removed: change.removed, quitEarly: false, identical: false, isFinal: true, isBusy: false }; } function makeUserMessage(text: string) { @@ -81,6 +124,11 @@ export async function renderChatWidget(context: ComponentFixtureContext, options const riskFeatureExplicitlyDisabled = options.riskAssessmentEnabled === false; const needsRiskService = hasRiskAssessment || hasRiskLoading || riskFeatureExplicitlyDisabled; + // Maps a completed turn's requestId to its per-turn file diffs, consumed by + // the turn changes summary via the stubbed IChatResponseFileChangesService. + const requestDiffs = new Map<string, readonly IEditSessionEntryDiff[]>(); + const needsTurnPills = !!options.turnStatusPills; + const instantiationService = createEditorServices(disposableStore, { colorTheme: context.theme, additionalServices: (reg) => { @@ -100,6 +148,14 @@ export async function renderChatWidget(context: ComponentFixtureContext, options override register() { return { dispose() { } }; } }()); + if (needsTurnPills) { + reg.defineInstance(IChatResponseFileChangesService, new class extends mock<IChatResponseFileChangesService>() { + override getChangesForRequest(_sessionResource: URI, requestId: string) { + return constObservable(requestDiffs.get(requestId) ?? []); + } + }()); + } + if (needsRiskService) { reg.defineInstance(ILanguageModelToolsService, new class extends mock<ILanguageModelToolsService>() { override onDidChangeTools = Event.None; @@ -133,20 +189,35 @@ export async function renderChatWidget(context: ComponentFixtureContext, options }); configService.setUserConfiguration('editor', { fontFamily: 'monospace', fontLigatures: false }); configService.setUserConfiguration(ChatConfiguration.ToolConfirmationCarousel, true); + if (needsTurnPills) { + configService.setUserConfiguration(ChatConfiguration.TurnStatusPills, { + changes: !!options.turnStatusPills?.changes, + preview: !!options.turnStatusPills?.preview, + }); + } // Build a real ChatModel populated with hand-crafted requests/responses, then drive a // real ChatViewModel + ChatListWidget — the same components used in production. + // The turn changes summary only renders for agent host sessions, whose frontend + // resource uses the session type as the scheme (e.g. `agent-host-copilotcli:/…`), + // which is what `getChatSessionType` / `toAgentHostBackendSessionUri` recognize. + const sessionResource = needsTurnPills + ? URI.from({ scheme: SessionType.AgentHostCopilot, path: '/turn-pills-session' }) + : undefined; const chatService = instantiationService.get(IChatService) as MockChatService; const model = disposableStore.add(instantiationService.createInstance( ChatModel, undefined, - { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + { initialLocation: ChatAgentLocation.Chat, canUseTools: true, resource: sessionResource } )); chatService.addSession(model); for (const message of options.messages) { const request = model.addRequest(makeUserMessage(message.user), { variables: [] }, 0); const response = request.response!; + if (message.fileChanges) { + requestDiffs.set(request.id, message.fileChanges.map(makeFileDiff)); + } for (const part of message.assistant ?? []) { if (part.kind === 'markdown') { model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString(part.text) }); @@ -263,6 +334,8 @@ export async function renderChatWidget(context: ComponentFixtureContext, options inputPart.render(session, '', fixtureWidget); inputPart.layout(width); + options.decorateInputPart?.(inputPart, instantiationService); + const listContainer = dom.$('.interactive-list'); listContainer.style.flex = '1 1 auto'; listContainer.style.minHeight = '0'; @@ -373,10 +446,42 @@ const MULTI_TURN: IFixtureMessage[] = [ }, ]; +// Code blocks that follow or are nested in list items should have symmetric spacing +// above and below. Covers the two DOM shapes markdown produces: a code block that is a +// sibling after a list, and a code block nested inside a list item (indented fence). +const CODE_BLOCK_IN_LIST: IFixtureMessage[] = [ + { + user: 'How do I set up the project?', + assistant: [ + { + kind: 'markdown', text: [ + 'Follow these steps:', + '', + '- Clone the repository', + '- Install the dependencies', + '', + '```bash', + 'npm install', + '```', + '', + '- Then start the build watcher:', + '', + ' ```bash', + ' npm run watch', + ' ```', + '', + '- Finally, launch the app', + ].join('\n') + }, + ], + }, +]; + export default defineThemedFixtureGroup({ path: 'chat/widget/' }, { SimpleQA: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: SIMPLE_QA }) }), Streaming: defineComponentFixture({ labels: { kind: 'animated' }, render: ctx => renderChatWidget(ctx, { messages: STREAMING }) }), PendingToolApproval: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: PENDING_TOOL_APPROVAL }) }), + CodeBlockInList: defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: CODE_BLOCK_IN_LIST }) }), bugs: defineThemedFixtureGroup({ 'issue-309796-missing-backslash': defineComponentFixture({ render: ctx => renderChatWidget(ctx, { messages: ISSUE_309796_MISSING_BACKSLASH }) }), }), diff --git a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts index b9522522ec095a..7eccf605e337bb 100644 --- a/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts +++ b/src/vs/workbench/test/browser/componentFixtures/chat/renderChatInput.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { observableValue } from '../../../../../base/common/observable.js'; import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; @@ -17,6 +17,7 @@ import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from '../../.. import { IArtifactSourceGroup } from '../../../../contrib/chat/common/tools/chatArtifactsService.js'; import { IChatEditingSession } from '../../../../contrib/chat/common/editing/chatEditingService.js'; import { IChatTodo } from '../../../../contrib/chat/common/tools/chatTodoListService.js'; +import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../contrib/chat/common/languageModels.js'; import { ChatAgentLocation, ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; import { AgentSandboxEnabledValue, AgentSandboxSettingId } from '../../../../../platform/sandbox/common/settings.js'; import { ComponentFixtureContext, createEditorServices } from '../fixtureUtils.js'; @@ -39,11 +40,15 @@ export interface ChatInputFixtureOptions { readonly value?: string; /** Selects this range after seeding the text, to exercise selection rendering (e.g. reverse-rounded corners). */ readonly selection?: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + /** Sets the fixture width, useful for exercising the compact picker layout. */ + readonly width?: number; + /** Supplies models so the picker renders provider icons. */ + readonly models?: readonly ILanguageModelChatMetadataAndIdentifier[]; } export async function renderChatInput(context: ComponentFixtureContext, fixtureOptions: ChatInputFixtureOptions = {}): Promise<void> { const { container, disposableStore } = context; - const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false } = fixtureOptions; + const { artifacts = [], editingSession, todos = [], isSessionsWindow = false, value, selection, sandboxingEnabled = false, width = 500, models = [] } = fixtureOptions; const artifactGroups: IArtifactSourceGroup[] = artifacts.length > 0 ? [{ source: { kind: 'agent' as const }, artifacts }] : []; const artifactsObs = observableValue<readonly IArtifactSourceGroup[]>('artifactGroups', artifactGroups); @@ -51,6 +56,34 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO colorTheme: context.theme, additionalServices: (reg) => { registerChatFixtureServices(reg, { artifactGroups: artifactsObs, todos }); + if (models.length > 0) { + const modelsById = new Map(models.map(model => [model.identifier, model])); + reg.defineInstance(ILanguageModelsService, new class extends mock<ILanguageModelsService>() { + override onDidChangeLanguageModels = Event.None; + override onDidChangeModelVisibility = Event.None; + override onDidChangePinnedModels = Event.None; + override getLanguageModelIds() { return [...modelsById.keys()]; } + override getHiddenModelIds() { return []; } + override getVendors() { + return [...new Set(models.map(model => model.metadata.vendor))].map(vendor => ({ + vendor, + displayName: vendor, + isDefault: false, + configuration: undefined, + managementCommand: undefined, + when: undefined, + })); + } + override isModelHidden() { return false; } + override getRecentlyUsedModelIds() { return []; } + override getPinnedModelIds() { return []; } + override getModelsControlManifest() { return { free: {}, paid: {} }; } + override lookupLanguageModel(modelId: string) { return modelsById.get(modelId)?.metadata; } + override getModelConfiguration() { return undefined; } + override getLanguageModelGroups() { return []; } + override hasResolvedVendor() { return true; } + }()); + } }, }); @@ -65,7 +98,7 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO await configService.setUserConfiguration(AgentSandboxSettingId.AgentSandboxEnabled, AgentSandboxEnabledValue.On); } - container.style.width = '500px'; + container.style.width = `${width}px`; container.style.backgroundColor = 'var(--vscode-sideBar-background, var(--vscode-editor-background))'; container.classList.add('monaco-workbench'); @@ -110,12 +143,12 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO }(); inputPart.render(session, '', mockWidget); - inputPart.layout(500); + inputPart.layout(width); await new Promise(r => setTimeout(r, 100)); - inputPart.layout(500); + inputPart.layout(width); if (value !== undefined) { inputPart.setValue(value, true); - inputPart.layout(500); + inputPart.layout(width); if (selection) { inputPart.inputEditor.setSelection(selection); } @@ -127,6 +160,6 @@ export async function renderChatInput(context: ComponentFixtureContext, fixtureO if (editingSession) { inputPart.renderChatEditingSessionState(editingSession); await new Promise(r => setTimeout(r, 50)); - inputPart.layout(500); + inputPart.layout(width); } } diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts index 45c4a252e56588..7ebe04b20deb8a 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/agentSessionsViewer.fixture.ts @@ -21,7 +21,7 @@ import { AgentSessionRenderer, AgentSessionSectionRenderer, IAgentSessionRendere import { IChatSessionsService } from '../../../../contrib/chat/common/chatSessionsService.js'; import { AgentSessionStatus, IAgentSession, AgentSessionSection, IAgentSessionSection } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; import { AgentSessionProviders } from '../../../../contrib/chat/browser/agentSessions/agentSessions.js'; -import { AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; import { HoverPosition } from '../../../../../base/browser/ui/hover/hoverWidget.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; @@ -599,6 +599,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-json'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Other, label: '{ "action": "deleteFile", "path": "/src/old-module.ts" }', languageId: 'json', since: new Date(), @@ -623,6 +624,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-bash'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', since: new Date(), @@ -647,6 +649,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-powershell'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'Start-Job -ScriptBlock { Set-Location \'c:\\some\\path\'; npm install } | Out-Null', languageId: 'pwsh', since: new Date(), @@ -671,6 +674,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-long'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'rm -rf node_modules && npm cache clean --force && npm install --legacy-peer-deps --ignore-scripts', languageId: 'sh', since: new Date(), @@ -697,6 +701,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-1line'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'npm install --save express@latest', languageId: 'sh', since: new Date(), @@ -721,6 +726,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-2lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install', languageId: 'sh', since: new Date(), @@ -745,6 +751,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build', languageId: 'sh', since: new Date(), @@ -769,6 +776,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-4lines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', languageId: 'sh', since: new Date(), @@ -793,6 +801,7 @@ export default defineThemedFixtureGroup({ const now = Date.now(); const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); const approvalModel = createMockApprovalModel(resource, { + kind: AgentSessionApprovalKind.Terminal, label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', languageId: 'sh', since: new Date(), diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts index 88e221677d70b9..2b23d8baf0b6b5 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationListWidget.fixture.ts @@ -50,6 +50,7 @@ function createMockPromptsService(instructionFiles: IFixtureInstructionFile[], a override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeSkills = Event.None; override readonly onDidChangeInstructions = Event.None; + override readonly onDidChangeAgentInstructions = Event.None; override readonly onDidChangeHooks = Event.None; override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } override async listPromptFiles(type: PromptsType) { 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 51ff67c0a0c8c6..9c938a0424128f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/aiCustomizationManagementEditor.fixture.ts @@ -65,7 +65,7 @@ import { McpListWidget } from '../../../../contrib/chat/browser/aiCustomization/ import { PluginListWidget } from '../../../../contrib/chat/browser/aiCustomization/pluginListWidget.js'; import { IIterativePager } from '../../../../../base/common/paging.js'; import { IAgentHostCustomizationService } from '../../../../contrib/chat/browser/agentSessions/agentHost/agentHostCustomizationService.js'; -import { McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; +import { McpAuthRequiredReason, McpServerStatus } from '../../../../../platform/agentHost/common/state/protocol/state.js'; // eslint-disable-next-line local/code-import-patterns import { IAgentFeedbackService } from '../../../../../sessions/contrib/agentFeedback/browser/agentFeedbackService.js'; // eslint-disable-next-line local/code-import-patterns @@ -123,6 +123,10 @@ function createMockAICustomizationItemsModel(): IAICustomizationItemsModel { type FixtureAgentHostMcpServer = ReturnType<IAgentHostCustomizationService['getMcpServers']>[number]; +function mcpLifecycleNoop(): Promise<void> { + return Promise.resolve(); +} + function createMockAgentHostCustomizationService(mcpServers: readonly FixtureAgentHostMcpServer[] = []): IAgentHostCustomizationService { return new class extends mock<IAgentHostCustomizationService>() { override readonly onDidChangeCustomAgents = Event.None; @@ -205,6 +209,7 @@ function createMockPromptsService(files: IFixtureFile[], agentInstructions: IAge override readonly onDidChangeSlashCommands = Event.None; override readonly onDidChangeSkills = Event.None; override readonly onDidChangeInstructions = Event.None; + override readonly onDidChangeAgentInstructions = Event.None; override readonly onDidChangeHooks = Event.None; override getDisabledPromptFiles(): ResourceSet { return new ResourceSet(); } override getPromptLocationLabel() { return ''; } @@ -459,9 +464,9 @@ const mcpRuntimeServers = [ ]; const activeSessionMcpServers: FixtureAgentHostMcpServer[] = [ - { id: 'mcp-top-level:fixture:session:component-explorer', name: 'component-explorer', enabled: true, status: McpServerStatus.Ready, setEnabled() { } }, - { id: 'mcp-top-level:fixture:session:Remote Browser', name: 'Remote Browser', enabled: true, status: McpServerStatus.AuthRequired, setEnabled() { } }, - { id: 'mcp-top-level:fixture:session:Remote Search', name: 'Remote Search', enabled: true, status: McpServerStatus.Error, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:component-explorer', name: 'component-explorer', enabled: true, status: McpServerStatus.Ready, state: { kind: McpServerStatus.Ready }, start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:Remote Browser', name: 'Remote Browser', enabled: true, status: McpServerStatus.AuthRequired, state: { kind: McpServerStatus.AuthRequired, reason: McpAuthRequiredReason.Required, resource: { resource: 'https://mcp.example.com' } }, start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, + { id: 'mcp-top-level:fixture:session:Remote Search', name: 'Remote Search', enabled: true, status: McpServerStatus.Error, state: { kind: McpServerStatus.Error, error: { errorType: 'fixture', message: 'Fixture error' } }, start: mcpLifecycleNoop, stop: mcpLifecycleNoop, setEnabled() { } }, ]; interface IRenderEditorOptions { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts new file mode 100644 index 00000000000000..5a15d0a4549cc3 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/blockedSessionsList.fixture.ts @@ -0,0 +1,346 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon, themeColorFromId } from '../../../../../base/common/themables.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +import { IMarkdownString, MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { EditorMarkdownCodeBlockRenderer } from '../../../../../editor/browser/widget/markdownRenderer/browser/editorMarkdownCodeBlockRenderer.js'; +// eslint-disable-next-line local/code-import-patterns +import { IChat, IGitHubInfo, ISession, ISessionChangesSummary, ISessionFileChange, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsListModelService } from '../../../../../sessions/services/sessions/browser/sessionsListModelService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionsList } from '../../../../../sessions/contrib/sessions/browser/blockedSessionsList.js'; +import { IChatService } from '../../../../contrib/chat/common/chatService/chatService.js'; +import { IChatModel } from '../../../../contrib/chat/common/model/chatModel.js'; +import { IAgentSessionsService } from '../../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; +import { IAgentSession, IAgentSessionsModel } from '../../../../contrib/chat/browser/agentSessions/agentSessionsModel.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// The blocked-sessions list reuses the shared session-row styles. +// eslint-disable-next-line local/code-import-patterns +import '../../../../../sessions/contrib/sessions/browser/media/sessionsList.css'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockWorkspace(label: string, branchName: string, pullRequest?: IGitHubInfo['pullRequest']): ISessionWorkspace { + const root = URI.file(`/home/user/projects/${label}`); + const gitHubInfo: IGitHubInfo | undefined = pullRequest ? { owner: 'microsoft', repo: label, pullRequest } : undefined; + + const gitRepository: ISessionGitRepository = { + uri: root, + workTreeUri: undefined, + branchName, + baseBranchName: 'main', + hasGitHubRemote: true, + gitHubInfo: constObservable(gitHubInfo), + }; + + const folder: ISessionFolder = { + root, + workingDirectory: root, + name: label, + description: undefined, + gitRepository, + }; + + return { + uri: root, + label, + icon: Codicon.folder, + folders: [folder], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createMockChangesSummary(files: number, additions: number, deletions: number): ISessionChangesSummary { + return { files, additions, deletions }; +} + +interface IBlockedSessionOptions { + title: string; + status: SessionStatus; + /** How long ago the session was last updated, in minutes. */ + minutesAgo: number; + workspace?: ISessionWorkspace; + /** Rendered in the details row for sessions that need input. */ + description?: string; + /** Diff stats shown for completed sessions. */ + changesSummary?: ISessionChangesSummary; + /** A terminal command awaiting approval; renders an approval row with an Allow button. */ + approvalCommand?: string; +} + +function createBlockedSession(options: IBlockedSessionOptions, approvals?: Map<string, IAgentSessionApprovalInfo>): ISession { + const updatedAt = new Date(Date.now() - options.minutesAgo * 60 * 1000); + const description: IMarkdownString | undefined = options.description ? new MarkdownString(options.description) : undefined; + + // A session awaiting a tool approval carries a chat whose resource the (mock) + // approval model keys the pending approval on. + let chats: readonly IChat[] = []; + if (options.approvalCommand !== undefined && approvals) { + const chatResource = URI.parse(`vscode-chat://chat/${Math.random().toString(36).slice(2)}`); + approvals.set(chatResource.toString(), { + kind: AgentSessionApprovalKind.Terminal, + label: options.approvalCommand, + languageId: undefined, + since: new Date(), + confirm: () => { }, + }); + chats = [new class extends mock<IChat>() { + override readonly resource = chatResource; + }()]; + } + + return new class extends mock<ISession>() { + override readonly sessionId = `local:${options.title}`; + override readonly resource = URI.parse(`vscode-session://session/${Math.random().toString(36).slice(2)}`); + override readonly providerId = 'local'; + override readonly sessionType = 'local'; + override readonly icon = Codicon.account; + override readonly createdAt = updatedAt; + override readonly title: IObservable<string> = constObservable(options.title); + override readonly updatedAt: IObservable<Date> = constObservable(updatedAt); + override readonly status: IObservable<SessionStatus> = constObservable(options.status); + override readonly workspace: IObservable<ISessionWorkspace | undefined> = constObservable(options.workspace); + override readonly isArchived: IObservable<boolean> = constObservable<boolean>(false); + override readonly isRead: IObservable<boolean> = constObservable<boolean>(true); + override readonly changes: IObservable<readonly ISessionFileChange[]> = constObservable<readonly ISessionFileChange[]>([]); + override readonly changesSummary: IObservable<ISessionChangesSummary | undefined> = constObservable<ISessionChangesSummary | undefined>(options.changesSummary); + override readonly description: IObservable<IMarkdownString | undefined> = constObservable<IMarkdownString | undefined>(description); + override readonly chats: IObservable<readonly IChat[]> = constObservable<readonly IChat[]>(chats); + }(); +} + +function createApprovalModel(approvals: Map<string, IAgentSessionApprovalInfo>): AgentSessionApprovalModel { + return new class extends mock<AgentSessionApprovalModel>() { + override getApproval(resource: URI): IObservable<IAgentSessionApprovalInfo | undefined> { + return constObservable(approvals.get(resource.toString())); + } + }(); +} + +/** + * Build a set of sessions together with a matching approval model: each session + * whose spec has an `approvalCommand` shows a pending terminal approval row. + */ +function buildApprovalScenario(specs: readonly IBlockedSessionOptions[]): { sessions: ISession[]; approvalModel: AgentSessionApprovalModel } { + const approvals = new Map<string, IAgentSessionApprovalInfo>(); + const sessions = specs.map(spec => createBlockedSession(spec, approvals)); + return { sessions, approvalModel: createApprovalModel(approvals) }; +} + +function createMockListModelService(): ISessionsListModelService { + return new class extends mock<ISessionsListModelService>() { + override readonly onDidChange = Event.None; + override isSessionRead(): boolean { return true; } + override isSessionPinned(): boolean { return false; } + override markRead(): void { } + override getStatusIcon(status: SessionStatus, _isRead: boolean, isArchived: boolean, completedStateIcon?: ThemeIcon): ThemeIcon { + switch (status) { + case SessionStatus.InProgress: + return { ...Codicon.sessionInProgress, color: themeColorFromId('textLink.foreground') }; + case SessionStatus.NeedsInput: + return { ...Codicon.circleFilled, color: themeColorFromId('list.warningForeground') }; + case SessionStatus.Error: + return { ...Codicon.error, color: themeColorFromId('errorForeground') }; + default: + if (isArchived) { + return { ...Codicon.passFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + if (completedStateIcon) { + return completedStateIcon; + } + return { ...Codicon.circleSmallFilled, color: themeColorFromId('agentSessionReadIndicator.foreground') }; + } + } + }(); +} + +// A failing-CI pull request (red) and a pull request with unresolved comments +// (yellow) — the two non-needs-input reasons a session counts as blocked. +const failingChecksPr: IGitHubInfo['pullRequest'] = { + number: 4821, + uri: URI.parse('https://github.com/microsoft/vscode/pull/4821'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.red') }, +}; + +const unresolvedCommentsPr: IGitHubInfo['pullRequest'] = { + number: 4750, + uri: URI.parse('https://github.com/microsoft/vscode/pull/4750'), + icon: { ...Codicon.gitPullRequest, color: themeColorFromId('charts.yellow') }, +}; + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderBlockedList(ctx: ComponentFixtureContext, sessions: readonly ISession[], approvalModel?: AgentSessionApprovalModel): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.define(IListService, ListService); + reg.define(IMarkdownRendererService, MarkdownRendererService); + // `SessionsFlatList` creates an `AgentSessionApprovalModel` (reads + // `IChatService.chatModels`) and observes each session through the + // agent-sessions model. Both are stubbed to no-ops for the fixture. + reg.defineInstance(IChatService, new class extends mock<IChatService>() { + override readonly chatModels: IObservable<Iterable<IChatModel>> = constObservable<Iterable<IChatModel>>([]); + }()); + reg.defineInstance(IAgentSessionsService, new class extends mock<IAgentSessionsService>() { + override readonly model = new class extends mock<IAgentSessionsModel>() { + override observeSession(): IObservable<IAgentSession | undefined> { + return constObservable<IAgentSession | undefined>(undefined); + } + }(); + }()); + reg.defineInstance(ISessionsService, new class extends mock<ISessionsService>() { + override readonly visibleSessions: IObservable<readonly (IActiveSession | undefined)[]> = constObservable<readonly (IActiveSession | undefined)[]>([]); + }()); + reg.defineInstance(ISessionsListModelService, createMockListModelService()); + reg.defineInstance(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() { + override readonly onDidChangeProviders = Event.None; + override getProviders() { return []; } + override getProvider() { return undefined; } + }()); + }, + }); + + // Render terminal-approval labels as real (monospace) code blocks — otherwise + // the markdown renderer emits empty code-block spans and the command is blank. + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration('editor', { fontFamily: 'monospace' }); + instantiationService.get(IMarkdownRendererService).setDefaultCodeBlockRenderer(instantiationService.createInstance(EditorMarkdownCodeBlockRenderer)); + + // The blocked-sessions list is shown as a floating dropdown anchored below + // the command center box in the agents window; approximate that surface (and + // its backdrop) here so the widget's own background/border/shadow read as + // they do in production. + container.style.width = '392px'; + container.style.padding = '16px'; + container.style.backgroundColor = 'var(--vscode-titleBar-activeBackground, var(--vscode-editor-background))'; + + const list = disposableStore.add(instantiationService.createInstance(BlockedSessionsList, container, { + onSessionOpen: () => { }, + approvalModel, + })); + list.setSessions(sessions); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + // A mix of the three reasons a session is "blocked": it needs input, its PR + // has failing CI checks, or its PR has unresolved comments. + BlockedSessionsList_Mixed: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ + title: 'Fix authentication redirect loop', + status: SessionStatus.NeedsInput, + minutesAgo: 3, + workspace: createMockWorkspace('vscode', 'feature/auth-fix'), + description: 'Waiting for you to confirm running the database migration.', + }), + createBlockedSession({ + title: 'Add telemetry for startup performance', + status: SessionStatus.Completed, + minutesAgo: 62, + workspace: createMockWorkspace('vscode', 'perf/startup-telemetry', failingChecksPr), + changesSummary: createMockChangesSummary(8, 240, 58), + }), + createBlockedSession({ + title: 'Refactor the notification service', + status: SessionStatus.Completed, + minutesAgo: 184, + workspace: createMockWorkspace('vscode', 'refactor/notifications', unresolvedCommentsPr), + changesSummary: createMockChangesSummary(12, 96, 140), + }), + ]), + }), + + // A single session that needs input — the most common blocked state. + BlockedSessionsList_SingleNeedsInput: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ + title: 'Update the onboarding walkthrough copy', + status: SessionStatus.NeedsInput, + minutesAgo: 1, + workspace: createMockWorkspace('vscode', 'docs/onboarding'), + description: 'Which tone should the welcome step use — formal or friendly?', + }), + ]), + }), + + // Enough sessions to fill the dropdown and show the bounded, scrollable height. + BlockedSessionsList_Many: defineComponentFixture({ + render: (ctx) => renderBlockedList(ctx, [ + createBlockedSession({ title: 'Fix authentication redirect loop', status: SessionStatus.NeedsInput, minutesAgo: 3, workspace: createMockWorkspace('vscode', 'feature/auth-fix'), description: 'Waiting for you to confirm running the database migration.' }), + createBlockedSession({ title: 'Add telemetry for startup performance', status: SessionStatus.Completed, minutesAgo: 62, workspace: createMockWorkspace('vscode', 'perf/startup-telemetry', failingChecksPr), changesSummary: createMockChangesSummary(8, 240, 58) }), + createBlockedSession({ title: 'Refactor the notification service', status: SessionStatus.Completed, minutesAgo: 184, workspace: createMockWorkspace('vscode', 'refactor/notifications', unresolvedCommentsPr), changesSummary: createMockChangesSummary(12, 96, 140) }), + createBlockedSession({ title: 'Migrate settings sync to the new store', status: SessionStatus.NeedsInput, minutesAgo: 240, workspace: createMockWorkspace('vscode', 'feature/settings-store'), description: 'Should I keep the legacy keys for one more release?' }), + createBlockedSession({ title: 'Investigate flaky terminal integration test', status: SessionStatus.Completed, minutesAgo: 320, workspace: createMockWorkspace('vscode', 'fix/flaky-terminal-test', failingChecksPr), changesSummary: createMockChangesSummary(3, 41, 12) }), + createBlockedSession({ title: 'Polish the command center hover states', status: SessionStatus.Completed, minutesAgo: 600, workspace: createMockWorkspace('vscode', 'polish/command-center', unresolvedCommentsPr), changesSummary: createMockChangesSummary(5, 64, 9) }), + ]), + }), + + // One session with a pending terminal approval — shows the approval row + Allow button. + BlockedSessionsList_OneApproval: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Build the production bundle', status: SessionStatus.NeedsInput, minutesAgo: 1, workspace: createMockWorkspace('vscode', 'release/prod-build'), approvalCommand: 'npm run build:prod' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), + + // Two sessions awaiting approval — a short command and a long single-line command. + BlockedSessionsList_TwoApprovals: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Push the auth fix', status: SessionStatus.NeedsInput, minutesAgo: 2, workspace: createMockWorkspace('vscode', 'feature/auth-fix'), approvalCommand: 'git push --force-with-lease origin feature/auth-fix' }, + { title: 'Publish the release image', status: SessionStatus.NeedsInput, minutesAgo: 6, workspace: createMockWorkspace('vscode', 'release/docker'), approvalCommand: 'docker run --rm -it -v "$(pwd)":/workspace -w /workspace -e NODE_ENV=production -e REGISTRY=ghcr.io/microsoft --network host node:20-alpine npm run build:image -- --push --tag latest --no-cache' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), + + // Five sessions awaiting approval, spanning short, long single-line and + // multi-line terminal commands (the approval row shows up to three lines). + BlockedSessionsList_FiveApprovals: defineComponentFixture({ + render: (ctx) => { + const { sessions, approvalModel } = buildApprovalScenario([ + { title: 'Install dependencies', status: SessionStatus.NeedsInput, minutesAgo: 1, workspace: createMockWorkspace('vscode', 'chore/deps'), approvalCommand: 'npm ci' }, + { title: 'Rebase onto main', status: SessionStatus.NeedsInput, minutesAgo: 3, workspace: createMockWorkspace('vscode', 'feature/rebase'), approvalCommand: 'git rebase --onto main feature/old-base feature/new-work' }, + { title: 'Provision the review environment', status: SessionStatus.NeedsInput, minutesAgo: 7, workspace: createMockWorkspace('vscode', 'infra/review-env'), approvalCommand: 'kubectl apply -f ./deploy/review.yaml --namespace review-pr-4821 && kubectl rollout status deployment/web --namespace review-pr-4821 --timeout=180s && kubectl get pods --namespace review-pr-4821 -o wide' }, + { title: 'Format changed files', status: SessionStatus.NeedsInput, minutesAgo: 12, workspace: createMockWorkspace('vscode', 'chore/format'), approvalCommand: 'for f in $(git diff --name-only main); do\n npx prettier --write "$f"\n git add "$f"\ndone' }, + { title: 'Reset and reinstall', status: SessionStatus.NeedsInput, minutesAgo: 20, workspace: createMockWorkspace('vscode', 'fix/clean-install'), approvalCommand: 'rm -rf node_modules\nrm -f package-lock.json\nnpm cache clean --force\nnpm install\nnpm run test:integration' }, + ]); + renderBlockedList(ctx, sessions, approvalModel); + }, + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts new file mode 100644 index 00000000000000..b3864bda51f920 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts @@ -0,0 +1,529 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { constObservable, IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { localize2 } from '../../../../../nls.js'; +import { IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { IFileContent, IFileService } from '../../../../../platform/files/common/files.js'; +import { SyncDescriptor } from '../../../../../platform/instantiation/common/descriptors.js'; +import { IListService, ListService } from '../../../../../platform/list/browser/listService.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IViewPaneOptions } from '../../../../browser/parts/views/viewPane.js'; +import { IViewContainerModel, IViewDescriptor, IViewDescriptorService, IViewPaneContainer, ViewContainer, ViewContainerLocation } from '../../../../common/views.js'; +import { isIChatSessionFileChange2 } from '../../../../contrib/chat/common/chatSessionsService.js'; +import { IDecorationsService } from '../../../../services/decorations/common/decorations.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { ILifecycleService, LifecyclePhase, StartupKind } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { INotebookDocumentService } from '../../../../services/notebook/common/notebookDocumentService.js'; +import { ITextFileService } from '../../../../services/textfile/common/textfiles.js'; +import { FixtureMenuService } from '../chat/chatFixtureUtils.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// eslint-disable-next-line local/code-import-patterns +import { ActiveSessionState, IChangesViewService } from '../../../../../sessions/contrib/changes/common/changesViewService.js'; +// eslint-disable-next-line local/code-import-patterns +import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewMode, IsolationMode } from '../../../../../sessions/contrib/changes/common/changes.js'; +// eslint-disable-next-line local/code-import-patterns +import { ChangesViewPane } from '../../../../../sessions/contrib/changes/browser/changesView.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionChangesService, SessionChangesService } from '../../../../../sessions/contrib/changes/browser/sessionChangesService.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubPullRequestCIModel } from '../../../../../sessions/contrib/github/browser/models/githubPullRequestCIModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { IGitHubService } from '../../../../../sessions/contrib/github/browser/githubService.js'; +// eslint-disable-next-line local/code-import-patterns +import { GitHubCheckConclusion, GitHubCheckStatus, GitHubCIOverallStatus, IGitHubCICheck } from '../../../../../sessions/contrib/github/common/types.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { BRANCH_CHANGES_CHANGESET_ID, IChat, IGitHubInfo, ISessionCapabilities, ISessionChangeset, ISessionChangesetOperation, ISessionFile, ISessionFileChange, ISessionGitRepository, ISessionWorkspace, SessionFileOperation, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; + +interface IChangesViewFixtureOptions { + readonly viewMode: ChangesViewMode; + readonly changes: readonly ISessionFileChange[]; + readonly otherFiles?: readonly ISessionFile[]; + readonly checks?: readonly IGitHubCICheck[]; + readonly reviewCommentCounts?: ReadonlyMap<string, number>; + readonly agentFeedbackCounts?: ReadonlyMap<string, number>; + readonly height?: number; +} + +const WORKSPACE_URI = URI.file('/workspace/vscode'); +const VIEW_WIDTH = 380; +const VIEW_HEIGHT = 520; + +class FixtureChangesViewService extends Disposable implements IChangesViewService { + declare readonly _serviceBrand: undefined; + + readonly activeSessionResourceObs: IObservable<URI | undefined>; + readonly activeSessionTypeObs: IObservable<string | undefined>; + readonly activeSessionIsVirtualWorkspaceObs: IObservable<boolean>; + readonly activeSessionChangesObs: IObservable<readonly ISessionFileChange[]>; + readonly activeSessionChangesetsObs: IObservable<readonly ISessionChangeset[] | undefined>; + readonly activeSessionChangesetsLoadingObs: IObservable<boolean>; + readonly activeSessionChangesetObs: IObservable<ISessionChangeset | undefined>; + readonly activeSessionChangesetLoadingObs: IObservable<boolean>; + readonly activeSessionChangesetOperationsObs: IObservable<readonly ISessionChangesetOperation[]>; + readonly activeSessionHasGitRepositoryObs: IObservable<boolean>; + readonly activeSessionReviewCommentCountByFileObs: IObservable<Map<string, number>>; + readonly activeSessionAgentFeedbackCountByFileObs: IObservable<Map<string, number>>; + readonly activeSessionStateObs: IObservable<ActiveSessionState | undefined>; + readonly activeSessionLoadingObs: IObservable<boolean>; + readonly viewModeObs = observableValue<ChangesViewMode>(this, ChangesViewMode.List); + + constructor(session: IActiveSession, options: IChangesViewFixtureOptions) { + super(); + + const changeset = createChangeset(options.changes); + this.viewModeObs.set(options.viewMode, undefined); + this.activeSessionResourceObs = constObservable(session.resource); + this.activeSessionTypeObs = constObservable(session.sessionType); + this.activeSessionIsVirtualWorkspaceObs = constObservable(false); + this.activeSessionChangesObs = constObservable(options.changes); + this.activeSessionChangesetsObs = constObservable([changeset]); + this.activeSessionChangesetsLoadingObs = constObservable(false); + this.activeSessionChangesetObs = constObservable(changeset); + this.activeSessionChangesetLoadingObs = constObservable(false); + this.activeSessionChangesetOperationsObs = constObservable<readonly ISessionChangesetOperation[]>([]); + this.activeSessionHasGitRepositoryObs = constObservable(true); + this.activeSessionReviewCommentCountByFileObs = constObservable(new Map(options.reviewCommentCounts)); + this.activeSessionAgentFeedbackCountByFileObs = constObservable(new Map(options.agentFeedbackCounts)); + this.activeSessionStateObs = constObservable({ + isolationMode: IsolationMode.Worktree, + hasGitRepository: true, + branchName: 'feature/changes-view-fixtures', + baseBranchName: 'main', + upstreamBranchName: 'origin/feature/changes-view-fixtures', + isMergeBaseBranchProtected: true, + incomingChanges: 0, + outgoingChanges: 2, + uncommittedChanges: 0, + hasBranchChanges: options.changes.length > 0, + hasGitHubRemote: true, + hasPullRequest: (options.checks?.length ?? 0) > 0, + hasOpenPullRequest: (options.checks?.length ?? 0) > 0, + hasGitOperationInProgress: false, + }); + this.activeSessionLoadingObs = constObservable(false); + } + + setChangesetId(_changesetId: string | undefined): void { } + + setViewMode(mode: ChangesViewMode): void { + this.viewModeObs.set(mode, undefined); + } +} + +class FixtureViewPaneContainer extends mock<IViewPaneContainer>() { } + +const changesViewContainer: ViewContainer = { + id: CHANGES_VIEW_CONTAINER_ID, + title: localize2('fixtureChangesContainer', 'Changes'), + ctorDescriptor: new SyncDescriptor(FixtureViewPaneContainer), +}; + +const changesViewDescriptor: IViewDescriptor = { + id: CHANGES_VIEW_ID, + name: localize2('fixtureChangesView', 'Changes'), + ctorDescriptor: new SyncDescriptor(ChangesViewPane), + containerIcon: Codicon.gitCompare, +}; + +class FixtureViewContainerModel extends mock<IViewContainerModel>() { + override readonly viewContainer = changesViewContainer; + override readonly title = 'Changes'; + override readonly icon: ThemeIcon | URI | undefined = Codicon.gitCompare; + override readonly keybindingId = undefined; + override readonly onDidChangeContainerInfo = Event.None; + override readonly allViewDescriptors = [changesViewDescriptor]; + override readonly onDidChangeAllViewDescriptors = Event.None; + override readonly activeViewDescriptors = [changesViewDescriptor]; + override readonly onDidChangeActiveViewDescriptors = Event.None; + override readonly visibleViewDescriptors = [changesViewDescriptor]; + override readonly onDidAddVisibleViewDescriptors = Event.None; + override readonly onDidRemoveVisibleViewDescriptors = Event.None; + override readonly onDidMoveVisibleViewDescriptors = Event.None; + + override isVisible(): boolean { return true; } + override setVisible(): void { } + override isCollapsed(): boolean { return false; } + override setCollapsed(): void { } + override getSize(): number | undefined { return undefined; } + override setSizes(): void { } + override move(): void { } +} + +class FixtureViewDescriptorService extends mock<IViewDescriptorService>() { + override readonly viewContainers = [changesViewContainer]; + override readonly onDidChangeViewContainers = Event.None; + override readonly onDidChangeContainerLocation = Event.None; + override readonly onDidChangeContainer = Event.None; + override readonly onDidChangeLocation = Event.None; + + private readonly _model = new FixtureViewContainerModel(); + + override getDefaultViewContainer(): ViewContainer | undefined { return changesViewContainer; } + override getViewContainerById(): ViewContainer | null { return changesViewContainer; } + override isViewContainerRemovedPermanently(): boolean { return false; } + override getDefaultViewContainerLocation(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override getViewContainerLocation(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override getViewContainersByLocation(): ViewContainer[] { return [changesViewContainer]; } + override getViewContainerModel(): IViewContainerModel { return this._model; } + override moveViewContainerToLocation(): void { } + override getViewContainerBadgeEnablementState(): boolean { return true; } + override setViewContainerBadgeEnablementState(): void { } + override getViewDescriptorById(): IViewDescriptor | null { return changesViewDescriptor; } + override getViewContainerByViewId(): ViewContainer | null { return changesViewContainer; } + override getDefaultContainerById(): ViewContainer | null { return changesViewContainer; } + override getViewLocationById(): ViewContainerLocation | null { return ViewContainerLocation.AuxiliaryBar; } + override canMoveViews(): boolean { return false; } + override moveViewsToContainer(): void { } + override moveViewToLocation(): void { } + override reset(): void { } +} + +function createChangeset(changes: readonly ISessionFileChange[]): ISessionChangeset { + return new class extends mock<ISessionChangeset>() { + override readonly id = BRANCH_CHANGES_CHANGESET_ID; + override readonly label = 'Branch Changes'; + override readonly isEnabled = constObservable(true); + override readonly isDefault = constObservable(true); + override readonly isLoadingChanges = constObservable(false); + override readonly changes = constObservable(changes); + override readonly operations = constObservable([]); + override readonly originalCheckpointRef = constObservable(undefined); + override readonly modifiedCheckpointRef = constObservable(undefined); + override async invokeOperation(): Promise<void> { } + }(); +} + +function createWorkspace(): ISessionWorkspace { + const gitRepository: ISessionGitRepository = { + uri: WORKSPACE_URI, + workTreeUri: URI.file('/workspace/.worktrees/changes-view-fixtures'), + branchName: 'feature/changes-view-fixtures', + baseBranchName: 'main', + baseBranchProtected: true, + hasGitHubRemote: true, + upstreamBranchName: 'origin/feature/changes-view-fixtures', + outgoingChanges: 2, + uncommittedChanges: 0, + gitHubInfo: constObservable<IGitHubInfo | undefined>({ + owner: 'microsoft', + repo: 'vscode', + pullRequest: { + number: 293163, + uri: URI.parse('https://github.com/microsoft/vscode/pull/293163'), + icon: Codicon.gitPullRequest, + }, + }), + }; + + return { + uri: WORKSPACE_URI, + label: 'vscode', + icon: Codicon.folder, + folders: [{ + root: WORKSPACE_URI, + workingDirectory: WORKSPACE_URI, + name: 'vscode', + description: undefined, + gitRepository, + }], + requiresWorkspaceTrust: false, + isVirtualWorkspace: false, + }; +} + +function createSession(options: IChangesViewFixtureOptions): IActiveSession { + const capabilities: ISessionCapabilities = { + supportsMultipleChats: false, + supportsRename: true, + }; + const changesets = [createChangeset(options.changes)]; + const chat = new class extends mock<IChat>() { }(); + + return new class extends mock<IActiveSession>() { + override readonly sessionId = 'fixture:changes-view'; + override readonly resource = URI.parse('fixture-session://changes-view'); + override readonly providerId = 'fixture'; + override readonly sessionType = 'fixture'; + override readonly icon = Codicon.account; + override readonly createdAt = new Date('2026-05-14T12:00:00Z'); + override readonly workspace = constObservable(createWorkspace()); + override readonly title = constObservable('Changes view fixture'); + override readonly updatedAt = constObservable(new Date('2026-05-14T12:30:00Z')); + override readonly status = constObservable(SessionStatus.Completed); + override readonly changes = constObservable(options.changes); + override readonly changesets = constObservable(changesets); + override readonly externalChanges = constObservable(options.otherFiles ?? []); + override readonly modelId = constObservable(undefined); + override readonly mode = constObservable(undefined); + override readonly loading = constObservable(false); + override readonly isArchived = constObservable(false); + override readonly isRead = constObservable(true); + override readonly description = constObservable(undefined); + override readonly lastTurnEnd = constObservable(undefined); + override readonly chats = constObservable([chat]); + override readonly mainChat = constObservable(chat); + override readonly capabilities = constObservable(capabilities); + override readonly activeChat = constObservable(chat); + override readonly isCreated = constObservable(true); + override readonly sticky = constObservable(false); + override readonly openChats = constObservable([chat]); + override readonly closedChats = constObservable([]); + override readonly lastClosedChat = undefined; + override readonly visibleChatTabs = constObservable([chat]); + override readonly shouldShowChatTabs = constObservable(false); + }(); +} + +function createFileChange(path: string, kind: 'added' | 'modified' | 'deleted', insertions: number, deletions: number): ISessionFileChange { + const uri = URI.file(`/workspace/vscode/${path}`); + return { + uri, + originalUri: kind === 'added' ? undefined : URI.file(`/workspace/vscode/.baseline/${path}`), + modifiedUri: kind === 'deleted' ? undefined : uri, + insertions, + deletions, + }; +} + +function createOtherFile(path: string, operation: SessionFileOperation): ISessionFile { + return { + uri: URI.file(path), + operation, + originalUri: operation === SessionFileOperation.Modified ? URI.file(`${path}.before`) : undefined, + }; +} + +function createCheck(id: number, name: string, status: GitHubCheckStatus, conclusion?: GitHubCheckConclusion): IGitHubCICheck { + return { + id, + name, + status, + conclusion, + startedAt: '2026-05-14T12:00:00Z', + completedAt: status === GitHubCheckStatus.Completed ? '2026-05-14T12:05:00Z' : undefined, + detailsUrl: `https://github.com/microsoft/vscode/actions/runs/${id}`, + }; +} + +function createCIModel(checks: readonly IGitHubCICheck[] | undefined): GitHubPullRequestCIModel | undefined { + if (!checks?.length) { + return undefined; + } + const visibleChecks: readonly IGitHubCICheck[] = checks; + + return new class extends mock<GitHubPullRequestCIModel>() { + override readonly owner = 'microsoft'; + override readonly repo = 'vscode'; + override readonly prNumber = 293163; + override readonly headSha = 'abcdef1234567890'; + override readonly checks = constObservable(visibleChecks); + override readonly overallStatus = constObservable(GitHubCIOverallStatus.Failure); + override readonly fixRequested = constObservable(false); + override markFixRequested(): void { } + override async refresh(): Promise<void> { } + override async rerunFailedCheck(): Promise<void> { } + override async getCheckRunAnnotations(): Promise<string> { return ''; } + override startPolling() { return { dispose() { } }; } + }(); +} + +function createGitHubService(checks: readonly IGitHubCICheck[] | undefined): IGitHubService { + return new class extends mock<IGitHubService>() { + override readonly activeSessionPullRequestObs = constObservable(undefined); + override readonly activeSessionPullRequestCIObs = constObservable(createCIModel(checks)); + override readonly activeSessionPullRequestReviewThreadsObs = constObservable(undefined); + override createRepositoryModelReference(): ReturnType<IGitHubService['createRepositoryModelReference']> { throw new Error('Not implemented in fixture.'); } + override createPullRequestModelReference(): ReturnType<IGitHubService['createPullRequestModelReference']> { throw new Error('Not implemented in fixture.'); } + override createPullRequestReviewThreadsModelReference(): ReturnType<IGitHubService['createPullRequestReviewThreadsModelReference']> { throw new Error('Not implemented in fixture.'); } + override createPullRequestCIModelReference(): ReturnType<IGitHubService['createPullRequestCIModelReference']> { throw new Error('Not implemented in fixture.'); } + override async getChangedFiles() { return []; } + override async findPullRequestNumberByHeadBranch() { return undefined; } + }(); +} + +function getChangeUri(change: ISessionFileChange): URI { + return isIChatSessionFileChange2(change) ? change.uri : change.modifiedUri; +} + +function renderChangesView(ctx: ComponentFixtureContext, options: IChangesViewFixtureOptions): void { + const { container, disposableStore, theme } = ctx; + const height = options.height ?? VIEW_HEIGHT; + const session = createSession(options); + const changesViewService = disposableStore.add(new FixtureChangesViewService(session, options)); + + container.style.width = `${VIEW_WIDTH}px`; + container.style.height = `${height}px`; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const host = dom.append(container, dom.$('.part.auxiliarybar')); + host.style.width = '100%'; + host.style.height = '100%'; + + const paneView = dom.append(host, dom.$('.monaco-pane-view')); + paneView.style.width = '100%'; + paneView.style.height = '100%'; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: theme, + additionalServices: reg => { + registerWorkbenchServices(reg); + reg.define(IMenuService, FixtureMenuService); + reg.define(IListService, ListService); + reg.define(ISessionChangesService, SessionChangesService); + reg.defineInstance(IChangesViewService, changesViewService); + reg.defineInstance(IGitHubService, createGitHubService(options.checks)); + reg.defineInstance(IViewDescriptorService, new FixtureViewDescriptorService()); + reg.defineInstance(ISessionsService, new class extends mock<ISessionsService>() { + override readonly activeSession = constObservable<IActiveSession | undefined>(session); + override readonly visibleSessions = constObservable([session]); + override readonly onDidToggleSessionStickiness = Event.None; + }()); + reg.defineInstance(IDecorationsService, new class extends mock<IDecorationsService>() { override onDidChangeDecorations = Event.None; }()); + reg.defineInstance(ITextFileService, new class extends mock<ITextFileService>() { override readonly untitled = new class extends mock<ITextFileService['untitled']>() { override readonly onDidChangeLabel = Event.None; }(); }()); + reg.defineInstance(IWorkspaceContextService, new class extends mock<IWorkspaceContextService>() { override onDidChangeWorkspaceFolders = Event.None; override getWorkspace(): IWorkspace { return { id: 'fixture', folders: [], configuration: undefined }; } }()); + reg.defineInstance(INotebookDocumentService, new class extends mock<INotebookDocumentService>() { override getNotebook() { return undefined; } }()); + reg.defineInstance(IFileService, new class extends mock<IFileService>() { + override async readFile(resource: URI): Promise<IFileContent> { + return new class extends mock<IFileContent>() { + override readonly resource = resource; + override readonly value = VSBuffer.fromString('before'); + }(); + } + }()); + reg.defineInstance(IEditorService, new class extends mock<IEditorService>() { + override readonly onDidActiveEditorChange = Event.None; + override readonly onDidVisibleEditorsChange = Event.None; + override readonly onDidEditorsChange = Event.None; + override async openEditor(): Promise<undefined> { return undefined; } + }()); + reg.defineInstance(IExtensionService, new class extends mock<IExtensionService>() { override readonly onDidChangeExtensions = Event.None; }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock<IWorkbenchLayoutService>() { }()); + reg.defineInstance(ILifecycleService, new class extends mock<ILifecycleService>() { + override readonly startupKind = StartupKind.NewWindow; + override phase = LifecyclePhase.Restored; + override readonly onBeforeShutdown = Event.None; + override readonly onShutdownVeto = Event.None; + override readonly onBeforeShutdownError = Event.None; + override readonly onWillShutdown = Event.None; + override readonly willShutdown = false; + override readonly onDidShutdown = Event.None; + override async when(): Promise<void> { } + override async shutdown(): Promise<void> { } + }()); + }, + }); + + const view = disposableStore.add(instantiationService.createInstance(ChangesViewPane, { + id: CHANGES_VIEW_ID, + title: 'Changes', + minimumBodySize: 0, + maximumBodySize: Number.POSITIVE_INFINITY, + } satisfies IViewPaneOptions)); + + view.render(); + paneView.appendChild(view.element); + view.setVisible(true); + view.orthogonalSize = VIEW_WIDTH; + view.layout(height); +} + +const SAMPLE_CHANGES = [ + createFileChange('src/vs/sessions/contrib/changes/browser/changesView.ts', 'modified', 42, 18), + createFileChange('src/vs/sessions/contrib/changes/browser/sessionFilesWidget.ts', 'modified', 24, 9), + createFileChange('src/vs/sessions/contrib/changes/browser/media/sessionFilesWidget.css', 'modified', 6, 2), + createFileChange('src/vs/sessions/contrib/changes/test/browser/changesView.fixture.ts', 'added', 132, 0), + createFileChange('src/vs/sessions/contrib/changes/browser/oldChangesLayout.ts', 'deleted', 0, 47), +]; + +const SAMPLE_OTHER_FILES = [ + createOtherFile('/home/user/.config/code/settings.json', SessionFileOperation.Modified), + createOtherFile('/home/user/.config/copilot/agents/inbox.agent.md', SessionFileOperation.Created), + createOtherFile('/home/user/.cache/copilot/session.log', SessionFileOperation.Deleted), + createOtherFile('/tmp/session-notes.md', SessionFileOperation.Created), + createOtherFile('/home/user/.gitconfig', SessionFileOperation.Modified), + createOtherFile('/home/user/.ssh/config', SessionFileOperation.Modified), + createOtherFile('/home/user/.local/share/copilot/state.json', SessionFileOperation.Created), + createOtherFile('/home/user/.vscode-insiders/argv.json', SessionFileOperation.Modified), +]; + +const SAMPLE_CHECKS = [ + createCheck(1001, 'Linux / Unit Tests', GitHubCheckStatus.Completed, GitHubCheckConclusion.Success), + createCheck(1002, 'Windows / Unit Tests', GitHubCheckStatus.Completed, GitHubCheckConclusion.Failure), + createCheck(1003, 'macOS / Smoke Tests', GitHubCheckStatus.InProgress), + createCheck(1004, 'Hygiene', GitHubCheckStatus.Queued), + createCheck(1005, 'Compile', GitHubCheckStatus.Completed, GitHubCheckConclusion.Success), +]; + +export default defineThemedFixtureGroup({ path: 'sessions/changes/' }, { + AllSections_List: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: SAMPLE_CHANGES, + otherFiles: SAMPLE_OTHER_FILES, + checks: SAMPLE_CHECKS, + reviewCommentCounts: new Map([[getChangeUri(SAMPLE_CHANGES[0]).fsPath, 2]]), + agentFeedbackCounts: new Map([[getChangeUri(SAMPLE_CHANGES[1]).fsPath, 1]]), + }), + }), + + TreeMode: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.Tree, + changes: SAMPLE_CHANGES, + otherFiles: SAMPLE_OTHER_FILES.slice(0, 3), + checks: SAMPLE_CHECKS.slice(0, 3), + }), + }), + + FilesAndChecksOnly: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: SAMPLE_CHANGES, + checks: SAMPLE_CHECKS, + height: 440, + }), + }), + + NoFileChangesWithOtherFiles: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: [], + otherFiles: SAMPLE_OTHER_FILES, + checks: SAMPLE_CHECKS.slice(0, 2), + height: 440, + }), + }), + + Empty: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: ctx => renderChangesView(ctx, { + viewMode: ChangesViewMode.List, + changes: [], + otherFiles: [], + checks: [], + height: 280, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts index c3839e2deb9a28..1ff6406eb158d1 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/chatCompositeBar.fixture.ts @@ -5,9 +5,9 @@ import { URI } from '../../../../../base/common/uri.js'; import { mock } from '../../../../../base/test/common/mock.js'; -import { IObservable, observableValue } from '../../../../../base/common/observable.js'; +import { IObservable, observableValue, derived } from '../../../../../base/common/observable.js'; // eslint-disable-next-line local/code-import-patterns -import { IChat, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +import { ChatInteractivity, ChatOriginKind, IChat, ISessionCapabilities, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; // eslint-disable-next-line local/code-import-patterns import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; // eslint-disable-next-line local/code-import-patterns @@ -29,6 +29,7 @@ interface IMockChatOptions { title: string; status?: SessionStatus; isRead?: boolean; + interactivity?: ChatInteractivity; } function createMockChat(options: IMockChatOptions): IChat { @@ -38,6 +39,7 @@ function createMockChat(options: IMockChatOptions): IChat { override readonly title: IObservable<string> = observableValue('title', options.title); override readonly status: IObservable<SessionStatus> = observableValue('status', options.status ?? SessionStatus.Completed); override readonly isRead: IObservable<boolean> = observableValue('isRead', options.isRead ?? true); + override readonly interactivity: IObservable<ChatInteractivity> = observableValue('interactivity', options.interactivity ?? ChatInteractivity.Full); }(); } @@ -47,8 +49,14 @@ function createMockSession(chats: readonly IChat[], activeChat: IChat, sessionTi override readonly chats: IObservable<readonly IChat[]> = observableValue('chats', chats); override readonly openChats: IObservable<readonly IChat[]> = observableValue('openChats', chats); override readonly closedChats: IObservable<readonly IChat[]> = observableValue('closedChats', []); + override readonly visibleChatTabs: IObservable<readonly IChat[]> = observableValue('visibleChatTabs', chats); + override readonly shouldShowChatTabs: IObservable<boolean> = derived(reader => { + const tabChats = this.chats.read(reader).filter(c => c.origin?.kind !== ChatOriginKind.Tool); + return tabChats.length > 1 || (tabChats.length === 1 && tabChats[0].title.read(reader) !== this.title.read(reader)); + }); override readonly mainChat: IObservable<IChat> = observableValue('mainChat', chats[0]); override readonly activeChat: IObservable<IChat> = observableValue('activeChat', activeChat); + override readonly capabilities: IObservable<ISessionCapabilities> = observableValue('capabilities', { supportsMultipleChats: true }); override readonly isCreated: IObservable<boolean> = observableValue('isCreated', true); override readonly isArchived: IObservable<boolean> = observableValue('isArchived', false); }(); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts new file mode 100644 index 00000000000000..e17b3ce48b0ae8 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionChatInputToolbar.fixture.ts @@ -0,0 +1,209 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { mock } from '../../../../../base/test/common/mock.js'; +import { constObservable, IObservable } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ChatConfiguration } from '../../../../contrib/chat/common/constants.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionChatInputToolbar } from '../../../../../sessions/contrib/chat/browser/sessionChatInputToolbar.js'; +// eslint-disable-next-line local/code-import-patterns +import { LOCAL_AGENT_HOST_PROVIDER_ID } from '../../../../../sessions/common/agentHostSessionsProvider.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionFileChange, IChat, SessionStatus } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; +import { registerChatFixtureServices } from '../chat/chatFixtureUtils.js'; +import { IFixtureMessage, renderChatWidget } from '../chat/chatWidget.fixture.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +/** A file created during the turn (no original => classified as "created"). */ +function createdFile(name: string, insertions: number, deletions: number): ISessionFileChange { + return { uri: URI.file(`/repo/${name}`), modifiedUri: URI.file(`/repo/${name}`), insertions, deletions }; +} + +/** A file edited during the turn (has an original => classified as "modified"). */ +function editedFile(name: string, insertions: number, deletions: number): ISessionFileChange { + const uri = URI.file(`/repo/${name}`); + return { uri, modifiedUri: uri, originalUri: uri, insertions, deletions }; +} + +interface ISessionSpec { + readonly providerId?: string; + /** File changes in the last turn; omit for a chat with no last-turn changes. */ + readonly turnChanges?: readonly ISessionFileChange[]; +} + +/** A mock session + its viewed chat, as the toolbar consumes them. */ +interface IMockSessionAndChat { + readonly session: IActiveSession; + readonly chat: IChat; +} + +function createMockSession(spec: ISessionSpec): IMockSessionAndChat { + const chat = new class extends mock<IChat>() { + override readonly resource = URI.parse('chat:1'); + // Pills above the input only show while the chat's turn is in progress. + override readonly status: IObservable<SessionStatus> = constObservable(SessionStatus.InProgress); + override readonly lastTurnChanges: IObservable<readonly ISessionFileChange[]> | undefined = + spec.turnChanges !== undefined ? constObservable(spec.turnChanges) : undefined; + }(); + const session = new class extends mock<IActiveSession>() { + override readonly resource = URI.parse('session:1'); + override readonly providerId = spec.providerId ?? LOCAL_AGENT_HOST_PROVIDER_ID; + }(); + return { session, chat }; +} + +// ============================================================================ +// Render helpers +// ============================================================================ + +function renderPills(ctx: ComponentFixtureContext, mock: IMockSessionAndChat): void { + const { container, disposableStore } = ctx; + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Broad chat service graph: provides IContextMenuService and the + // ResourceLabels dependencies (decorations, text file, workspace, label + // services) the preview pill needs, on top of the base editor services + // (which register a partial ISessionsService). + registerChatFixtureServices(reg); + }, + }); + + // Both pills are off by default; enable them so the fixture renders. + (instantiationService.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, { changes: true, preview: true }); + + const pills = disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + pills.setSession(mock.session, mock.chat); + container.appendChild(pills.element); + + container.style.padding = '12px'; + container.style.backgroundColor = 'var(--vscode-sideBar-background)'; +} + +async function renderChatViewWithPills(ctx: ComponentFixtureContext, mock: IMockSessionAndChat, messages: IFixtureMessage[]): Promise<void> { + await renderChatWidget(ctx, { + messages, + decorateInputPart: (inputPart, instantiationService) => { + // Both pills are off by default; enable them so the fixture renders. + instantiationService.invokeFunction(accessor => { + (accessor.get(IConfigurationService) as TestConfigurationService).setUserConfiguration(ChatConfiguration.TurnStatusPills, { changes: true, preview: true }); + }); + const pills = ctx.disposableStore.add(instantiationService.createInstance(SessionChatInputToolbar)); + pills.setSession(mock.session, mock.chat); + // Mount above the input, mirroring the sessions ChatView. + inputPart.element.insertBefore(pills.element, inputPart.element.firstChild); + }, + }); +} + +const FULL_VIEW_MESSAGES: IFixtureMessage[] = [ + { + user: 'Add a README describing the project', + assistant: [ + { kind: 'markdown', text: 'I created `README.md` with a project overview, setup steps, and usage examples.' }, + ], + }, + { + user: 'Now scaffold a simple landing page', + assistant: [ + { kind: 'markdown', text: 'Added `index.html` with a minimal landing page and linked it from the README.' }, + ], + }, +]; + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + // --- Changes pill (per turn) -------------------------------------------- + + SessionChatPills_ChangesSingleFile: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ turnChanges: [editedFile('app.ts', 12, 5)] })), + }), + + SessionChatPills_ChangesMultipleFiles: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + turnChanges: [editedFile('app.ts', 42, 7), editedFile('util.ts', 118, 64), editedFile('index.ts', 5, 0)], + })), + }), + + SessionChatPills_ChangesOnlyInsertions: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ turnChanges: [editedFile('feature.ts', 256, 0)] })), + }), + + SessionChatPills_ChangesOnlyDeletions: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ turnChanges: [editedFile('legacy.ts', 0, 89)] })), + }), + + // --- Preview pill (resource label + dropdown) --------------------------- + + SessionChatPills_PreviewMarkdown: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + turnChanges: [createdFile('README.md', 20, 0), editedFile('app.ts', 8, 3)], + })), + }), + + SessionChatPills_PreviewHtml: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + turnChanges: [createdFile('index.html', 60, 2), editedFile('styles.css', 14, 1)], + })), + }), + + SessionChatPills_PreviewMultiple_PrimaryCreated: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + turnChanges: [ + editedFile('app.ts', 8, 3), + createdFile('README.md', 20, 0), + createdFile('index.html', 30, 4), + editedFile('CHANGELOG.md', 6, 1), + ], + })), + }), + + SessionChatPills_PreviewMultiple_PrimaryEdited: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + turnChanges: [editedFile('docs.md', 10, 2), editedFile('page.html', 4, 1)], + })), + }), + + // --- Gating ------------------------------------------------------------- + + SessionChatPills_NotAgentHost_Hidden: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({ + providerId: 'copilot-cloud', + turnChanges: [editedFile('app.ts', 12, 5)], + })), + }), + + SessionChatPills_NoActivity_Hidden: defineComponentFixture({ + render: (ctx) => renderPills(ctx, createMockSession({})), + }), + + // --- Full chat view ----------------------------------------------------- + + SessionChatView_ChangesPill: defineComponentFixture({ + render: (ctx) => renderChatViewWithPills(ctx, createMockSession({ + turnChanges: [editedFile('app.ts', 12, 5), editedFile('util.ts', 4, 2)], + }), FULL_VIEW_MESSAGES), + }), + + SessionChatView_BothPills: defineComponentFixture({ + render: (ctx) => renderChatViewWithPills(ctx, createMockSession({ + turnChanges: [createdFile('README.md', 20, 0), createdFile('index.html', 30, 4), editedFile('app.ts', 8, 3)], + }), FULL_VIEW_MESSAGES), + }), +}); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts index 39225b9632c902..d42da8893452ec 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionHeader.fixture.ts @@ -113,7 +113,7 @@ function createMockSession(options: IMockSessionOptions): IActiveSession { return new class extends mock<IActiveSession>() { override readonly sessionId = `local:${options.title}`; override readonly resource = URI.parse(`vscode-session://session/${Math.random().toString(36).slice(2)}`); - override readonly capabilities = capabilities; + override readonly capabilities = constObservable(capabilities); override readonly title: IObservable<string> = constObservable(options.title); override readonly status: IObservable<SessionStatus> = constObservable(options.status ?? SessionStatus.Completed); override readonly isArchived: IObservable<boolean> = constObservable(options.isArchived ?? false); diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionReadOnlyBanner.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionReadOnlyBanner.fixture.ts new file mode 100644 index 00000000000000..805637a5b95cc3 --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionReadOnlyBanner.fixture.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line local/code-import-patterns +import { SessionReadOnlyBanner } from '../../../../../sessions/browser/parts/sessionReadOnlyBanner.js'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils.js'; + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + ReadOnlyBanner: defineComponentFixture({ render: renderReadOnlyBanner }), +}); + +function renderReadOnlyBanner({ container, disposableStore }: ComponentFixtureContext): void { + container.style.width = '480px'; + + const banner = disposableStore.add(new SessionReadOnlyBanner()); + banner.setVisible(true); + container.appendChild(banner.domNode); +} diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts new file mode 100644 index 00000000000000..54a9e0a427b6ed --- /dev/null +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/sessionsTitleBarWidget.fixture.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, append } from '../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IObservable, constObservable } from '../../../../../base/common/observable.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; +import { AgentSessionApprovalKind, AgentSessionApprovalModel, IAgentSessionApprovalInfo } from '../../../../contrib/chat/browser/agentSessions/agentSessionApprovalModel.js'; +// eslint-disable-next-line local/code-import-patterns +import { IChat, ISession, ISessionWorkspace } from '../../../../../sessions/services/sessions/common/session.js'; +// eslint-disable-next-line local/code-import-patterns +import { IActiveSession, ISessionsManagementService } from '../../../../../sessions/services/sessions/common/sessionsManagement.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsService } from '../../../../../sessions/services/sessions/browser/sessionsService.js'; +// eslint-disable-next-line local/code-import-patterns +import { ISessionsProvidersService } from '../../../../../sessions/services/sessions/browser/sessionsProvidersService.js'; +// eslint-disable-next-line local/code-import-patterns +import { BlockedSessionReason, BlockedSessions, IBlockedSession } from '../../../../../sessions/contrib/blockedSessions/browser/blockedSessions.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionActionFeedback } from '../../../../../sessions/contrib/sessions/browser/sessionActionFeedback.js'; +// eslint-disable-next-line local/code-import-patterns +import { SessionsTitleBarWidget } from '../../../../../sessions/contrib/sessions/browser/sessionsTitleBarWidget.js'; +import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; + +// ============================================================================ +// Mock helpers +// ============================================================================ + +function createMockActiveSession(title: string, workspaceLabel: string): IActiveSession { + const workspace = new class extends mock<ISessionWorkspace>() { + override readonly label = workspaceLabel; + }(); + return new class extends mock<IActiveSession>() { + override readonly icon = Codicon.copilot; + override readonly title: IObservable<string> = constObservable(title); + override readonly workspace: IObservable<ISessionWorkspace | undefined> = constObservable(workspace); + override readonly isQuickChat: IObservable<boolean> = constObservable<boolean>(false); + }(); +} + +/** A blocked session to synthesize for a fixture. */ +interface IBlockedSpec { + /** Why the session is blocked. */ + readonly reason: BlockedSessionReason; + /** For `NeedsInput`, the kind of pending approval (terminal vs question). */ + readonly approvalKind?: AgentSessionApprovalKind; +} + +/** + * Build mock blocked sessions plus an approval model that reports the requested + * approval kind for each session's chat, so the widget classifies them exactly. + */ +function buildBlocked(specs: readonly IBlockedSpec[]): { blocked: IBlockedSession[]; approvalModel: AgentSessionApprovalModel } { + const approvals = new Map<string, IAgentSessionApprovalInfo>(); + const blocked = specs.map((spec, i): IBlockedSession => { + const chatResource = URI.parse(`session-chat:/blocked/${i}`); + if (spec.approvalKind) { + approvals.set(chatResource.toString(), { + kind: spec.approvalKind, + label: 'npm run build', + languageId: undefined, + since: new Date(), + confirm: () => { }, + }); + } + const chat = new class extends mock<IChat>() { + override readonly resource = chatResource; + }(); + const session = new class extends mock<ISession>() { + override readonly sessionId = `blocked-${i}`; + override readonly chats: IObservable<readonly IChat[]> = constObservable([chat]); + }(); + return { session, reason: spec.reason }; + }); + const approvalModel = new class extends mock<AgentSessionApprovalModel>() { + override getApproval(resource: URI): IObservable<IAgentSessionApprovalInfo | undefined> { + return constObservable(approvals.get(resource.toString())); + } + }(); + return { blocked, approvalModel }; +} + +interface ITitleBarState { + /** The active session shown in the default pill (falls back to "New Session"). */ + activeSession?: IActiveSession; + /** Number of blocked sessions (drives the orange "N sessions require input"). */ + blockedCount?: number; + /** Explicit typed blocked sessions (drives the specific requires-input message). */ + blocked?: readonly IBlockedSpec[]; + /** Number of recently approved sessions (drives the green "Approved N sessions"). */ + approvedCount?: number; +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderTitleBar(ctx: ComponentFixtureContext, state: ITitleBarState): void { + const { container, disposableStore } = ctx; + + // Blocked sessions: either an explicit typed list, or a plain count of + // unclassified needs-input sessions (which yield the generic message). + const specs: readonly IBlockedSpec[] = state.blocked + ?? Array.from({ length: state.blockedCount ?? 0 }, (): IBlockedSpec => ({ reason: BlockedSessionReason.NeedsInput })); + const { blocked, approvalModel } = buildBlocked(specs); + + const instantiationService = createEditorServices(disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + registerWorkbenchServices(reg); + reg.defineInstance(ISessionsService, new class extends mock<ISessionsService>() { + override readonly activeSession: IObservable<IActiveSession | undefined> = constObservable(state.activeSession); + override readonly visibleSessions: IObservable<readonly (IActiveSession | undefined)[]> = constObservable<readonly (IActiveSession | undefined)[]>([]); + }()); + reg.defineInstance(ISessionsManagementService, new class extends mock<ISessionsManagementService>() { + override readonly onDidChangeSessions = Event.None; + }()); + reg.defineInstance(ISessionsProvidersService, new class extends mock<ISessionsProvidersService>() { + override readonly onDidChangeProviders = Event.None; + }()); + reg.defineInstance(IWorkbenchLayoutService, new class extends mock<IWorkbenchLayoutService>() { + override readonly onDidChangePartVisibility = Event.None; + }()); + reg.defineInstance(IQuickInputService, new class extends mock<IQuickInputService>() { + override readonly onShow = Event.None; + }()); + // The blocked-sessions feature is only enabled outside of stable builds. + reg.defineInstance(IProductService, new class extends mock<IProductService>() { + override readonly quality = 'insider'; + }()); + }, + }); + + // The widget's pill styles are scoped under `.command-center`, so recreate + // that ancestor. The command center box sizes itself relative to the + // viewport, so give the host a representative width. + container.classList.add('agent-sessions-workbench'); + container.style.width = '460px'; + const commandCenter = append(container, $('.command-center')); + const widgetHost = append(commandCenter, $('div')); + + const action = new class extends mock<SubmenuItemAction>() { + override readonly id = 'workbench.agentSessions.titlebar'; + override readonly label = 'Agent Sessions'; + override readonly tooltip = ''; + override readonly enabled = true; + override async run() { } + }(); + + const sessionActionFeedback = new class extends mock<SessionActionFeedback>() { + override readonly approvedCount: IObservable<number> = constObservable<number>(state.approvedCount ?? 0); + override notifyApproved(): void { } + }(); + + const blockedSessionsModel = new class extends mock<BlockedSessions>() { + override readonly blockedSessions: IObservable<readonly ISession[]> = constObservable(blocked.map(entry => entry.session)); + override readonly blockedSessionsWithReasons: IObservable<readonly IBlockedSession[]> = constObservable(blocked); + }(); + + const widget = disposableStore.add(instantiationService.createInstance(SessionsTitleBarWidget, action, undefined, sessionActionFeedback, approvalModel, blockedSessionsModel)); + widget.render(widgetHost); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + // Default: shows the active session pill (icon + title + workspace). + SessionsTitleBar_ActiveSession: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + }), + }), + + // Requires-input: generic orange state (a mix, or unclassified needs-input). + SessionsTitleBar_RequiresInput: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blockedCount: 3, + }), + }), + + // Requires-input (terminal): all blocked sessions are waiting on a terminal command. + SessionsTitleBar_RequiresInputTerminal: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + ], + }), + }), + + // Requires-input (question): all blocked sessions are asking a question. + SessionsTitleBar_RequiresInputQuestion: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Question }, + ], + }), + }), + + // Requires-input (failing CI): all blocked sessions have failing CI checks. + SessionsTitleBar_RequiresInputFailingCI: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.FailingCI }, + { reason: BlockedSessionReason.FailingCI }, + ], + }), + }), + + // Requires-input (mixed): a mix of reasons falls back to the generic message. + SessionsTitleBar_RequiresInputMixed: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blocked: [ + { reason: BlockedSessionReason.NeedsInput, approvalKind: AgentSessionApprovalKind.Terminal }, + { reason: BlockedSessionReason.FailingCI }, + ], + }), + }), + + // Approved (one): transient green confirmation after approving a session action. + SessionsTitleBar_ApprovedOne: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + approvedCount: 1, + }), + }), + + // Approved (many): green confirmation after approving several sessions in a row. + // Takes precedence over the orange requires-input state while visible. + SessionsTitleBar_ApprovedMany: defineComponentFixture({ + render: (ctx) => renderTitleBar(ctx, { + activeSession: createMockActiveSession('Fix authentication redirect loop', 'vscode'), + blockedCount: 3, + approvedCount: 3, + }), + }), +}); diff --git a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts index d20d907c18e4a0..779807e5dc4bb7 100644 --- a/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts +++ b/src/vs/workbench/test/browser/parts/activitybar/activitybarPart.test.ts @@ -110,7 +110,7 @@ suite('ActivitybarPart', () => { // --- Static constants --------------------------------------------------- - test('default constants match original (pre-compact) dimensions', () => { + test('default constants match expected dimensions', () => { assert.deepStrictEqual( { width: ActivitybarPart.ACTIVITYBAR_WIDTH, @@ -140,6 +140,21 @@ suite('ActivitybarPart', () => { ); }); + test('floating constants are narrower than default', () => { + assert.deepStrictEqual( + { + width: ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH, + actionHeight: ActivitybarPart.FLOATING_ACTION_HEIGHT, + compactWidth: ActivitybarPart.FLOATING_COMPACT_ACTIVITYBAR_WIDTH, + }, + { + width: 44, + actionHeight: 44, + compactWidth: 32, + } + ); + }); + // --- Dimension getters -------------------------------------------------- test('default mode returns default width constraints', () => { @@ -170,8 +185,8 @@ suite('ActivitybarPart', () => { assert.deepStrictEqual( { min: part.minimumWidth, max: part.maximumWidth }, { - min: ActivitybarPart.ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN, - max: ActivitybarPart.ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN, + min: ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN, + max: ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN, } ); }); @@ -248,7 +263,7 @@ suite('ActivitybarPart', () => { fireConfigChange(configService, LayoutSettings.MODERN_UI); assert.deepStrictEqual(events, [undefined]); - assert.strictEqual(part.minimumWidth, ActivitybarPart.ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN); + assert.strictEqual(part.minimumWidth, ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH + ActivitybarPart.FLOATING_MARGIN); }); // --- CSS custom properties on element ----------------------------------- @@ -279,6 +294,19 @@ suite('ActivitybarPart', () => { assert.strictEqual(el.classList.contains('compact'), true); }); + test('updateCompactStyle sets correct CSS custom properties in floating mode', () => { + const { part } = createActivitybarPart(false, true); + + const el = document.createElement('div'); + fixture.appendChild(el); + part.create(el); + + assert.strictEqual(el.style.getPropertyValue('--activity-bar-width'), `${ActivitybarPart.FLOATING_ACTIVITYBAR_WIDTH}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-action-height'), `${ActivitybarPart.FLOATING_ACTION_HEIGHT}px`); + assert.strictEqual(el.style.getPropertyValue('--activity-bar-icon-size'), `${ActivitybarPart.ICON_SIZE}px`); + assert.strictEqual(el.classList.contains('compact'), false); + }); + test('toggling compact updates CSS custom properties on element', () => { const { part, configService } = createActivitybarPart(false); diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 70ffa107d85eca..b15a1c1b7c9ef6 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -821,7 +821,6 @@ export class TestChatEntitlementService implements IChatEntitlementService { markSetupCompleted(): void { } setForceHidden(_hidden: boolean): void { } - readonly previewFeaturesDisabled = false; readonly clientByokEnabled = false; readonly hasByokModels = false; } diff --git a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts index 1ca6e7bf478781..04a428211f9185 100644 --- a/src/vs/workbench/test/electron-browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/electron-browser/workbenchTestServices.ts @@ -25,7 +25,7 @@ import { InMemoryFileSystemProvider } from '../../../platform/files/common/inMem import { IInstantiationService } from '../../../platform/instantiation/common/instantiation.js'; import { ISharedProcessService } from '../../../platform/ipc/electron-browser/services.js'; import { NullLogService } from '../../../platform/log/common/log.js'; -import { INativeHostOptions, INativeHostService, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../../../platform/native/common/native.js'; +import { INativeHostOptions, INativeHostService, INativeSystemWideKeybinding, INativeSystemWideKeybindingResult, IOSProperties, IOSStatistics, IToastOptions, IToastResult, PowerSaveBlockerType, SystemIdleState, ThermalState } from '../../../platform/native/common/native.js'; import { IProductService } from '../../../platform/product/common/productService.js'; import { AuthInfo, Credentials } from '../../../platform/request/common/request.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -105,6 +105,8 @@ export class TestNativeHostService implements INativeHostService { async openAgentsWindow(_options?: { folderUri?: UriComponents; sessionResource?: UriComponents }): Promise<void> { } + async syncSystemWideKeybindings(_keybindings: INativeSystemWideKeybinding[]): Promise<INativeSystemWideKeybindingResult> { return { failed: [] }; } + async toggleFullScreen(): Promise<void> { } async isMaximized(): Promise<boolean> { return true; } async isFullScreen(): Promise<boolean> { return true; } diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index b94b0d6ef90f45..7f7312caae19f6 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -93,6 +93,7 @@ import '../platform/sandbox/electron-browser/sandboxHelperService.js'; import '../platform/webContentExtractor/electron-browser/webContentExtractorService.js'; import './services/agentHost/electron-browser/agentHostService.js'; import '../platform/agentHost/electron-browser/remoteAgentHostService.js'; +import '../platform/agentHost/browser/agentHostEnablementService.js'; import './services/browserView/electron-browser/playwrightWorkbenchService.js'; import './services/process/electron-browser/processService.js'; import './services/power/electron-browser/powerService.js'; @@ -204,6 +205,9 @@ import './contrib/policyExport/electron-browser/policyExport.contribution.js'; // Keybindings Export import './contrib/keybindingsExport/electron-browser/keybindingsExport.contribution.js'; +// System-wide (OS global) Keybindings +import './contrib/keybindings/electron-browser/systemWideKeybindings.contribution.js'; + //#endregion diff --git a/src/vs/workbench/workbench.web.main.ts b/src/vs/workbench/workbench.web.main.ts index 76016a43a398a0..cd132e8da5cf49 100644 --- a/src/vs/workbench/workbench.web.main.ts +++ b/src/vs/workbench/workbench.web.main.ts @@ -105,6 +105,7 @@ import { IAgentHostService } from '../platform/agentHost/common/agentService.js' import { EditorRemoteAgentHostServiceClient } from './services/agentHost/browser/editorRemoteAgentHostServiceClient.js'; import { IRemoteAgentHostService, NullRemoteAgentHostService } from '../platform/agentHost/common/remoteAgentHostService.js'; import { BrowserAgentHostDebugLogsExportService, IAgentHostDebugLogsExportService } from './contrib/chat/browser/actions/exportAgentHostDebugLogsAction.js'; +import '../platform/agentHost/browser/agentHostEnablementService.js'; registerSingleton(IWorkbenchExtensionManagementService, ExtensionManagementService, InstantiationType.Delayed); registerSingleton(IAccessibilityService, AccessibilityService, InstantiationType.Delayed); diff --git a/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts b/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts new file mode 100644 index 00000000000000..c84233dfee8a8a --- /dev/null +++ b/src/vscode-dts/vscode.proposed.agentEditorComments.d.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + + /** + * A comment anchored to a range of a resource, sourced from the workbench's + * agent/session comment store (the same store the built-in code editor renders + * its session comments from). + */ + export interface AgentEditorComment { + /** Stable identity of the comment within its provider. */ + readonly id: string; + /** The range the comment is anchored to. */ + readonly range: Range; + /** The comment text. */ + readonly body: string; + /** Display name of the author, if any. */ + readonly author?: string; + } + + /** + * A live, disposable view of the agent/session comments for a resource that is + * not necessarily shown in a {@link TextEditor} (for example a document rendered + * by a custom editor). + * + * The comments are backed by the same workbench store the code editor uses, so + * comments added here are visible in the code editor and vice versa. + */ + export interface AgentEditorCommentsProvider extends Disposable { + /** + * The current comments for the resource. Empty when the resource has no + * comments or is not in scope for any session. + */ + readonly comments: readonly AgentEditorComment[]; + + /** + * Whether new comments can be added for the resource. This is `false` when the + * resource is not in scope for a session, in which case {@link addComment} is a + * no-op. UI that lets the user author comments should be hidden while this is + * `false`. + * + * Changes to this value are reported through {@link onDidChange}. + */ + readonly acceptsComments: boolean; + + /** + * An event that fires whenever {@link comments} or {@link acceptsComments} + * changes. + */ + readonly onDidChange: Event<void>; + + /** + * Add a comment for the resource at the given range. No-op when the resource + * is not in scope for a session. + * + * @param range The range to anchor the comment to. + * @param body The comment text. + */ + // eslint-disable-next-line local/vscode-dts-provider-naming + addComment(range: Range, body: string): void; + + /** + * Delete the comment with the given id. No-op when no comment with that id + * exists or the resource is not in scope for a session. + * + * @param id The {@link AgentEditorComment.id id} of the comment to delete. + */ + // eslint-disable-next-line local/vscode-dts-provider-naming + deleteComment(id: string): void; + } + + export namespace window { + /** + * Observe (and contribute to) the agent/session comments for a resource. + * + * The resource only needs to be backed by an open {@link TextDocument}; it does + * not need to be shown in a {@link TextEditor}. This is useful for custom + * editors, which render their document in a webview rather than a text editor. + * + * The returned provider must be disposed once it is no longer needed. + * + * @param uri The resource to observe. + * @returns A live, disposable view of the resource's comments. + */ + export function createAgentEditorComments(uri: Uri): AgentEditorCommentsProvider; + } + +} diff --git a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts index e7cd493ae50adb..00f8a62081eea0 100644 --- a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts @@ -8,6 +8,8 @@ declare module 'vscode' { // https://github.com/microsoft/vscode/issues/271104 @alexr00 + export type TabSelector = { uri: DocumentSelector } | { viewType: string }; + export namespace chat { /** @@ -33,7 +35,13 @@ declare module 'vscode' { * @param id Unique identifier for the provider. * @param provider The chat explicit context provider. */ - export function registerChatExplicitContextProvider(id: string, provider: ChatExplicitContextProvider): Disposable; + export function registerChatAttachContextProvider(id: string, provider: ChatAttachContextProvider): Disposable; + + /** + * @deprecated + */ + export function registerChatExplicitContextProvider(id: string, provider: any): Disposable; + /** * Register a chat resource context provider. Resource context is provided for a specific resource. @@ -45,18 +53,12 @@ declare module 'vscode' { * @param id Unique identifier for the provider. * @param provider The chat resource context provider. */ - export function registerChatResourceContextProvider(selector: DocumentSelector, id: string, provider: ChatResourceContextProvider): Disposable; + export function registerChatTabContextProvider(selector: TabSelector, id: string, provider: ChatTabContextProvider): Disposable; /** - * Register a chat context provider. - * - * @deprecated Use {@link registerChatWorkspaceContextProvider}, {@link registerChatExplicitContextProvider}, or {@link registerChatResourceContextProvider} instead. - * - * @param selector Optional document selector to filter which resources the provider is called for. If omitted, the provider will only be called for explicit context requests. - * @param id Unique identifier for the provider. - * @param provider The chat context provider. + * @deprecated */ - export function registerChatContextProvider(selector: DocumentSelector | undefined, id: string, provider: ChatContextProvider): Disposable; + export function registerChatResourceContextProvider(selector: DocumentSelector, id: string, provider: any): Disposable; } @@ -112,14 +114,9 @@ declare module 'vscode' { * @param token A cancellation token. */ provideWorkspaceChatContext(token: CancellationToken): ProviderResult<T[]>; - - /** - * @deprecated - */ - provideChatContext?(token: CancellationToken): ProviderResult<T[]>; } - export interface ChatExplicitContextProvider<T extends ChatContextItem = ChatContextItem> { + export interface ChatAttachContextProvider<T extends ChatContextItem = ChatContextItem> { /** * Provide a list of chat context items that a user can choose from. These context items are shown as options when the user explicitly attaches context. @@ -128,12 +125,7 @@ declare module 'vscode' { * * @param token A cancellation token. */ - provideExplicitChatContext(token: CancellationToken): ProviderResult<T[]>; - - /** - * @deprecated - */ - provideChatContext?(token: CancellationToken): ProviderResult<T[]>; + provideAttachChatContext(token: CancellationToken): ProviderResult<T[]>; /** * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. @@ -141,15 +133,10 @@ declare module 'vscode' { * @param context The context item to resolve. * @param token A cancellation token. */ - resolveExplicitChatContext(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; - - /** - * @deprecated - */ - resolveChatContext?(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; + resolveAttachChatContext(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; } - export interface ChatResourceContextProvider<T extends ChatContextItem = ChatContextItem> { + export interface ChatTabContextProvider<T extends ChatContextItem = ChatContextItem> { /** * Given a particular resource, provide a chat context item for it. This is used for implicit context (see the settings `chat.implicitContext.enabled` and `chat.implicitContext.suggestedContext`). @@ -161,12 +148,9 @@ declare module 'vscode' { * @param options Options include the resource for which to provide context. * @param token A cancellation token. */ - provideResourceChatContext(options: { resource: Uri }, token: CancellationToken): ProviderResult<T | undefined>; - - /** - * @deprecated - */ - provideChatContext?(options: { resource: Uri }, token: CancellationToken): ProviderResult<T | undefined>; + // Can use active editor?\ + // Rename ChatTab to be consistent + provideChatTabContext(options: { tab: Tab }, token: CancellationToken): ProviderResult<T | undefined>; /** * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. @@ -174,48 +158,6 @@ declare module 'vscode' { * @param context The context item to resolve. * @param token A cancellation token. */ - resolveResourceChatContext(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; - - /** - * @deprecated - */ - resolveChatContext?(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; - } - - /** - * @deprecated Use {@link ChatWorkspaceContextProvider}, {@link ChatExplicitContextProvider}, or {@link ChatResourceContextProvider} instead. - */ - export interface ChatContextProvider<T extends ChatContextItem = ChatContextItem> { - - /** - * An optional event that should be fired when the workspace chat context has changed. - * @deprecated Use {@link ChatWorkspaceContextProvider.onDidChangeWorkspaceChatContext} instead. - */ - onDidChangeWorkspaceChatContext?: Event<void>; - - /** - * Provide a list of chat context items to be included as workspace context for all chat requests. - * @deprecated Use {@link ChatWorkspaceContextProvider.provideWorkspaceChatContext} instead. - */ - provideWorkspaceChatContext?(token: CancellationToken): ProviderResult<T[]>; - - /** - * Provide a list of chat context items that a user can choose from. - * @deprecated Use {@link ChatExplicitContextProvider.provideExplicitChatContext} instead. - */ - provideChatContextExplicit?(token: CancellationToken): ProviderResult<T[]>; - - /** - * Given a particular resource, provide a chat context item for it. - * @deprecated Use {@link ChatResourceContextProvider.provideResourceChatContext} instead. - */ - provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult<T | undefined>; - - /** - * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. - * @deprecated Use the `resolveChatContext` method on the specific provider type instead. - */ - resolveChatContext?(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; + resolveChatTabContext(context: T, token: CancellationToken): ProviderResult<ChatContextItem>; } - } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index c9d6832fddb1fb..567c5096701744 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -442,6 +442,7 @@ declare module 'vscode' { ChatResponseThinkingProgressPart: ChatResponseThinkingProgressPart; ChatResponseExternalEditPart: ChatResponseExternalEditPart; ChatResponseQuestionCarouselPart: ChatResponseQuestionCarouselPart; + ChatResponseAutoModeResolutionPart: ChatResponseAutoModeResolutionPart; } export type ExtendedChatResponsePart = ExtendedChatResponseParts[keyof ExtendedChatResponseParts]; @@ -566,6 +567,22 @@ declare module 'vscode' { constructor(uriOrCommand: Uri | Command, title: string, description: string, author: string, linkTag: string); } + /** + * Represents an auto-mode model routing resolution. Displayed as a collapsible + * widget in the chat stream showing which model was selected and why. + */ + export class ChatResponseAutoModeResolutionPart { + /** The model ID that was selected by the router */ + resolvedModel: string; + /** The user-facing display name of the resolved model */ + resolvedModelName: string; + /** The router's classification label */ + predictedLabel: string; + /** Confidence score (0-1) from the router */ + confidence: number; + constructor(resolvedModel: string, resolvedModelName: string, predictedLabel: string, confidence: number); + } + export interface ChatResponseStream { /** diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index e391b10f79cf5f..cbedfc0b86e9c2 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -83,6 +83,13 @@ declare module 'vscode' { * The value must match a `type` declared in a `chatSessions` extension contribution. */ readonly targetChatSessionType?: string; + + /** + * Optional warning text to display in the model picker hover as a warning banner. + * The keys are warning categories (e.g. "data_retention") and the values are markdown strings. + * Unlike degradation warnings, this does not produce a warning icon in the picker list. + */ + readonly warningText?: Record<string, string>; } export interface LanguageModelChatCapabilities { diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index 5851658fd5b6b7..66a6e102411c3e 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -201,6 +201,8 @@ declare module 'vscode' { readonly uri: Uri; /** Display label for the picker when multiple folders are offered. */ readonly label: string; + /** Source of the customization folder. */ + readonly source: ChatSessionCustomizationSource; } // #endregion diff --git a/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts b/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts index faa18c2932d610..e940734ad823a0 100644 --- a/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts +++ b/src/vscode-dts/vscode.proposed.textEditorDiffInformation.d.ts @@ -40,8 +40,43 @@ declare module 'vscode' { readonly diffInformation: TextEditorDiffInformation[] | undefined; } + /** + * A live, disposable view of the source control diff information of a resource + * that is not necessarily shown in a {@link TextEditor} (for example a document + * rendered by a custom editor). + */ + export interface SourceControlDiffInformationProvider extends Disposable { + /** + * The current diff information for the resource, reflecting the primary + * source control provider (e.g. git), or `undefined` while it is unavailable + * (for example before the first diff has been computed or when the resource + * is not under source control). + */ + readonly diffInformation: TextEditorDiffInformation | undefined; + + /** + * An event that fires whenever {@link diffInformation} changes. + */ + readonly onDidChange: Event<void>; + } + export namespace window { export const onDidChangeTextEditorDiffInformation: Event<TextEditorDiffInformationChangeEvent>; + + /** + * Observe the source control diff information for a resource. + * + * Unlike {@link TextEditor.diffInformation}, this does not require the + * resource to be shown in a {@link TextEditor}; the resource only needs to be + * backed by an open {@link TextDocument}. This is useful for custom editors, + * which render their document in a webview rather than a text editor. + * + * The returned provider must be disposed once it is no longer needed. + * + * @param uri The resource to observe. + * @returns A live, disposable view of the resource's diff information. + */ + export function createSourceControlDiffInformation(uri: Uri): SourceControlDiffInformationProvider; } } diff --git a/test/automation/package-lock.json b/test/automation/package-lock.json index 7deea930455d40..e8dca87b0a3745 100644 --- a/test/automation/package-lock.json +++ b/test/automation/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "axe-core": "^4.10.2", "ncp": "^2.0.0", - "tmp": "0.2.6", + "tmp": "0.2.7", "tree-kill": "1.2.2", "vscode-uri": "3.0.2" }, @@ -890,9 +890,9 @@ } }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "license": "MIT", "engines": { "node": ">=14.14" diff --git a/test/automation/package.json b/test/automation/package.json index b0d65884094247..f4f52d5c34e5f7 100644 --- a/test/automation/package.json +++ b/test/automation/package.json @@ -20,7 +20,7 @@ "dependencies": { "axe-core": "^4.10.2", "ncp": "^2.0.0", - "tmp": "0.2.6", + "tmp": "0.2.7", "tree-kill": "1.2.2", "vscode-uri": "3.0.2" }, diff --git a/test/automation/src/agentsWindow.ts b/test/automation/src/agentsWindow.ts index cad5d36b3f1f15..4cede2e450a4c4 100644 --- a/test/automation/src/agentsWindow.ts +++ b/test/automation/src/agentsWindow.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Code } from './code'; +import { acceptToolConfirmationIfPresent } from './chat'; import { QuickAccess } from './quickaccess'; const AGENTS_WORKBENCH = '.agent-sessions-workbench'; @@ -428,7 +429,7 @@ export class AgentsWindow { * well after the content is on screen, so requiring `:not(.chat-response-loading)` * causes false-negative timeouts. */ - async waitForAssistantText(predicate: RegExp | string, timeoutMs: number = 60_000): Promise<string> { + async waitForAssistantText(predicate: RegExp | string, timeoutMs: number = 60_000, options?: { acceptToolConfirmations?: boolean }): Promise<string> { const retryCount = Math.ceil(timeoutMs / 100); await this.code.waitForElement(RESPONSE, undefined, retryCount); @@ -437,6 +438,12 @@ export class AgentsWindow { const deadline = Date.now() + timeoutMs; let lastTexts: string[] = []; while (Date.now() < deadline) { + // When requested, accept any pending terminal tool confirmation so + // the agentic loop can proceed. No-op for sessions that + // auto-approve their shell commands. + if (options?.acceptToolConfirmations) { + await acceptToolConfirmationIfPresent(this.code); + } // Look in BOTH the active session view and the broader workbench // scope. The Agents Window can auto-swap the active slot to a // fresh untitled session immediately after a follow-up commits, @@ -571,12 +578,16 @@ export class AgentsWindow { // that intercepts pointer events while the action widget animates open. await row.click({ force: true }); // Confirm the selection actually committed: the picker name button - // must now display the chosen model. A non-committing click (e.g. + // must now reflect the chosen model. A non-committing click (e.g. // absorbed by the animating pointer-block overlay) silently leaves the // previous model selected and the picker dismissed, so waiting only - // for the popup to close would miss it. Scope to `:visible` so a hidden - // overflow duplicate of the name button can't produce a false positive. - await page.locator(`${ACTIVE_SESSION_MODEL_PICKER_NAME}:visible`, { hasText: modelName }) + // for the popup to close would miss it. Match on the button's accessible + // name (aria-label, e.g. "Models, <modelName>") rather than the visible + // text: when the input is narrow the picker collapses to an icon-only + // button and no longer renders the model name as visible text. Scope to + // `:visible` so a hidden overflow duplicate of the name button can't + // produce a false positive. + await page.locator(`${ACTIVE_SESSION_MODEL_PICKER_NAME}[aria-label*="${modelName}"]:visible`) .first() .waitFor({ state: 'visible', timeout: 15_000 }); return; diff --git a/test/automation/src/chat.ts b/test/automation/src/chat.ts index 75a0853be5f57b..d53eedf3c78d7a 100644 --- a/test/automation/src/chat.ts +++ b/test/automation/src/chat.ts @@ -16,6 +16,10 @@ const CHAT_RESPONSE_RENDERED = `${CHAT_RESPONSE} .rendered-markdown`; const CHAT_FOOTER_DETAILS = `${CHAT_VIEW} .chat-footer-details`; const CHAT_MODEL_PICKER_NAME = `${CHAT_VIEW} .interactive-input-part .model-picker-name`; const CHAT_MODEL_PICKER_CONFIG = `${CHAT_VIEW} .interactive-input-part .model-picker-config`; +// The panel chat lives in the auxiliary bar; widening it pushes the model +// picker out of its width-driven compact (icon-only) layout so the inline +// model-config button renders. See `ensureModelPickerExpanded`. +const AUXILIARYBAR_PART = '.part.auxiliarybar'; const ACTION_WIDGET = '.action-widget'; const ACTION_WIDGET_ROW = '.action-widget .monaco-list-row.action'; // Context-usage gauge in the panel chat input. The inline widget only renders a @@ -126,12 +130,12 @@ export class Chat { * the content has actually arrived (avoiding false matches on placeholder * text like "Considering" that appears before streaming begins). */ - async waitForResponseText(predicate: string | RegExp, timeoutMs: number = 60_000): Promise<string> { - return await this.pollForResponseText(CHAT_RESPONSE, CHAT_RESPONSE_RENDERED, predicate, timeoutMs); + async waitForResponseText(predicate: string | RegExp, timeoutMs: number = 60_000, options?: { acceptToolConfirmations?: boolean }): Promise<string> { + return await this.pollForResponseText(CHAT_RESPONSE, CHAT_RESPONSE_RENDERED, predicate, timeoutMs, options?.acceptToolConfirmations); } - async waitForEditorResponseText(predicate: string | RegExp, timeoutMs: number = 60_000): Promise<string> { - const matched = await this.pollForResponseText(CHAT_EDITOR_RESPONSE, CHAT_EDITOR_RESPONSE_RENDERED, predicate, timeoutMs); + async waitForEditorResponseText(predicate: string | RegExp, timeoutMs: number = 60_000, options?: { acceptToolConfirmations?: boolean }): Promise<string> { + const matched = await this.pollForResponseText(CHAT_EDITOR_RESPONSE, CHAT_EDITOR_RESPONSE_RENDERED, predicate, timeoutMs, options?.acceptToolConfirmations); // After a contributed chat session (e.g. Copilot CLI, Claude) returns // its first response, the workbench commits the untitled session into // a real (titled) one and `replaceEditors` swaps the chat editor over. @@ -164,10 +168,16 @@ export class Chat { } } - private async pollForResponseText(bubbleSelector: string, renderedSelector: string, predicate: string | RegExp, timeoutMs: number): Promise<string> { + private async pollForResponseText(bubbleSelector: string, renderedSelector: string, predicate: string | RegExp, timeoutMs: number, acceptToolConfirmations?: boolean): Promise<string> { const deadline = Date.now() + timeoutMs; const matches = (text: string) => typeof predicate === 'string' ? text.includes(predicate) : predicate.test(text); while (Date.now() < deadline) { + // When requested, accept any pending terminal tool confirmation so + // the agentic loop can proceed to render the final response. This + // is a no-op for sessions that auto-approve their shell commands. + if (acceptToolConfirmations) { + await acceptToolConfirmationIfPresent(this.code); + } const elements = await this.code.driver.getElements(renderedSelector, /* recursive */ true); const matched = elements.map(el => el.textContent ?? '').filter(matches); if (matched.length > 0) { @@ -263,11 +273,15 @@ export class Chat { // that intercepts pointer events while the action widget animates open. await row.click({ force: true }); // Confirm the selection actually committed: the picker name button must - // now display the chosen model. (A non-committing click leaves the old + // now reflect the chosen model. (A non-committing click leaves the old // model selected and the picker dismissed, so waiting only for the - // popup to close would miss it.) Scope to `:visible` so a hidden overflow - // duplicate of the name button can't produce a false positive. - await page.locator(`${CHAT_MODEL_PICKER_NAME}:visible`, { hasText: modelName }) + // popup to close would miss it.) Match on the button's accessible name + // (aria-label, e.g. "Models, <modelName>") rather than the visible text: + // when the input is narrow the picker collapses to an icon-only button and + // no longer renders the model name as visible text. Scope to `:visible` so + // a hidden overflow duplicate of the name button can't produce a false + // positive. + await page.locator(`${CHAT_MODEL_PICKER_NAME}[aria-label*="${modelName}"]:visible`) .first() .waitFor({ state: 'visible', timeout: 10_000 }); return; @@ -283,7 +297,53 @@ export class Chat { } /** - * Opens the combined model configuration dropdown (Thinking Effort / Context + * Widens the panel chat (its host auxiliary bar) until the model picker leaves + * its width-driven compact layout, i.e. until the inline model-config button + * renders. At the auxiliary bar's default width the picker collapses to an + * icon-only layout that omits the config button (and the model-name text), so + * tests that drive the inline config UI must first expand the panel. + * + * The panel is widened by dragging the auxiliary bar's leading sash outward. + * This is a no-op once the button is already visible, so it is safe to call + * repeatedly and cheap on the common (already-expanded) path. + */ + private async ensureModelPickerExpanded(timeoutMs: number = 20_000): Promise<void> { + const page = this.code.driver.currentPage; + const configButton = page.locator(`${CHAT_MODEL_PICKER_CONFIG}:visible`).first(); + const auxBar = page.locator(AUXILIARYBAR_PART).first(); + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (await configButton.isVisible().catch(() => false)) { + // Park the pointer away from the sash so a lingering cursor can't + // keep a resize hover active over the picker we're about to click. + await page.mouse.move(0, 0).catch(() => { /* no page */ }); + return; + } + + const box = await auxBar.boundingBox().catch(() => null); + if (box) { + // The auxiliary bar sits at the right edge of the workbench; its + // leading (left) sash lies on the bar's left border. Drag it left to + // widen the bar (and shrink the editor). Move the pointer onto the + // sash, then in two steps — a small nudge engages the drag before the + // larger travel — so the resize registers reliably. + const sashX = box.x; + const sashY = box.y + Math.min(box.height / 2, 200); + await page.mouse.move(sashX, sashY); + await page.mouse.down(); + await page.mouse.move(sashX - 20, sashY); + await page.mouse.move(sashX - 160, sashY); + await page.mouse.up(); + } + + // Let the ResizeObserver-driven relayout recompute the picker's compact + // state before re-checking / dragging again. + await new Promise(r => setTimeout(r, 300)); + } + } + + /** * Size) by clicking the model picker's configuration button. The button is * only visible when the selected model advertises configurable options, so * this waits for it to become visible before clicking. @@ -312,6 +372,12 @@ export class Chat { // overlay to detach before clicking. await this.dismissContextUsageDetails(); + // The inline model-config button only renders when the model picker is in + // its full layout. At the panel's default width the picker collapses to a + // compact, icon-only layout that omits the config button entirely, so widen + // the panel until the button appears before waiting on it below. + await this.ensureModelPickerExpanded(); + while (Date.now() < deadline) { try { await configButton.waitFor({ state: 'visible', timeout: 15_000 }); @@ -474,3 +540,54 @@ export class Chat { } } } + +/** + * Click the "Allow" button of a pending terminal tool confirmation if one is + * present. No-op when there is no confirmation (e.g. the session auto-approved + * its shell command). Shared by {@link Chat} and the Agents Window driver so + * shell-tool tests can drive the real confirmation flow without per-agent + * special-casing. + * + * The terminal confirmation renders as a `.chat-confirmation-widget2` whose + * action buttons are `.monaco-button.monaco-text-button` anchors — the primary + * "Allow" (rendered first), then "Skip". The dropdown chevron next to "Allow" + * is a `.monaco-dropdown-button.codicon` (no `.monaco-text-button`) and the + * carousel's "Allow All" lives on `.chat-tool-carousel-allow-all-button`, so + * neither is matched here. We verify the first text button actually reads + * "Allow" before clicking to avoid ever hitting "Skip". + * + * Uses the VS Code driver (`getElements`/`click`) rather than a raw Playwright + * locator: the confirmation renders outside the chat response/editor + * containers, and the driver reliably resolves it workbench-wide. + */ +export async function acceptToolConfirmationIfPresent(code: Code): Promise<void> { + const allowButtonSelector = '.chat-confirmation-widget2 .monaco-button.monaco-text-button'; + try { + const buttons = await code.driver.getElements(allowButtonSelector, /* recursive */ true); + // The first text button in the widget is "Allow"; only proceed when + // that holds so we never accidentally hit "Skip". + if (!buttons || buttons.length === 0 || (buttons[0].textContent ?? '').trim() !== 'Allow') { + return; + } + // Filter to only visible "Allow" buttons: the DOM can contain multiple + // widgets (e.g. a stale one plus the currently active one, or a + // sandbox-prerequisite confirmation plus the terminal one), and only + // one is user-actionable at a time. `.first()` alone would race on + // element order and often pick a hidden one. + const allowButtons = code.driver.currentPage + .locator('.chat-confirmation-widget2 .monaco-button.monaco-text-button') + .filter({ hasText: /^Allow$/ }); + const total = await allowButtons.count(); + for (let i = 0; i < total; i++) { + const b = allowButtons.nth(i); + if (await b.isVisible()) { + await b.click({ timeout: 2_000 }); + return; + } + } + } catch { + // Ignore: the confirmation may not be present, may not be actionable + // yet, or may have just been dismissed between the query and the click. + // The surrounding poll retries. + } +} diff --git a/test/automation/src/playwrightDriver.ts b/test/automation/src/playwrightDriver.ts index 6e953e60f144b1..386d2e3225cd7d 100644 --- a/test/automation/src/playwrightDriver.ts +++ b/test/automation/src/playwrightDriver.ts @@ -158,8 +158,8 @@ export class PlaywrightDriver { /** * Get the accessibility snapshot of the current window. */ - async getAccessibilitySnapshot(): Promise<playwright.Accessibility['snapshot'] extends () => Promise<infer T> ? T : never> { - return await this.page.accessibility.snapshot(); + async getAccessibilitySnapshot(): Promise<string> { + return await this.page.ariaSnapshot({ mode: 'ai' }); } /** diff --git a/test/componentFixtures/blocks-ci-screenshots.md b/test/componentFixtures/blocks-ci-screenshots.md index 32ee0852cb9069..4baf0de5ff03ed 100644 --- a/test/componentFixtures/blocks-ci-screenshots.md +++ b/test/componentFixtures/blocks-ci-screenshots.md @@ -1,19 +1,19 @@ <!-- auto-generated by CI — do not edit manually --> #### editor/codeEditor/CodeEditor/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/67bfb687fd2818bd53771a60660541b9ed6f38b80d37da0aac15d267ecaeacec) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/af3df9d90346c8473e8576c2aab60a9cab514ed0a130a2208fa72851e4884852) #### editor/codeEditor/CodeEditor/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/0469dd8d0a587d94a1eaec514c79917b93b9a38694ef2b767bb1892819ae0a55) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/62651503e0db22d7800352a848d4c6db4cdc56499fa40d5eac681958e9aea19a) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/34445847f76a97dce08d34a25919c6a7f273425a1c602526d3798c9079f83723) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/6f5e2359cd3f3ee8a19538f9c70d9571c7c07a3bf7074be85c1ecd79a038451d) #### editor/inlineChatZoneWidget/InlineChatZoneWidget/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/b3b7ad4ccee01fe410c769addfc113b643549abf5cd53243d9709e5afecc7ec2) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/ae6445602445f495f25edba110c91a658b8d75040028f9a104421644de5b2d0f) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Dark -![screenshot](https://hediet-screenshots.azurewebsites.net/images/2fbc12507b59ff950d9612d2df92e6b39d8bf0bf500478e42eca2ead4d1ae206) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/0752cf02ae3a4e21fce84b62859df32a5f41c13622bdec0083a3fd46832c2e0a) #### editor/inlineChatZoneWidget/InlineChatZoneWidgetTerminated/Light -![screenshot](https://hediet-screenshots.azurewebsites.net/images/4632ab04d1fdd7db9ab0e00cce10aefb7a6344eb8869dfce740309a8801cab73) +![screenshot](https://hediet-screenshots.azurewebsites.net/images/a29cfc0bf4510b57c82d9eae0d974babe7035042456326be861308cae609a1b5) diff --git a/test/componentFixtures/component-explorer.json b/test/componentFixtures/component-explorer.json index d3d574d0def667..61cfa846a3734b 100644 --- a/test/componentFixtures/component-explorer.json +++ b/test/componentFixtures/component-explorer.json @@ -20,7 +20,7 @@ "cmd": "npm run serve-out-rspack", "cwd": "../../", "wait": { - "stderr": "Loopback: http://localhost:(?<port>\\d+)/," + "stdout": "http://localhost:(?<port>\\d+)/" }, "url": "http://localhost:${var:port}" } diff --git a/test/componentFixtures/playwright/package-lock.json b/test/componentFixtures/playwright/package-lock.json index 0474afe05c7490..95e0e7f6ddca79 100644 --- a/test/componentFixtures/playwright/package-lock.json +++ b/test/componentFixtures/playwright/package-lock.json @@ -9,19 +9,19 @@ "version": "0.1.0", "license": "MIT", "devDependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.61.1", "@types/node": "24.x", "typescript": "^5.8.0" } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -56,13 +56,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.61.1" }, "bin": { "playwright": "cli.js" @@ -75,9 +75,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", "dev": true, "license": "Apache-2.0", "bin": { diff --git a/test/componentFixtures/playwright/package.json b/test/componentFixtures/playwright/package.json index debce5e338b767..04df31ad630303 100644 --- a/test/componentFixtures/playwright/package.json +++ b/test/componentFixtures/playwright/package.json @@ -10,7 +10,7 @@ "test:debug": "playwright test --debug" }, "devDependencies": { - "@playwright/test": "^1.52.0", + "@playwright/test": "^1.61.1", "@types/node": "24.x", "typescript": "^5.8.0" } diff --git a/test/integration/browser/package-lock.json b/test/integration/browser/package-lock.json index 1f44058fcea8ef..4c93085a4527c8 100644 --- a/test/integration/browser/package-lock.json +++ b/test/integration/browser/package-lock.json @@ -13,7 +13,7 @@ "@types/rimraf": "^2.0.4", "@types/tmp": "0.1.0", "rimraf": "^2.6.1", - "tmp": "0.2.6", + "tmp": "0.2.7", "tree-kill": "1.2.2", "vscode-uri": "^3.0.2" } @@ -179,9 +179,9 @@ } }, "node_modules/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", "dev": true, "license": "MIT", "engines": { diff --git a/test/integration/browser/package.json b/test/integration/browser/package.json index b48e2564bf9c70..b38f1b36f528f8 100644 --- a/test/integration/browser/package.json +++ b/test/integration/browser/package.json @@ -11,7 +11,7 @@ "@types/rimraf": "^2.0.4", "@types/tmp": "0.1.0", "rimraf": "^2.6.1", - "tmp": "0.2.6", + "tmp": "0.2.7", "tree-kill": "1.2.2", "vscode-uri": "^3.0.2" } diff --git a/test/mcp/src/automationTools/windows.ts b/test/mcp/src/automationTools/windows.ts index ab6076ec234349..4004626f29fc00 100644 --- a/test/mcp/src/automationTools/windows.ts +++ b/test/mcp/src/automationTools/windows.ts @@ -95,7 +95,7 @@ export function applyWindowTools(server: McpServer, appService: ApplicationServi const snapshot = await driver.getAccessibilitySnapshot(); const url = driver.currentPage.url(); - return textResponse(`Page snapshot (URL: ${url}):\n\n${JSON.stringify(snapshot, null, 2)}`); + return textResponse(`Page snapshot (URL: ${url}):\n\n${snapshot}`); } )); diff --git a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts index dffc8d1f7662c3..332e7714422dd3 100644 --- a/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts +++ b/test/smoke/src/areas/agentsWindow/agentsWindow.test.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Application, ApplicationOptions, Logger } from '../../../../automation'; import { createApp, dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAppAfterHandler, installDiagnosticsHandler, installAllHandlers, MockLlmServer, suiteCrashPath, suiteLogsPath } from '../../utils'; +import { shellEchoResponseMatcher, shellEchoScenario } from '../chat/shellScenarios'; // Selector for the send button in the Agents Window new-session homepage. // Kept in sync with `SEND_BUTTON_ENABLED` in `test/automation/src/agentsWindow.ts` @@ -209,6 +210,79 @@ export function setup(logger: Logger) { let mockServer: MockLlmServer; + // Shell-tool scenarios for each session type. Each entry carries + // everything the registration step and the corresponding test need — + // scenario id, expected echoed reply, and the mock-LLM scenario + // factory (different tool names per surface: `bash`/`pwsh`/ + // `powershell` for SDK sessions vs `run_in_terminal` for the Local + // agent), plus optional per-session hooks for cold-start warm-up and + // extra assertions. Keeping the data here avoids drift between the + // scenario registration and the test that consumes it. + interface ShellSession { + readonly name: string; + readonly sessionType: string; + readonly scenarioId: string; + readonly reply: string; + readonly scenarioFactory: (reply: string) => unknown; + /** + * Override `chat.cli.sandbox.enabled` to `'off'` for this test. + * The Agents Window suite enables the Copilot CLI sandbox at the + * suite level (for the "Test Copilot CLI session (sandbox)" + * test), but the Win32 AppContainer backend returns + * `Experimental_CreateProcessInSandbox returned E_NOTIMPL` on dev + * machines without the corresponding velocity feature flags + * (61389575, 61155944) enabled, which would fail any Copilot + * shell-tool test on Windows. Set this for non-sandbox Copilot + * tests so they exercise the plain (non-sandboxed) shell path + * everywhere — including Windows dev machines and CI. + */ + readonly disableCliSandbox?: boolean; + /** Optional cold-start warm-up (e.g. Claude SDK bundling). */ + readonly warmUp?: (app: Application, label: string) => Promise<void>; + /** Optional extra assertion run after the chat reply lands. */ + readonly extraAssertion?: (app: Application) => Promise<void>; + } + + const SHELL_SESSIONS: readonly ShellSession[] = [ + { + name: 'Copilot', + sessionType: 'Copilot', + scenarioId: 'smoke-hello-copilot-shell', + reply: 'MOCKED_COPILOT_SHELL_RESPONSE', + scenarioFactory: shellEchoScenario, + disableCliSandbox: true, + // Confirm the shell tool actually executed by checking the + // CopilotCLISession diagnostic log. We don't care whether + // the command was sandboxed for this test. + extraAssertion: async (app) => { + const chatLogPath = path.join(app.logsPath, 'window2', 'exthost', 'GitHub.copilot-chat', 'GitHub Copilot Chat.log'); + const chatLog = await fs.promises.readFile(chatLogPath, 'utf8'); + assert.match( + chatLog, + /\[CopilotCLISession\] tool\.execution_complete /, + `expected tool.execution_complete in ${chatLogPath}` + ); + }, + }, + { + name: 'Claude', + sessionType: 'Claude', + scenarioId: 'smoke-hello-claude-shell', + reply: 'MOCKED_CLAUDE_SHELL_RESPONSE', + scenarioFactory: shellEchoScenario, + // Pre-pay the Claude cold-start cost so the real assertion + // below runs against a warm pipeline (see warmUpClaudeModel). + warmUp: (app, label) => warmUpClaudeModel(app, logger, label), + }, + // Note: there is intentionally no "Local" entry. The Local agent + // in the Agents Window does not include `run_in_terminal` in its + // advertised tool set, so the model's tool call is rejected with + // "Tool run_in_terminal is currently disabled by the user". + // The Chat Sessions "Test Local session run in terminal" test + // already covers `run_in_terminal` against the regular chat panel + // where the tool is available. + ]; + // Start the mock server BEFORE installAllHandlers' `before` runs so // the mock URL is available when we configure the app's env vars via // `optionsTransform`. @@ -226,21 +300,14 @@ export function setup(logger: Logger) { registerScenario(session.scenarioId2, new ScenarioBuilder().emit(session.reply2).build()); } - registerScenario(COPILOT_SANDBOX_SCENARIO_ID, { - type: 'multi-turn', - turns: [ - { - kind: 'tool-calls', - toolCalls: [ - { - toolNamePattern: /^(bash|pwsh|powershell)$/i, - arguments: { command: `echo ${COPILOT_SANDBOX_REPLY}` }, - }, - ], - }, - { kind: 'echo-last-message' }, - ], - }); + registerScenario(COPILOT_SANDBOX_SCENARIO_ID, shellEchoScenario(COPILOT_SANDBOX_REPLY)); + + // Shell-tool scenarios for the non-sandbox shell-tool tests + // (auto-approved by the default `chat.tools.terminal.autoApprove` + // entry for `echo`). + for (const shellSession of SHELL_SESSIONS) { + registerScenario(shellSession.scenarioId, shellSession.scenarioFactory(shellSession.reply)); + } registerScenario(CLAUDE_WARMUP_SCENARIO_ID, new ScenarioBuilder().emit(CLAUDE_WARMUP_REPLY).build()); @@ -483,6 +550,61 @@ export function setup(logger: Logger) { `expected tool.execution_complete with sandboxed=true in ${chatLogPath}` ); }); + + // Shell-tool variants for each session type — exercise the + // model-driven shell tool (`bash` / `pwsh` / `powershell` for the SDK + // sessions) on the first prompt and verify both that the command + // actually ran (the JSON tool result contains the echoed marker) and + // that the reply rendered in the chat. These run the "non-sandbox" + // path: the shell command surfaces a terminal confirmation, which the + // wait helper accepts by clicking "Allow" (a no-op for sessions that + // auto-approve their shell commands). + for (const shellSession of SHELL_SESSIONS) { + it(`Test ${shellSession.name} session run in terminal`, async function () { + const app = this.app as Application; + const label = `Agents Window/${shellSession.name} shell`; + try { + if (shellSession.disableCliSandbox) { + // Override the suite-level `chat.cli.sandbox.enabled: 'on'` + // (set in the suite `before` for the sandbox test) so the + // SDK runs the shell tool without the Win32 AppContainer + // backend, which fails with E_NOTIMPL on dev machines and + // CI agents that lack the velocity feature flags. Write + // directly to settings.json on disk (the configuration + // service has a file watcher) rather than opening the + // settings editor — that would steal focus from the + // Agents Window UI under test. + await overrideUserSettingOnDisk(app, 'github.copilot.chat.cli.sandbox.enabled', 'off'); + } + await app.workbench.agentsWindow.startNewSession(); + await app.workbench.agentsWindow.waitForNewSessionView(); + if (shellSession.warmUp) { + await shellSession.warmUp(app, label); + } else { + await app.workbench.agentsWindow.selectSessionType(shellSession.sessionType); + } + + const requestsBefore = mockServer.requestCount(); + await app.workbench.agentsWindow.submitNewSessionPrompt(`hello world [scenario:${shellSession.scenarioId}]`); + + const text = await app.workbench.agentsWindow.waitForAssistantText(shellEchoResponseMatcher(shellSession.reply), 120_000, { acceptToolConfirmations: true }); + logger.log(`${label} response: ${text}`); + + assert.ok( + mockServer.requestCount() > requestsBefore, + `expected the mock LLM server to have received a new request from the ${shellSession.name} shell session` + ); + + if (shellSession.extraAssertion) { + await shellSession.extraAssertion(app); + } + } catch (error) { + logger.log(`[${label}] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + await dumpFailureDiagnostics(app, logger, label, { sendButtonSelector: AGENTS_SEND_BUTTON_SELECTOR }); + throw error; + } + }); + } }); describe('Agents Window (model configuration)', function () { @@ -661,6 +783,15 @@ export function setup(logger: Logger) { // turns the sandbox on for the auto-approve path used by the sandbox test. 'chat.agentHost.customTerminalTool.enabled': true, 'chat.agent.sandbox.enabled': 'on', + // CI macOS runners commonly resolve the default shell as /bin/sh, which + // exercises the sentinel-based completion parser path. Force the same + // profile on macOS so local runs cover the same branch. + ...(process.platform === 'darwin' ? { + 'terminal.integrated.profiles.osx': { + 'Smoke AgentHost Sandbox sh': { path: '/bin/sh' }, + }, + 'terminal.integrated.defaultProfile.osx': 'Smoke AgentHost Sandbox sh', + } : {}), }, }); @@ -761,6 +892,13 @@ export function setup(logger: Logger) { engineShellRun, `expected the AgentHost's own shell engine ([ShellManager]) to have run the command in ${agentHostLogPath}` ); + if (process.platform === 'darwin') { + assert.match( + agentHostLog, + /\[ShellManager\] Created \w+ shell .*executable=\/bin\/sh\)/, + `expected the macOS AgentHost sandbox smoke test to run under /bin/sh (CI parity and sentinel-parser coverage), in ${agentHostLogPath}` + ); + } assert.doesNotMatch( agentHostLog, /Applied SDK sandboxConfig/, @@ -927,6 +1065,27 @@ export function setup(logger: Logger) { this.skip(); } + // Codex reports as "available" once the `@openai/codex` launcher shim + // resolves, but the native binary ships as a separate per-platform + // optional dependency that npm silently skips when its install fails. + // A stale `node_modules` cache can thus have the shim but no binary, so + // fail fast here (from source) instead of timing out at spawn time. + if (process.env['VSCODE_DEV'] === '1') { + const repoRoot = path.resolve(process.cwd(), '..', '..'); + const platformPkgDir = path.join(repoRoot, 'node_modules', `@openai/codex-${process.platform}-${process.arch}`); + const binaryName = process.platform === 'win32' ? 'codex.exe' : 'codex'; + let codexBinaryFound = false; + try { + const vendorDir = path.join(platformPkgDir, 'vendor'); + codexBinaryFound = fs.readdirSync(vendorDir).some(triple => fs.existsSync(path.join(vendorDir, triple, 'bin', binaryName))); + } catch { + // vendor dir (or the whole platform package) is missing → treated as not found + } + if (!codexBinaryFound) { + throw new Error(`[Agents Window/Codex] Codex native binary missing at ${platformPkgDir}. We depend on \`@openai/codex\`, which is only a thin launcher shim; the actual native binaries ship as its per-platform optional dependencies (\`@openai/codex-<platform>-<arch>\`). \`npm install\` does not fail when an optional dependency can't be installed, so node_modules can end up with the shim but no binary — Codex then reports as "available" but has nothing to spawn. Try bumping build/.cachesalt to force a fresh \`npm ci\` that reinstalls the binary.`); + } + } + try { // Pre-pay the Codex session cold-start cost: the first Codex session // in a fresh agent host has to spawn the native codex app-server and @@ -966,54 +1125,6 @@ export function setup(logger: Logger) { }); } -/** - * Builds a two-turn mock scenario that exercises a sandboxed shell tool: the - * model first runs `echo <reply>` via the bash/pwsh/powershell tool, then — - * after the tool result round-trips — replays the last (tool-result) message - * back as a ```json fenced block via `echo-last-message`. - * - * The reply text therefore appears in two kinds of `.rendered-markdown` - * elements (both searched by {@link AgentsWindow.waitForAssistantText}): - * 1. the terminal tool-call's command preview — rendered as `echo <reply>` - * (the bareword, no surrounding quotes), and - * 2. the final assistant response — the JSON dump of the tool result, an - * object whose `output` field holds the echoed `<reply>` (possibly with - * a prefix, e.g. shell-integration noise, and an `<exited ...>` suffix). - * To assert on the real response (2) and not the command preview (1), callers - * match with {@link shellEchoResponseMatcher} — see the sandbox tests. - */ -function shellEchoScenario(reply: string) { - return { - type: 'multi-turn', - turns: [ - { - kind: 'tool-calls', - toolCalls: [ - { - toolNamePattern: /^(bash|pwsh|powershell)$/i, - arguments: { command: `echo ${reply}` }, - }, - ], - }, - { kind: 'echo-last-message' }, - ], - }; -} - -/** - * Builds the {@link AgentsWindow.waitForAssistantText} matcher for a - * {@link shellEchoScenario} reply. The final response renders the tool result - * as a ```json block of the form - * `{ ..., "output": "<reply>\n<exited with exit code 0>" }`, so anchoring on - * `"output": ... <reply>` matches that JSON value specifically — not the - * `echo <reply>` command preview (which has no `"output"` field) — while still - * tolerating any prefix inside the captured output (e.g. shell-integration - * noise). `<reply>` contains no regex metacharacters. - */ -function shellEchoResponseMatcher(reply: string): RegExp { - return new RegExp(`"output":.*${reply}`); -} - /** * Primes a freshly-spawned AgentHost process's CLI model list to avoid the * cold-start "No model available" race (github/copilot-agent-runtime#9876): @@ -1249,3 +1360,34 @@ function ahpJsonlFiles(ahpLogDir: string): string[] { function readAhpFrames(ahpLogDir: string): string { return ahpJsonlFiles(ahpLogDir).map(f => fs.readFileSync(path.join(ahpLogDir, f), 'utf8')).join('\n'); } + +/** + * Override a single user-scope VS Code setting by editing + * `<userDataDir>/User/settings.json` directly on disk. The configuration + * service watches the file and picks up the change. Preferred over + * {@link Settings.addUserSetting} when the workbench has switched to a + * secondary window (Agents Window) where opening the settings editor would + * steal focus from the UI under test. + */ +async function overrideUserSettingOnDisk(app: Application, key: string, value: unknown): Promise<void> { + const userDataDir = app.userDataPath; + if (!userDataDir) { + throw new Error('overrideUserSettingOnDisk: app.userDataPath is unset'); + } + const settingsPath = path.join(userDataDir, 'User', 'settings.json'); + let current: Record<string, unknown> = {}; + try { + const raw = await fs.promises.readFile(settingsPath, 'utf8'); + // Strip trailing comma the settings editor may emit and accept JSONC. + current = JSON.parse(raw.replace(/,(\s*[}\]])/g, '$1')) as Record<string, unknown>; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + throw err; + } + } + current[key] = value; + await fs.promises.writeFile(settingsPath, JSON.stringify(current, null, '\t')); + // The configuration service debounces file watcher events; give it a + // moment to pick up the change before downstream code reads the setting. + await new Promise(resolve => setTimeout(resolve, 500)); +} diff --git a/test/smoke/src/areas/chat/chatSessions.test.ts b/test/smoke/src/areas/chat/chatSessions.test.ts index e7c4a6b31d91da..db55428d21135f 100644 --- a/test/smoke/src/areas/chat/chatSessions.test.ts +++ b/test/smoke/src/areas/chat/chatSessions.test.ts @@ -6,6 +6,7 @@ import * as assert from 'assert'; import { Application, Chat, Logger } from '../../../../automation'; import { dumpFailureDiagnostics, getCopilotSmokeTestEnv, getMockLlmServerPath, installAllHandlers, MockLlmServer } from '../../utils'; +import { runInTerminalScenario, shellEchoResponseMatcher, shellEchoScenario } from './shellScenarios'; /** * Per-session scenarios. Each session uses a pair of unique scenario ids so @@ -35,7 +36,29 @@ const SESSIONS: readonly SessionConfig[] = [ { name: 'Local', command: 'workbench.action.chat.open', kind: 'view', scenarioId: 'smoke-chat-sessions-local', reply: 'MOCKED_CHAT_SESSIONS_LOCAL_RESPONSE', scenarioId2: 'smoke-chat-sessions-local-2', reply2: 'MOCKED_CHAT_SESSIONS_LOCAL_RESPONSE_2' }, ]; -async function openSession(app: Application, session: SessionConfig): Promise<void> { +/** + * Per-session shell-tool scenarios. Each session triggers a shell tool call + * on the first prompt and verifies the echoed marker appears in the chat + * (which proves both that the command ran and that the reply was rendered). + * SDK-based sessions (Copilot CLI, Claude) advertise `bash`/`pwsh`/ + * `powershell`; the Local chat agent advertises `run_in_terminal`. + */ +interface ShellSessionConfig { + readonly name: string; + readonly command: string; + readonly kind: 'editor' | 'view'; + readonly scenarioId: string; + readonly reply: string; + readonly scenarioFactory: (reply: string) => unknown; +} + +const SHELL_SESSIONS: readonly ShellSessionConfig[] = [ + { name: 'Copilot CLI', command: 'smoketest.openCopilotCliChat', kind: 'editor', scenarioId: 'smoke-chat-sessions-copilot-cli-shell', reply: 'MOCKED_CHAT_SESSIONS_COPILOT_CLI_SHELL_RESPONSE', scenarioFactory: shellEchoScenario }, + { name: 'Claude', command: 'smoketest.openClaudeChat', kind: 'editor', scenarioId: 'smoke-chat-sessions-claude-shell', reply: 'MOCKED_CHAT_SESSIONS_CLAUDE_SHELL_RESPONSE', scenarioFactory: shellEchoScenario }, + { name: 'Local', command: 'workbench.action.chat.open', kind: 'view', scenarioId: 'smoke-chat-sessions-local-terminal', reply: 'MOCKED_CHAT_SESSIONS_LOCAL_TERMINAL_RESPONSE', scenarioFactory: runInTerminalScenario }, +]; + +async function openSession(app: Application, session: { readonly command: string; readonly kind: 'editor' | 'view' }): Promise<void> { await app.workbench.quickaccess.runCommand(session.command); if (session.kind === 'editor') { await app.workbench.chat.waitForChatEditor(600); @@ -84,6 +107,14 @@ export function setup(logger: Logger) { registerScenario(session.scenarioId2, new ScenarioBuilder().emit(session.reply2).build()); } + // Shell-tool scenarios. `echo` is in the default + // `chat.tools.terminal.autoApprove` list, so no extra settings + // are required to auto-approve the command — these tests + // deliberately exercise the non-sandbox shell-tool path. + for (const shellSession of SHELL_SESSIONS) { + registerScenario(shellSession.scenarioId, shellSession.scenarioFactory(shellSession.reply)); + } + mockServer = await startServer(0, { logger: (msg: string) => logger.log(`[mock-llm] ${msg}`) }); logger.log(`[Chat Sessions] mock LLM server started at ${mockServer.url} (platform=${process.platform}, arch=${process.arch}, node=${process.version})`); logger.log(`[Chat Sessions] env: VSCODE_DEV=${process.env.VSCODE_DEV ?? '<unset>'}, VSCODE_QUALITY=${process.env.VSCODE_QUALITY ?? '<unset>'}, BUILD_SOURCEBRANCH=${process.env.BUILD_SOURCEBRANCH ?? '<unset>'}, GITHUB_RUN_ID=${process.env.GITHUB_RUN_ID ?? '<unset>'}, GITHUB_ACTIONS=${process.env.GITHUB_ACTIONS ?? '<unset>'}`); @@ -131,6 +162,13 @@ export function setup(logger: Logger) { // would route through the ms-vscode.vscode-claude-sdk extension, // which would attempt a network install during the smoke run). ['github.copilot.chat.claudeAgent.useSdkExtension', 'false'], + // Disable the LLM-generated tool risk assessment. It issues a + // separate model request whose mock reply ("OK") never resolves + // to a valid Safe/Caution/Review verdict, which would otherwise + // leave the terminal confirmation stuck in the "Assessing risk…" + // state so its "Allow" button never becomes available. The + // shell-tool tests need to click "Allow" to proceed. + ['chat.tools.riskAssessment.enabled', 'false'], ]); logger.log(`[Chat Sessions] user settings written; requestCount=${mockServer.requestCount()}`); }); @@ -187,5 +225,58 @@ export function setup(logger: Logger) { } }); } + + // Shell-tool variant per session — exercises the model-driven shell + // tool (`bash`/`pwsh`/`powershell` for the SDK sessions, or + // `run_in_terminal` for the Local session) on the first prompt and + // verifies both that the command actually ran (the JSON tool result + // contains the echoed marker) and that the reply was rendered in the + // chat. `echo` is in the default `chat.tools.terminal.autoApprove` + // list, so no extra auto-approve settings are required. + for (const shellSession of SHELL_SESSIONS) { + it(`Test ${shellSession.name} session run in terminal`, async function () { + const app = this.app as Application; + const requestsBefore = mockServer.requestCount(); + logger.log(`[Chat Sessions/${shellSession.name} shell] starting test; requestCount=${requestsBefore}`); + + try { + await openSession(app, shellSession); + + const prompt = `hello world [scenario:${shellSession.scenarioId}]`; + const matcher = shellEchoResponseMatcher(shellSession.reply); + let responseText: string; + if (shellSession.kind === 'editor') { + await app.workbench.chat.sendEditorMessage(prompt); + // 120s timeout — SDK + shell tool round-trip can be slow on cold CI. + // acceptToolConfirmations clicks "Allow" on the terminal + // confirmation so the command runs (no-op when the session + // auto-approves). + responseText = (await app.workbench.chat.waitForEditorResponseText(matcher, 120_000, { acceptToolConfirmations: true })).trim(); + } else { + await app.workbench.chat.sendMessage(prompt); + responseText = (await app.workbench.chat.waitForResponseText(matcher, 120_000, { acceptToolConfirmations: true })).trim(); + } + logger.log(`Chat Sessions (${shellSession.name} shell) response: ${responseText}`); + + assert.match( + responseText, + matcher, + `Expected ${shellSession.name} shell response to include the echoed marker "${shellSession.reply}" inside a JSON tool result string.\n\nResponse:\n${responseText}` + ); + + assert.ok( + mockServer.requestCount() > requestsBefore, + `expected the mock LLM server to have received a new request from the ${shellSession.name} shell session (before=${requestsBefore}, after=${mockServer.requestCount()})` + ); + } catch (error) { + logger.log(`[Chat Sessions/${shellSession.name} shell] FAILURE: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + logger.log(`[Chat Sessions/${shellSession.name} shell] mock server requestCount at failure: ${mockServer.requestCount()} (before=${requestsBefore})`); + await dumpFailureDiagnostics(app, logger, `Chat Sessions/${shellSession.name} shell`); + throw error; + } finally { + await app.workbench.quickaccess.runCommand('workbench.action.closeAllEditors'); + } + }); + } }); } diff --git a/test/smoke/src/areas/chat/shellScenarios.ts b/test/smoke/src/areas/chat/shellScenarios.ts new file mode 100644 index 00000000000000..6f986a4705bd6a --- /dev/null +++ b/test/smoke/src/areas/chat/shellScenarios.ts @@ -0,0 +1,97 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Mock-LLM scenarios + response matchers used by smoke tests that exercise a + * model-driven shell tool invocation and verify both: + * 1. The tool actually ran (the tool result contains the echoed marker), and + * 2. The reply appears in the chat (the same marker is rendered). + * + * Two variants are provided to cover the different tool names advertised by + * each surface: + * - {@link shellEchoScenario} matches SDK-based sessions (Copilot CLI, + * Claude, AgentHost), which expose `bash` / `pwsh` / `powershell` tools. + * - {@link runInTerminalScenario} matches the VS Code built-in chat agent + * (used by the "Local" session), which exposes the `run_in_terminal` + * tool. + * + * Both scenarios use a two-turn protocol: + * - Turn 1 (`tool-calls`): the model asks the tool to execute + * `echo <reply>`. + * - Turn 2 (`echo-last-message`): the model replays the last + * (tool-result) message back as a ```json fenced block, so the reply + * appears in the assistant rendering. + * + * Importantly, `echo` is in the default `chat.tools.terminal.autoApprove` + * list, so no extra settings are required to auto-approve the command. + */ + +export function shellEchoScenario(reply: string): unknown { + return { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /^(bash|pwsh|powershell)$/i, + arguments: { command: `echo ${reply}` }, + }, + ], + }, + { kind: 'echo-last-message' }, + ], + }; +} + +export function runInTerminalScenario(reply: string): unknown { + return { + type: 'multi-turn', + turns: [ + { + kind: 'tool-calls', + toolCalls: [ + { + toolNamePattern: /^run_in_terminal$/, + arguments: { + command: `echo ${reply}`, + explanation: 'Smoke test echo to verify run_in_terminal', + goal: 'Echo a marker to verify terminal execution', + mode: 'sync', + }, + }, + ], + }, + { kind: 'echo-last-message' }, + ], + }; +} + +/** + * Matcher for the assistant text produced by {@link shellEchoScenario} or + * {@link runInTerminalScenario}. The final response renders the tool result + * as a ```json block; the exact format varies by surface: + * - Copilot CLI (responses-API): `{ "output": "<reply>" }` + * - Local `run_in_terminal`: `{ "output": "<reply>..." }` + * - Claude SDK Bash (messages-API): `{ "content": "<reply>" }` + * - AgentHost custom terminal tool: `{ "output": "ShellID: ...\nExitcode: 0\n<reply>" }` + * + * Anchoring on a JSON double-quote OR newline immediately preceding the reply + * matches: + * - the value side of any `"<key>": "<reply>..."` pair (Copilot CLI, Claude, + * Local `run_in_terminal`), AND + * - the AgentHost custom-terminal format where the reply is preceded by + * `\n` inside a larger `"output"` string. + * + * This excludes the `echo <reply>` command preview (a bareword surrounded by + * whitespace, not `"` or `\n`). `<reply>` must not contain regex + * metacharacters. + */ +export function shellEchoResponseMatcher(reply: string): RegExp { + // The rendered JSON block collapses `\n` in the string value into a real + // newline in `textContent`, but the assistant output can also contain + // literal escaped `\n` when the JSON is displayed verbatim; match either. + return new RegExp(`(?:"|\\n|\\\\n)${reply}`); +} diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index 0a34d3d30f087c..7a1737b9016231 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -134,7 +134,19 @@ function getTestTypeSuffix(): string { } } -const testDataPath = path.join(os.tmpdir(), `vscsmoke-${getTestTypeSuffix()}`); +function getTmpDir(): string { + const tmpDir = os.tmpdir(); + if (process.platform === 'win32') { + try { + return fs.realpathSync.native(tmpDir); + } catch { + // ignore and fall back to the short path + } + } + return tmpDir; +} + +const testDataPath = path.join(getTmpDir(), `vscsmoke-${getTestTypeSuffix()}`); if (fs.existsSync(testDataPath)) { fs.rmSync(testDataPath, { recursive: true, force: true, maxRetries: 10, retryDelay: 1000 }); }